From f850e52fc4af98e42e0ee62483b628eba5d91b0c Mon Sep 17 00:00:00 2001 From: Travis Vasceannie Date: Fri, 16 Jan 2026 17:01:10 +0000 Subject: [PATCH] docs: replace lib-focused code review with comprehensive frontend architecture review covering React components, pages, and e2e tests - Replaced codefixes.md with broader frontend review covering React/TypeScript patterns, Tauri integration, component architecture, and test coverage - Added pages.md documenting critical bugs (React key collision in Analytics pie chart, fire-and-forget error swallowing), API architecture patterns, and code duplication opportunities - Identified must-fix issues: Speaker --- .claudectx/codefixes.md | 207 +- .claudectx/pages.md | 136 + client/biome.json | 9 +- client/e2e-native-mac/app.spec.ts | 24 +- client/e2e-native/app.spec.ts | 6 +- client/e2e-native/connection.spec.ts | 34 +- client/e2e-native/diarization.spec.ts | 1 - client/e2e-native/export.spec.ts | 1 - client/e2e-native/fixtures.ts | 83 +- client/e2e-native/globals.d.ts | 5 +- client/e2e-native/lifecycle.spec.ts | 79 +- client/e2e-native/meetings.spec.ts | 28 +- client/e2e-native/recording.spec.ts | 25 +- client/e2e-native/roundtrip.spec.ts | 15 +- client/e2e/fixtures.ts | 19 +- client/e2e/oidc-providers.spec.ts | 6 +- client/e2e/post-processing.spec.ts | 15 +- client/e2e/settings-ui.spec.ts | 13 +- .../src/audio/drift_compensation/detector.rs | 20 +- .../src/audio/drift_compensation/metrics.rs | 25 +- .../src/audio/drift_compensation/resampler.rs | 27 +- client/src-tauri/src/audio/mixer.rs | 10 +- client/src/App.tsx | 5 +- client/src/api/cached/apps.ts | 5 +- client/src/api/cached/streaming.ts | 5 +- client/src/api/error-utils.ts | 5 +- client/src/api/index.ts | 5 +- client/src/api/interface.ts | 4 +- client/src/api/mock-adapter.ts | 3 +- client/src/api/tauri-adapter.ts | 81 +- .../api/tauri-transcription-stream.test.ts | 45 +- client/src/api/types/features/asr.ts | 15 +- client/src/components/analytics/log-entry.tsx | 46 +- .../src/components/analytics/log-timeline.tsx | 75 +- .../components/analytics/logs-tab-list.tsx | 143 + .../components/analytics/logs-tab.test.tsx | 42 +- client/src/components/analytics/logs-tab.tsx | 146 +- client/src/components/confirmation-dialog.tsx | 7 +- client/src/components/connection-status.tsx | 2 +- client/src/components/dev-profiler.tsx | 29 +- .../calendar-config.tsx | 4 +- .../src/components/processing-status.test.tsx | 8 +- .../projects/ProjectMembersPanel.tsx | 2 +- .../projects/ProjectSettingsPanel.tsx | 2 +- .../recording/audio-device-selector.tsx | 8 + .../recording/audio-level-meter.tsx | 17 +- .../advanced-local-ai-settings/_constants.ts | 16 +- .../advanced-local-ai-settings/index.tsx | 8 +- .../model-auth-section.tsx | 24 +- .../resource-fit-panel.tsx | 6 +- .../streaming-config-section.tsx | 83 +- .../transcription-engine-section.tsx | 33 +- .../components/settings/ai-config-hooks.ts | 6 +- .../components/settings/ai-config-models.ts | 6 +- .../components/settings/ai-config-section.tsx | 163 +- .../settings/audio-devices-section.tsx | 10 +- .../settings/connection-diagnostics-panel.tsx | 2 +- .../components/settings/export-ai-section.tsx | 15 +- .../custom-integration-dialog.tsx | 6 +- .../settings/integrations-section/index.tsx | 191 +- .../integrations-section/integration-item.tsx | 99 +- .../integration-settings-context.tsx | 45 + .../use-integration-handlers.test.tsx | 319 + .../settings/ollama-status-card.tsx | 8 +- .../settings/provider-config-card.tsx | 8 +- .../settings/recording-app-policy-section.tsx | 14 +- .../settings/summarization-templates-list.tsx | 17 +- .../src/components/tauri-event-listener.tsx | 2 +- .../components/timestamped-notes-editor.tsx | 3 +- client/src/components/ui/icon-circle.tsx | 51 +- client/src/components/ui/scroll-area.tsx | 13 +- .../src/components/webhook-settings-panel.tsx | 11 +- client/src/contexts/connection-context.tsx | 7 +- client/src/contexts/project-context.tsx | 4 +- client/src/contexts/workspace-context.tsx | 3 +- client/src/hooks/post-processing/state.ts | 4 +- client/src/hooks/use-asr-config.ts | 82 +- client/src/hooks/use-audio-devices.helpers.ts | 10 +- client/src/hooks/use-audio-devices.test.ts | 10 +- client/src/hooks/use-audio-devices.ts | 62 +- client/src/hooks/use-audio-testing.ts | 40 +- client/src/hooks/use-calendar-sync.ts | 35 +- client/src/hooks/use-diarization.test.ts | 4 +- client/src/hooks/use-diarization.ts | 36 +- client/src/hooks/use-huggingface-token.ts | 63 +- client/src/hooks/use-integration-sync.ts | 17 +- client/src/hooks/use-meeting-reminders.ts | 2 +- client/src/hooks/use-oauth-flow.ts | 6 +- client/src/hooks/use-post-processing.test.ts | 2 +- client/src/hooks/use-post-processing.ts | 30 +- client/src/hooks/use-project-members.ts | 36 +- client/src/hooks/use-recording-app-policy.ts | 6 +- client/src/hooks/use-recording-session.ts | 15 +- .../hooks/use-secure-integration-secrets.ts | 61 +- client/src/hooks/use-streaming-config.ts | 5 +- client/src/lib/ai-providers/fetch-models.ts | 298 +- .../lib/ai-providers/model-catalog-utils.ts | 2 +- .../lib/ai-providers/strategies/anthropic.ts | 83 + .../src/lib/ai-providers/strategies/azure.ts | 141 + .../src/lib/ai-providers/strategies/custom.ts | 124 + .../lib/ai-providers/strategies/deepgram.ts | 129 + .../lib/ai-providers/strategies/elevenlabs.ts | 74 + .../src/lib/ai-providers/strategies/google.ts | 75 + .../src/lib/ai-providers/strategies/index.ts | 87 + .../src/lib/ai-providers/strategies/ollama.ts | 70 + .../src/lib/ai-providers/strategies/openai.ts | 134 + .../strategies/strategies.test.ts | 1175 + .../src/lib/ai-providers/strategies/types.ts | 111 + client/src/lib/ai-providers/test-endpoint.ts | 294 +- client/src/lib/async-utils.ts | 14 +- client/src/lib/audio-device-ids.test.ts | 5 +- client/src/lib/audio-device-ids.ts | 12 +- ...dio-device-persistence.integration.test.ts | 4 +- client/src/lib/cache/meeting-cache.ts | 86 +- client/src/lib/client-log-events.ts | 23 +- client/src/lib/client-logs.ts | 76 +- client/src/lib/crypto.ts | 120 + client/src/lib/download-utils.ts | 32 + client/src/lib/entity-store.test.ts | 4 +- client/src/lib/entity-store.ts | 5 +- client/src/lib/event-emitter.ts | 10 +- client/src/lib/id-utils.ts | 15 + client/src/lib/log-groups.test.ts | 4 +- client/src/lib/log-groups.ts | 4 +- client/src/lib/log-messages.test.ts | 5 +- client/src/lib/log-messages.ts | 36 +- client/src/lib/log-sanitizer.ts | 83 + client/src/lib/log-summarizer.test.ts | 24 +- client/src/lib/polling-utils.ts | 324 + client/src/lib/preferences-sync.ts | 6 +- client/src/lib/preferences/local-only-keys.ts | 30 + client/src/lib/preferences/tauri.ts | 15 +- client/src/pages/Analytics.tsx | 34 +- client/src/pages/Home.tsx | 2 +- client/src/pages/Meetings.tsx | 2 +- client/src/pages/People.tsx | 12 +- client/src/pages/Recording.logic.test.tsx | 9 +- client/src/pages/Recording.test.tsx | 22 +- client/src/pages/Settings.tsx | 2 +- client/src/pages/Tasks.tsx | 2 +- client/src/pages/meeting-detail/index.tsx | 27 +- .../meeting-detail/use-meeting-detail.ts | 18 +- .../src/pages/meeting-detail/use-playback.ts | 8 +- client/src/pages/settings/AudioTab.tsx | 35 +- client/src/pages/settings/DiagnosticsTab.tsx | 35 +- client/src/pages/settings/IntegrationsTab.tsx | 15 +- client/src/pages/settings/StatusTab.tsx | 5 +- .../src/pages/settings/use-settings-state.ts | 20 +- client/src/test/code-quality.test.ts | 29 +- client/src/types/window.d.ts | 5 +- client/vite.config.ts | 4 +- client/wdio.mac.conf.ts | 12 +- repomix-output.md | 38568 ++++++++++------ repomix.config.json | 2 +- 154 files changed, 30720 insertions(+), 15073 deletions(-) create mode 100644 .claudectx/pages.md create mode 100644 client/src/components/analytics/logs-tab-list.tsx create mode 100644 client/src/components/settings/integrations-section/integration-settings-context.tsx create mode 100644 client/src/components/settings/integrations-section/use-integration-handlers.test.tsx create mode 100644 client/src/lib/ai-providers/strategies/anthropic.ts create mode 100644 client/src/lib/ai-providers/strategies/azure.ts create mode 100644 client/src/lib/ai-providers/strategies/custom.ts create mode 100644 client/src/lib/ai-providers/strategies/deepgram.ts create mode 100644 client/src/lib/ai-providers/strategies/elevenlabs.ts create mode 100644 client/src/lib/ai-providers/strategies/google.ts create mode 100644 client/src/lib/ai-providers/strategies/index.ts create mode 100644 client/src/lib/ai-providers/strategies/ollama.ts create mode 100644 client/src/lib/ai-providers/strategies/openai.ts create mode 100644 client/src/lib/ai-providers/strategies/strategies.test.ts create mode 100644 client/src/lib/ai-providers/strategies/types.ts create mode 100644 client/src/lib/download-utils.ts create mode 100644 client/src/lib/id-utils.ts create mode 100644 client/src/lib/log-sanitizer.ts create mode 100644 client/src/lib/polling-utils.ts create mode 100644 client/src/lib/preferences/local-only-keys.ts diff --git a/.claudectx/codefixes.md b/.claudectx/codefixes.md index cb6461e..52bc283 100644 --- a/.claudectx/codefixes.md +++ b/.claudectx/codefixes.md @@ -1,86 +1,157 @@ -Here is a code review of the provided `client/src/lib` directory. +Here is a comprehensive code review of the provided React/TypeScript codebase. -## Executive Summary +### **Executive Summary** -The codebase demonstrates **high maturity** and a strong focus on reliability. There is a clear emphasis on handling "real world" edge cases that often plague desktop/web hybrid apps, such as audio device ID shifting, offline state synchronization, and backward compatibility for encryption keys. +The codebase represents a high-quality, modern React application likely built for a Tauri environment (Desktop app). It demonstrates a strong grasp of component composition, state management, and UI consistency using Shadcn/Radix primitives. -The modularization of the `preferences` system and the robust testing culture (evident in the companion `.test.ts` files for almost every module) are stand-out features. +**Strengths:** +* **Architecture:** Clear separation of concerns between UI components, business logic hooks, and API layers. +* **UX/UI:** Sophisticated handling of loading states, error boundaries, and empty states. Excellent use of Framer Motion for polish. +* **Tauri Integration:** Robust handling of IPC events, offline states, and secure storage. +* **Testing:** Comprehensive component testing using Vitest and React Testing Library with proper mocking. -## Strengths +**Areas for Improvement:** +* Performance optimization for large lists (Logs/Entities). +* Reduction of prop drilling in complex settings panels. +* Standardization of "Magic Numbers" into constants. +* Browser compatibility fallbacks for specific APIs. -1. **Robust Audio Device Handling (`audio-device-ids.ts`)** - * The logic to resolve audio devices when OS-level IDs change (e.g., "Wave Link (2)" becoming "Wave Link (3)") is excellent. - * The test suite in `audio-device-persistence.integration.test.ts` is rigorous and covers specific, painful hardware scenarios. +--- -2. **Centralized Configuration (`config/`)** - * Moving all magic numbers, timeouts, and API endpoints into `app-config.ts` and `timing-constants.ts` makes the application highly tunable and maintainable. +### **1. Critical & Functional Issues** -3. **Resilient Async Operations (`async-utils.ts`)** - * The `AsyncQueue` and `StreamingQueue` implementations provide necessary backpressure handling for audio streaming and API calls, preventing memory leaks during network congestion. - * The `fireAndForget` wrapper is a safe pattern for non-critical side effects (logging, analytics) that shouldn't crash the app. +#### **A. `crypto.randomUUID` Dependency** +**File:** `client/src/components/timestamped-notes-editor.tsx` (Line 73) +The code uses `crypto.randomUUID()`. While supported in modern browsers and Tauri, it **throws an error** in non-secure contexts (http:// IP addresses other than localhost) or older environments. +* **Recommendation:** Use a library like `uuid` or a utility function wrapper that falls back to `Math.random` if `crypto` is unavailable, to prevent the app from crashing in edge-case network environments. -4. **Sophisticated Logging System** - * The logging subsystem (`client-logs.ts`, `log-summarizer.ts`) is surprisingly advanced. It doesn't just dump text; it structures, groups (by meeting/operation), and summarizes repeated events (e.g., "Processed 45 segments"). This significantly aids debugging in production. - -5. **Secure Storage Strategy (`crypto.ts`)** - * Using `Web Crypto API` with `PBKDF2` key derivation is the correct standard. - * The inclusion of `migrateSecureStorage` shows foresight regarding updating encryption strategies without logging users out. - -## Areas for Improvement & Risks - -### 1. LocalStorage Performance Bottlenecks -In several modules (`meeting-cache.ts`, `client-logs.ts`), data is serialized via `JSON.stringify` and written to `localStorage` synchronously. -* **Risk:** `client-logs.ts` caps at 500 entries. If logs are large (stack traces), stringifying the entire array on every log insertion will cause frame drops (UI jank). -* **Recommendation:** Move heavy logging or caching to `IndexedDB` (using a wrapper like `idb-keyval`) to keep the main thread free, or drastically reduce the synchronous write frequency using a debounce. - -### 2. Crypto Key Persistence (`crypto.ts`) -The encryption key is derived from a `DEVICE_ID_KEY` stored in `localStorage`. -* **Risk:** If the user clears their browser cache/local storage, the `DEVICE_ID_KEY` is lost. Consequently, the `SECURE_DATA_KEY` (containing API keys) becomes undecryptable garbage. -* **Recommendation:** This is an inherent trade-off of browser-based crypto. Ensure the UI handles "Decryption failed" gracefully by prompting the user to re-enter API keys rather than crashing. - -### 3. Preferences Hydration Complexity (`preferences/tauri.ts`) -The `hydratePreferencesFromTauri` function attempts to merge server state with local state while protecting "local-only" fields (like `audio_devices`). -* **Observation:** The logic relies on `Object.assign` and explicit exclusions. -* **Risk:** As the preferences object grows, the list of "local-only" keys must be maintained in multiple places (`preferences-sync.ts` and `tauri.ts`). -* **Recommendation:** Define the `UserPreferences` schema such that local-only keys are strictly typed or separated into a distinct sub-object to automate this merging logic. - -### 4. Meeting Cache Writes (`meeting-cache.ts`) -* **Issue:** The cache writes the *entire* meeting map to storage on every update. -* **Risk:** For users with many long meetings, this JSON blob could exceed the 5MB localStorage limit or cause performance issues. -* **Recommendation:** Consider storing only metadata in the monolithic cache key and storing individual meeting segments in separate keys (e.g., `meeting_segments_${id}`) or switching to IndexedDB. - -## Code-Specific Feedback - -### `client/src/lib/async-utils.ts` -The `fireAndForget` function is good, but `options?.metadata` should ideally merge with context from the error if the error is typed. +#### **B. Client-Side Filtering on Potentially Large Datasets** +**File:** `client/src/components/analytics/logs-tab.tsx` (Line 113) +The filtering of logs happens entirely on the client side: ```typescript -// Current -const message = err instanceof Error ? err.message : String(err); - -// Suggestion: Extract stack trace for debug level -const details = err instanceof Error ? err.stack : String(err); +const filteredLogs = useMemo(() => { ... }, [mergedLogs, ...]); ``` +If the application runs for a long time, `mergedLogs` could grow to thousands of entries. Filtering this array on every keystroke in the search box will cause UI jank. +* **Recommendation:** + 1. Implement **pagination** or **virtualization** (e.g., `react-virtuoso`) for the `ScrollArea`. + 2. Debounce the search input state update. -### `client/src/lib/ai-providers/fetch-models.ts` -The `fetchModels` function uses a large switch statement. -* **Refactor:** Consider a strategy pattern or a configuration object mapping providers to their fetch implementations. This would make `ai-providers/index.ts` cleaner and easier to extend. +#### **C. Memory Leak Risk in `AudioLevelMeter`** +**File:** `client/src/components/recording/audio-level-meter.tsx` (Lines 45-49) +The component sets an interval to generate random levels when no specific level is provided but `isActive` is true. While `useEffect` cleans it up, if `isActive` toggles rapidly or the component re-renders frequently due to parent state changes, this could cause jitter. +* **Refinement:** Ensure `isActive` is stable. The current implementation is safe regarding memory leaks due to the cleanup function, but the logic `if (typeof level === 'number')` inside the effect might cause the interval to be set/cleared rapidly if `level` fluctuates between number and undefined (unlikely but possible). -### `client/src/lib/preferences-sync.ts` -In `pushToServer`: +--- + +### **2. Code Quality & Refactoring** + +#### **A. Complex Component Decomposition** +**File:** `client/src/components/settings/ai-config-section.tsx` +This component is becoming a "God Component." It manages state for Transcription, Summary, and Embedding configurations simultaneously, including fetching states, testing states, and secure storage loading. +* **Refactor:** Create a reusable custom hook `useAIProviderConfig(configType)` that handles the state, loading, and API calls for a single provider type. This would reduce the main component code by ~50%. + +#### **B. Prop Drilling in Settings** +**File:** `client/src/components/settings/integrations-section/index.tsx` -> `IntegrationItem.tsx` -> `IntegrationConfigPanel` +Configuration handlers and state are passed down through several layers. +* **Recommendation:** Since this is a settings page, utilizing a context (`IntegrationSettingsContext`) would clean up the component signatures significantly and make adding new actions (like "Test Connection") easier to implement without touching intermediate components. + +#### **C. Magic Numbers** +**File:** `client/src/components/sync-history-log.tsx` ```typescript -// Lines 246-251 -const response = await invoke('set_preferences_sync', { - preferences: encoded, - if_match: options?.force ? null : meta.etag, +const interval = setInterval(loadHistory, 30000); // 30 seconds +``` +While `Timing.THIRTY_SECONDS_MS` is imported, there are still raw numbers used in some places (e.g., `client/src/components/recording/audio-level-meter.tsx` uses `100` for interval). +* **Recommendation:** Move all timing constants to `@/api/constants` or a local `consts.ts` file to ensure consistency across the app. + +--- + +### **3. UI/UX & Accessibility** + +#### **A. Accessible Icon Buttons** +**File:** `client/src/components/recording/audio-device-selector.tsx` (and others) +```typescript + +``` +When in `compact` mode, this button has no text. +* **Fix:** Ensure all icon-only buttons have `aria-label` props or `Description` for screen readers. The `SidebarMenuButton` handles tooltips well, but ad-hoc buttons in headers often miss this. + +#### **B. Feedback during "Save" Operations** +**File:** `client/src/components/settings/advanced-local-ai-settings/model-auth-section.tsx` +When saving the token, the button shows a spinner. However, there is no success toast or visual feedback *after* the save completes, only if it fails (via `error` prop) or implicitly by the input clearing. +* **Recommendation:** Explicitly trigger a `toast({ title: "Saved" })` upon success to give user closure. + +--- + +### **4. Security Considerations** + +#### **A. Sensitive Data in Logs** +**File:** `client/src/components/analytics/log-entry.tsx` +```typescript +
+  {JSON.stringify(log.metadata, null, 2)}
+
+``` +* **Risk:** If `metadata` contains PII or tokens (even accidental), it is rendered in plain text in the UI (and potentially exported). +* **Mitigation:** Implement a `sanitizeMetadata` utility that runs before rendering or exporting logs, redacting keys like `api_key`, `token`, `secret`, `authorization`. + +#### **B. Secure Storage Fallback** +**File:** `client/src/components/settings/ai-config-section.tsx` +The component checks `isSecureStorageAvailable()`. If false, it simply returns. +* **UX Issue:** If secure storage fails (e.g., on a specific browser or OS restriction), the user can't save API keys at all. +* **Recommendation:** Fallback to `localStorage` (with a warning/consent dialog) or in-memory storage if secure storage is unavailable, rather than failing silently. + +--- + +### **5. Test Coverage** + +The codebase has excellent test coverage patterns. +* **Good:** `client/src/components/analytics/logs-tab.test.tsx` mocks `ResizeObserver` and Time/Date functions effectively. +* **Good:** `client/src/components/recording/audio-device-selector.test.tsx` tests permission states. +* **Missing:** There are no tests for `client/src/components/settings/integrations-section/use-integration-handlers.ts`. This hook contains complex logic regarding OAuth flows and state updates. It is a critical path and should be unit tested. + +--- + +### **6. Specific Code Fixes** + +**Fix for `client/src/components/timestamped-notes-editor.tsx` (UUID issue):** + +```typescript +// Replace crypto.randomUUID() usage +function generateId() { + if (typeof crypto !== 'undefined' && crypto.randomUUID) { + return crypto.randomUUID(); + } + // Simple fallback for non-secure contexts + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { + const r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8); + return v.toString(16); + }); +} + +// Usage in saveNote: +const newNote: NoteEdit = { + id: generateId(), // ... -}); +}; ``` -* **Note:** The optimistic locking via `etag` is a great implementation detail for preventing overwrite conflicts. -### `client/src/lib/event-emitter.ts` -The `createMultiEventEmitter` is quite useful. -* **Nitpick:** The error handling inside `emit` catches errors from listeners. This is good for stability, but ensure `addClientLog` doesn't create a recursive loop if the event being emitted is a log event. +**Optimization for `client/src/components/recording/speaker-distribution.tsx`:** -## Conclusion +The `SpeakerDistribution` re-calculates `speakerCounts` and sorts `speakers` on every render. While `useMemo` is used, ensuring the `segments` array reference is stable in the parent component is crucial to prevent unnecessary recalculations during active recording. -This is a **high-quality codebase**. The developers have successfully abstracted away the complexity of the Tauri bridge, hardware interactions, and state synchronization. The primary concern is scalability regarding `localStorage` usage as the dataset (logs/meetings) grows. Transitioning specific heavy stores to IndexedDB would be the next logical architectural step. \ No newline at end of file +```typescript +// Ensure parent passes a stable reference or memoize efficiently +const speakerCounts = useMemo(() => { + // Logic remains same, but ensure this isn't running on every audio frame update + // passed via props. +}, [segments]); +``` + +### **Conclusion** + +The code is production-ready with minor adjustments. It adheres to modern React standards. The primary focus for the next sprint should be: +1. **Hardening:** UUID fallback and Metadata sanitization. +2. **Performance:** Virtualization for the Logs tab. +3. **Refactoring:** Breaking down the AI Config settings into smaller hooks/components. \ No newline at end of file diff --git a/.claudectx/pages.md b/.claudectx/pages.md new file mode 100644 index 0000000..461efdb --- /dev/null +++ b/.claudectx/pages.md @@ -0,0 +1,136 @@ +Here’s a *real* code review based on the Repomix snapshot you uploaded (frontend pages + a bunch of client/src + tauri + backend index listing). + +## 🚨 Must-fix bugs / correctness issues + +### 1) React key bug in Analytics “Speaker Participation” pie + +You render pie cells with `key={`cell-${stat.speaker}`}` but `SpeakerStats` uses `speakerId`, not `speaker`. This will produce duplicate keys (`cell-undefined`) and can break reconciliation. + +**Fix** + +```tsx +{analytics.speakerStats.map((stat, idx) => ( + +))} +``` + +(Also removes the `indexOf(stat)` O(n²) pattern.) + +--- + +### 2) “RefreshKey” hack in PeoplePage is a smell (and currently pointless) + +Inside `useMemo`, you have `void refreshKey;` which does nothing except silence “unused” linting. + +If you want rerenders after `preferences.setGlobalSpeakerName`, you should subscribe to the preferences store (or use a store hook) instead of manually bumping a counter. + +**Better** + +* Add `preferences.subscribe(...)` and store speaker names in state, or +* Expose a proper `usePreferences()` hook that triggers updates, or +* Use `useSyncExternalStore` for preferences. + +At minimum: **delete** the `void refreshKey;` line and keep the dependency array. + +--- + +## 🧠 API & state architecture review (Tauri / cached / mock) + +### 3) Fire-and-forget is everywhere — keep it, but stop swallowing errors silently + +Patterns like: + +* `.catch(() => {})` in event bridges / invoke calls (fire-and-forget) +* `void startProcessing(meetingId)` (post-processing) + …are okay **only** for high-frequency, non-critical operations, *but you need an error sink*. + +Right now, a failure to `SEND_AUDIO_CHUNK` (or similar) becomes invisible. That’s how you end up with “backend never logs anything” and no idea why. + +**Recommendation (small, high impact):** +Create a single helper used everywhere: + +```ts +export function fireAndForget(p: Promise, meta: { op: string }) { + p.catch((err) => addClientLog({ + level: 'warning', + source: 'api', + message: `Fire-and-forget failed: ${meta.op}`, + details: err instanceof Error ? err.message : String(err), + })); +} +``` + +Then replace `promise.catch(()=>{})` / `void promise` with `fireAndForget(...)`. + +This preserves your non-blocking UX **without** silent failures. + +--- + +### 4) Connection/reconnection logic is solid conceptually, but watch retry backoff math + +Your reconnection code increments attempts on failure and then computes delay from attempts. Ensure you’re not “double counting” attempts when scheduling next delay (common bug when you do `attempts + 1` in more than one place). (I’m flagging this because the pattern in the snapshot looks prone to it.) + +--- + +## ♻️ Duplication & consolidation opportunities (high ROI) + +### 5) Export logic is duplicated (and error-prone) + +You implement base64→bytes→Blob in multiple places (Meeting Detail export, Diagnostics export). Consolidate to one utility: + +* `buildExportBlob(format, content)` +* `downloadBlob(filename, blob)` + +Also: revoking `URL.createObjectURL(blob)` immediately after `a.click()` can intermittently break downloads in some browsers. Prefer: + +```ts +a.click(); +setTimeout(() => URL.revokeObjectURL(url), 1000); +``` + +--- + +### 6) Multiple enum-mapping “sources of truth” + +You have multiple mapping systems (grpc ↔ domain values) across helpers. This is exactly where drift happens (and it’s hard to detect unless you have contract tests). + +**Recommendation** + +* Make a single `enums.ts` mapping module per boundary (gRPC boundary vs UI boundary) +* Export **typed** conversion fns (don’t accept `string`, accept `MeetingState`, `AnnotationType`, etc.) +* Add a test that asserts mappings cover *every* union member (exhaustive). + +--- + +## 🧹 Dead/unused code and small cleanups + +### 7) Analytics performance & clarity + +* Avoid `indexOf(stat)` inside `.map()` (O(n²)); use the `map` index. (See fix above.) +* Use consistent IDs: your chart gradients use static IDs like `"durationGradient"`, which can collide if multiple charts render on the same page. Consider prefixing with a stable instance ID. + +### 8) Meeting detail + processing hooks look good, but verify repomix artifacts + +Some snippets show clearly broken tokens (e.g., `.gridProps`, `.speakers`, `setMeeting({ .meeting, summary })`). Those *might* be Repomix formatting artifacts, but if they exist in your real source they’re compile breakers. Spot-check those exact files locally. + +--- + +## ✅ What you’re doing well + +* **Separation of adapters** (mock/cached/tauri) is clean and test-covered. +* **Guarded mutations** for offline-mode UX are a strong pattern. +* The Meeting Detail page is structured sensibly (Header, Transcript, Summary/Entities tabs). + +--- + +## If you want the highest-impact next PR + +1. Fix the Analytics pie key bug. +2. Add a `fireAndForget()` helper + replace silent catches. +3. Centralize export blob + download helper and reuse it. +4. Remove the `void refreshKey` hack and move to a real preferences subscription/store. + +If you tell me which area you care about most (UI pages vs API adapters vs Tauri bridge vs backend python services), I’ll go deeper on that slice and produce a punch-list that’s basically “open PRs” ready. diff --git a/client/biome.json b/client/biome.json index 02a89e4..13c6b7d 100644 --- a/client/biome.json +++ b/client/biome.json @@ -7,7 +7,14 @@ }, "files": { "ignoreUnknown": false, - "includes": ["**", "!**/dist", "!**/node_modules", "!**/src-tauri/target", "!**/*.gen.ts", "!**/src-tauri/src/*.html"] + "includes": [ + "**", + "!**/dist", + "!**/node_modules", + "!**/src-tauri/target", + "!**/*.gen.ts", + "!**/src-tauri/src/*.html" + ] }, "overrides": [ { diff --git a/client/e2e-native-mac/app.spec.ts b/client/e2e-native-mac/app.spec.ts index 6bfdb62..3f8b45b 100644 --- a/client/e2e-native-mac/app.spec.ts +++ b/client/e2e-native-mac/app.spec.ts @@ -885,13 +885,13 @@ describe('audio: recording flow with hardware', () => { interval: IntegrationTimeouts.POLLING_INTERVAL_MS, } ); - } catch { - // Recording failed - this is OK without audio hardware - process.stdout.write('Recording did not start - likely no audio permission or device\n'); - const hasStartButton = await isLabelDisplayed('Start Recording'); - expect(hasStartButton).toBe(true); - return; - } + } catch { + // Recording failed - this is OK without audio hardware + process.stdout.write('Recording did not start - likely no audio permission or device\n'); + const hasStartButton = await isLabelDisplayed('Start Recording'); + expect(hasStartButton).toBe(true); + return; + } if (!recordingActive) { return; @@ -901,11 +901,11 @@ describe('audio: recording flow with hardware', () => { await browser.pause(AudioTestTimeouts.AUDIO_RECORDING_MS); // Check for audio level visualization during recording - const hasAudioLevelIndicator = - (await isLabelDisplayed('Audio Level')) || - (await isLabelDisplayed('VU')) || - (await isLabelDisplayed('Input Level')); - expect(typeof hasAudioLevelIndicator).toBe('boolean'); + const hasAudioLevelIndicator = + (await isLabelDisplayed('Audio Level')) || + (await isLabelDisplayed('VU')) || + (await isLabelDisplayed('Input Level')); + expect(typeof hasAudioLevelIndicator).toBe('boolean'); // Stop recording await clickByLabel('Stop Recording'); diff --git a/client/e2e-native/app.spec.ts b/client/e2e-native/app.spec.ts index 49edc03..8f8077b 100644 --- a/client/e2e-native/app.spec.ts +++ b/client/e2e-native/app.spec.ts @@ -128,7 +128,11 @@ describe('Preferences', () => { }); it('should load user preferences', async () => { - const result = await executeInApp<{ success?: boolean; prefs?: Record; error?: string }>({ + const result = await executeInApp<{ + success?: boolean; + prefs?: Record; + error?: string; + }>({ type: 'getPreferences', }); diff --git a/client/e2e-native/connection.spec.ts b/client/e2e-native/connection.spec.ts index 432c709..aeec180 100644 --- a/client/e2e-native/connection.spec.ts +++ b/client/e2e-native/connection.spec.ts @@ -15,9 +15,11 @@ describe('Server Connection', () => { describe('isConnected', () => { it('should return connection status', async () => { - const result = await executeInApp<{ success?: boolean; connected?: boolean; error?: string }>({ - type: 'isConnected', - }); + const result = await executeInApp<{ success?: boolean; connected?: boolean; error?: string }>( + { + type: 'isConnected', + } + ); expect(result.success).toBe(true); expect(typeof result.connected).toBe('boolean'); @@ -26,7 +28,11 @@ describe('Server Connection', () => { describe('getServerInfo', () => { it('should return server information when connected', async () => { - const result = await executeInApp<{ success?: boolean; info?: Record; error?: string }>({ + const result = await executeInApp<{ + success?: boolean; + info?: Record; + error?: string; + }>({ type: 'getServerInfo', }); @@ -39,7 +45,11 @@ describe('Server Connection', () => { describe('connect', () => { it('should connect to server with default URL', async () => { - const result = await executeInApp<{ success?: boolean; info?: Record; error?: string }>({ + const result = await executeInApp<{ + success?: boolean; + info?: Record; + error?: string; + }>({ type: 'connectDefault', }); @@ -66,7 +76,11 @@ describe('Identity', () => { describe('getCurrentUser', () => { it('should return current user info', async () => { - const result = await executeInApp<{ success?: boolean; user?: Record; error?: string }>({ + const result = await executeInApp<{ + success?: boolean; + user?: Record; + error?: string; + }>({ type: 'getCurrentUser', }); @@ -105,9 +119,11 @@ describe('Projects', () => { describe('listProjects', () => { it('should list projects', async () => { - const workspaces = await executeInApp<{ workspaces?: Array<{ id: string }>; error?: string }>({ - type: 'listWorkspaces', - }); + const workspaces = await executeInApp<{ workspaces?: Array<{ id: string }>; error?: string }>( + { + type: 'listWorkspaces', + } + ); if (!workspaces?.workspaces?.length) { return; } diff --git a/client/e2e-native/diarization.spec.ts b/client/e2e-native/diarization.spec.ts index e5f991c..d97613f 100644 --- a/client/e2e-native/diarization.spec.ts +++ b/client/e2e-native/diarization.spec.ts @@ -152,7 +152,6 @@ describe('Speaker Diarization', () => { await api?.deleteMeeting(id); }, meeting.id); }); - }); describe('cancelDiarization', () => { diff --git a/client/e2e-native/export.spec.ts b/client/e2e-native/export.spec.ts index 6923d9f..35d14e5 100644 --- a/client/e2e-native/export.spec.ts +++ b/client/e2e-native/export.spec.ts @@ -104,6 +104,5 @@ describe('Export Operations', () => { // Cleanup await executeInApp({ type: 'deleteMeeting', meetingId: meeting.id }); }); - }); }); diff --git a/client/e2e-native/fixtures.ts b/client/e2e-native/fixtures.ts index 60b1cbc..f873527 100644 --- a/client/e2e-native/fixtures.ts +++ b/client/e2e-native/fixtures.ts @@ -76,7 +76,11 @@ export type AppAction = | { type: 'connect'; serverUrl: string } | { type: 'resetRecordingState' } | { type: 'updatePreferences'; updates: Record } - | { type: 'forceConnectionState'; mode: 'connected' | 'disconnected' | 'cached' | 'mock'; serverUrl?: string | null } + | { + type: 'forceConnectionState'; + mode: 'connected' | 'disconnected' | 'cached' | 'mock'; + serverUrl?: string | null; + } | { type: 'listMeetings'; states?: Array<'created' | 'recording' | 'stopped' | 'completed' | 'error'>; @@ -233,7 +237,11 @@ export async function executeInApp(action: AppAction): Promise return testInvoke; } const tauri = (window as { __TAURI__?: unknown }).__TAURI__ as - | { core?: { invoke?: (cmd: string, args?: Record) => Promise } } + | { + core?: { + invoke?: (cmd: string, args?: Record) => Promise; + }; + } | { invoke?: (cmd: string, args?: Record) => Promise } | undefined; if (!tauri) { @@ -248,7 +256,9 @@ export async function executeInApp(action: AppAction): Promise return null; }; - const normalizeInjectResult = (result: unknown): { chunksSent: number; durationSeconds: number } | null => { + const normalizeInjectResult = ( + result: unknown + ): { chunksSent: number; durationSeconds: number } | null => { if (!result || typeof result !== 'object') { return null; } @@ -270,8 +280,9 @@ export async function executeInApp(action: AppAction): Promise return; } case 'resetRecordingState': { - const testApi = (window as { __NOTEFLOW_TEST_API__?: unknown }) - .__NOTEFLOW_TEST_API__ as { resetRecordingState?: () => Promise } | undefined; + const testApi = (window as { __NOTEFLOW_TEST_API__?: unknown }).__NOTEFLOW_TEST_API__ as + | { resetRecordingState?: () => Promise } + | undefined; if (typeof testApi?.resetRecordingState === 'function') { await testApi.resetRecordingState(); finish({ success: true }); @@ -287,8 +298,9 @@ export async function executeInApp(action: AppAction): Promise return; } case 'updatePreferences': { - const testApi = (window as { __NOTEFLOW_TEST_API__?: unknown }) - .__NOTEFLOW_TEST_API__ as { updatePreferences?: (updates: Record) => void } | undefined; + const testApi = (window as { __NOTEFLOW_TEST_API__?: unknown }).__NOTEFLOW_TEST_API__ as + | { updatePreferences?: (updates: Record) => void } + | undefined; if (typeof testApi?.updatePreferences !== 'function') { try { const raw = localStorage.getItem('noteflow_preferences'); @@ -306,8 +318,7 @@ export async function executeInApp(action: AppAction): Promise return; } case 'forceConnectionState': { - const testApi = (window as { __NOTEFLOW_TEST_API__?: unknown }) - .__NOTEFLOW_TEST_API__ as + const testApi = (window as { __NOTEFLOW_TEST_API__?: unknown }).__NOTEFLOW_TEST_API__ as | { forceConnectionState?: ( mode: 'connected' | 'disconnected' | 'cached' | 'mock', @@ -351,7 +362,10 @@ export async function executeInApp(action: AppAction): Promise return; } case 'createMeeting': { - const meeting = await api.createMeeting({ title: payload.title, metadata: payload.metadata }); + const meeting = await api.createMeeting({ + title: payload.title, + metadata: payload.metadata, + }); finish(meeting); return; } @@ -522,7 +536,10 @@ export async function executeInApp(action: AppAction): Promise } catch (error) { const message = extractErrorMessage(error); const normalized = message.toLowerCase(); - if (normalized.includes('already streaming') || normalized.includes('already recording')) { + if ( + normalized.includes('already streaming') || + normalized.includes('already recording') + ) { alreadyRecording = true; } else { finish({ success: false, error: message }); @@ -530,8 +547,7 @@ export async function executeInApp(action: AppAction): Promise } } - const testApi = (window as { __NOTEFLOW_TEST_API__?: unknown }) - .__NOTEFLOW_TEST_API__ as + const testApi = (window as { __NOTEFLOW_TEST_API__?: unknown }).__NOTEFLOW_TEST_API__ as | { injectTestTone?: ( meetingId: string, @@ -586,27 +602,32 @@ export async function executeInApp(action: AppAction): Promise return; } case 'startTranscriptionWithInjection': { - let alreadyRecording = false; - try { - const stream = await api.startTranscription(payload.meetingId); - const streamStore = getStreamStore(); - streamStore[payload.meetingId] = stream; - } catch (error) { - const message = extractErrorMessage(error); - const normalized = message.toLowerCase(); - if (normalized.includes('already streaming') || normalized.includes('already recording')) { - // Treat as already-active stream and continue with injection. - alreadyRecording = true; - } else { - finish({ success: false, error: message }); - return; + let alreadyRecording = false; + try { + const stream = await api.startTranscription(payload.meetingId); + const streamStore = getStreamStore(); + streamStore[payload.meetingId] = stream; + } catch (error) { + const message = extractErrorMessage(error); + const normalized = message.toLowerCase(); + if ( + normalized.includes('already streaming') || + normalized.includes('already recording') + ) { + // Treat as already-active stream and continue with injection. + alreadyRecording = true; + } else { + finish({ success: false, error: message }); + return; + } } - } - const testApi = (window as { __NOTEFLOW_TEST_API__?: unknown }) - .__NOTEFLOW_TEST_API__ as + const testApi = (window as { __NOTEFLOW_TEST_API__?: unknown }).__NOTEFLOW_TEST_API__ as | { - injectTestAudio?: (meetingId: string, config: { wavPath: string; speed: number; chunkMs: number }) => Promise; + injectTestAudio?: ( + meetingId: string, + config: { wavPath: string; speed: number; chunkMs: number } + ) => Promise; injectTestTone?: ( meetingId: string, frequency: number, diff --git a/client/e2e-native/globals.d.ts b/client/e2e-native/globals.d.ts index 98b4445..20f51b1 100644 --- a/client/e2e-native/globals.d.ts +++ b/client/e2e-native/globals.d.ts @@ -38,7 +38,10 @@ declare global { ) => Promise; isE2EMode?: () => string | undefined; updatePreferences?: (updates: Record) => void; - forceConnectionState?: (mode: 'connected' | 'disconnected' | 'cached' | 'mock', serverUrl?: string | null) => void; + forceConnectionState?: ( + mode: 'connected' | 'disconnected' | 'cached' | 'mock', + serverUrl?: string | null + ) => void; resetRecordingState?: () => Promise; }; diff --git a/client/e2e-native/lifecycle.spec.ts b/client/e2e-native/lifecycle.spec.ts index 8ac03df..b8b4ee0 100644 --- a/client/e2e-native/lifecycle.spec.ts +++ b/client/e2e-native/lifecycle.spec.ts @@ -32,7 +32,12 @@ async function listMeetings( limit = MEETING_LIST_LIMIT, offset = 0 ) { - const result = await executeInApp({ type: 'listMeetings', states, limit, offset }); + const result = await executeInApp({ + type: 'listMeetings', + states, + limit, + offset, + }); if (isErrorResult(result)) { throw new Error(`listMeetings failed: ${result.error}`); } @@ -86,9 +91,7 @@ function assertRecentMeeting( ): void { const createdAt = meeting.created_at ?? 0; if (minCreatedAt && createdAt < minCreatedAt) { - throw new Error( - `Latest meeting predates scenario start (created_at=${createdAt.toFixed(1)}s)` - ); + throw new Error(`Latest meeting predates scenario start (created_at=${createdAt.toFixed(1)}s)`); } const ageSeconds = Date.now() / 1000 - createdAt; if (!createdAt || ageSeconds > maxAgeSeconds) { @@ -146,11 +149,7 @@ async function stopMeetingIfRecording(meetingId: string): Promise { } } -async function startTone( - meetingId: string, - tone = TONE, - options?: { waitForRecording?: boolean } -) { +async function startTone(meetingId: string, tone = TONE, options?: { waitForRecording?: boolean }) { const result = await executeInApp({ type: 'startTranscriptionWithTone', meetingId, tone }); if (!result.success) { throw new Error(`Tone injection failed: ${result.error ?? 'unknown error'}`); @@ -198,7 +197,11 @@ describe('Lifecycle stress tests', () => { timeoutMsg: 'Test API not available within 15s', } ); - const prefsResult = await executeInApp<{ success?: boolean; error?: string; needsReload?: boolean }>({ + const prefsResult = await executeInApp<{ + success?: boolean; + error?: string; + needsReload?: boolean; + }>({ type: 'updatePreferences', updates: { simulate_transcription: false, skip_simulation_confirmation: true }, }); @@ -316,7 +319,9 @@ describe('Lifecycle stress tests', () => { name: 'Start then immediate stop before injection completes', async run() { await ensureNoActiveRecordings(); - const meeting = await createMeeting(`Lifecycle immediate stop ${TestData.generateTestId()}`); + const meeting = await createMeeting( + `Lifecycle immediate stop ${TestData.generateTestId()}` + ); createdMeetingIds.add(meeting.id); await startTone(meeting.id, { ...TONE, seconds: 2 }); await executeInApp({ type: 'stopMeeting', meetingId: meeting.id }); @@ -329,7 +334,9 @@ describe('Lifecycle stress tests', () => { name: 'Double start on same meeting should not crash', async run() { await ensureNoActiveRecordings(); - const meeting = await createMeeting(`Lifecycle double start ${TestData.generateTestId()}`); + const meeting = await createMeeting( + `Lifecycle double start ${TestData.generateTestId()}` + ); createdMeetingIds.add(meeting.id); await startTone(meeting.id); const secondStart = await executeInApp({ @@ -385,7 +392,9 @@ describe('Lifecycle stress tests', () => { } await waitForMeetingState(meeting.id, ['stopped', 'completed']); // Evidence - console.log(`[e2e-lifecycle] stop-active: stopped=${result.stopped ?? 0} meeting=${meeting.id}`); + console.log( + `[e2e-lifecycle] stop-active: stopped=${result.stopped ?? 0} meeting=${meeting.id}` + ); }, }, { @@ -438,7 +447,9 @@ describe('Lifecycle stress tests', () => { name: 'Delete meeting while recording does not leave an active recording behind', async run() { await ensureNoActiveRecordings(); - const meeting = await createMeeting(`Lifecycle delete-active ${TestData.generateTestId()}`); + const meeting = await createMeeting( + `Lifecycle delete-active ${TestData.generateTestId()}` + ); createdMeetingIds.add(meeting.id); await startTone(meeting.id); await deleteMeeting(meeting.id); @@ -540,7 +551,9 @@ describe('Lifecycle stress tests', () => { await executeInApp({ type: 'stopMeeting', meetingId: meeting.id }); await waitForMeetingState(meeting.id, ['stopped', 'completed']); // Evidence - console.log(`[e2e-lifecycle] annotation-live: meeting=${meeting.id} annotation=${annotationId}`); + console.log( + `[e2e-lifecycle] annotation-live: meeting=${meeting.id} annotation=${annotationId}` + ); }, }, { @@ -552,7 +565,11 @@ describe('Lifecycle stress tests', () => { await startTone(meeting.id, { ...TONE, seconds: 2 }); await executeInApp({ type: 'stopMeeting', meetingId: meeting.id }); await waitForMeetingState(meeting.id, ['stopped', 'completed']); - const summary = await executeInApp({ type: 'generateSummary', meetingId: meeting.id, force: true }); + const summary = await executeInApp({ + type: 'generateSummary', + meetingId: meeting.id, + force: true, + }); if (isErrorResult(summary)) { throw new Error(`Summary generation failed: ${summary.error}`); } @@ -564,11 +581,17 @@ describe('Lifecycle stress tests', () => { name: 'Concurrent stop and summary requests do not crash', async run() { await ensureNoActiveRecordings(); - const meeting = await createMeeting(`Lifecycle stop-summary ${TestData.generateTestId()}`); + const meeting = await createMeeting( + `Lifecycle stop-summary ${TestData.generateTestId()}` + ); createdMeetingIds.add(meeting.id); await startTone(meeting.id, { ...TONE, seconds: 2 }); const stopPromise = executeInApp({ type: 'stopMeeting', meetingId: meeting.id }); - const summaryPromise = executeInApp({ type: 'generateSummary', meetingId: meeting.id, force: true }); + const summaryPromise = executeInApp({ + type: 'generateSummary', + meetingId: meeting.id, + force: true, + }); await Promise.all([stopPromise, summaryPromise]); await waitForMeetingState(meeting.id, ['stopped', 'completed']); // Evidence @@ -599,14 +622,18 @@ describe('Lifecycle stress tests', () => { name: 'Start recording after delete does not reuse deleted meeting', async run() { await ensureNoActiveRecordings(); - const meeting = await createMeeting(`Lifecycle delete-restart ${TestData.generateTestId()}`); + const meeting = await createMeeting( + `Lifecycle delete-restart ${TestData.generateTestId()}` + ); createdMeetingIds.add(meeting.id); await deleteMeeting(meeting.id); const meetings = await listMeetings(); if (meetings.some((item) => item.id === meeting.id)) { throw new Error('Deleted meeting still appears in list'); } - const replacement = await createMeeting(`Lifecycle delete-restart new ${TestData.generateTestId()}`); + const replacement = await createMeeting( + `Lifecycle delete-restart new ${TestData.generateTestId()}` + ); createdMeetingIds.add(replacement.id); await startTone(replacement.id, TONE); await executeInApp({ type: 'stopMeeting', meetingId: replacement.id }); @@ -621,7 +648,9 @@ describe('Lifecycle stress tests', () => { await ensureNoActiveRecordings(); const meetingIds: string[] = []; for (let i = 0; i < 3; i += 1) { - const meeting = await createMeeting(`Lifecycle rapid chain ${TestData.generateTestId()}`); + const meeting = await createMeeting( + `Lifecycle rapid chain ${TestData.generateTestId()}` + ); createdMeetingIds.add(meeting.id); meetingIds.push(meeting.id); await startTone(meeting.id, { ...TONE, seconds: 1 }); @@ -636,7 +665,9 @@ describe('Lifecycle stress tests', () => { name: 'Stop recording while injecting tone continues gracefully', async run() { await ensureNoActiveRecordings(); - const meeting = await createMeeting(`Lifecycle stop-during-inject ${TestData.generateTestId()}`); + const meeting = await createMeeting( + `Lifecycle stop-during-inject ${TestData.generateTestId()}` + ); createdMeetingIds.add(meeting.id); void startTone(meeting.id, { ...TONE, seconds: 2 }, { waitForRecording: false }); await waitForMeetingState(meeting.id, ['recording']); @@ -697,7 +728,9 @@ describe('Lifecycle stress tests', () => { throw new Error('Meeting state did not transition out of recording'); } // Evidence - console.log(`[e2e-lifecycle] badge-transition: meeting=${meeting.id} state=${stopped.state}`); + console.log( + `[e2e-lifecycle] badge-transition: meeting=${meeting.id} state=${stopped.state}` + ); }, }, ]; diff --git a/client/e2e-native/meetings.spec.ts b/client/e2e-native/meetings.spec.ts index 41515fd..901b1a2 100644 --- a/client/e2e-native/meetings.spec.ts +++ b/client/e2e-native/meetings.spec.ts @@ -28,7 +28,11 @@ describe('Meeting Operations', () => { describe('listMeetings', () => { it('should list meetings with default parameters', async () => { - const result = await executeInApp<{ meetings?: unknown[]; total_count?: number; error?: string }>({ + const result = await executeInApp<{ + meetings?: unknown[]; + total_count?: number; + error?: string; + }>({ type: 'listMeetings', limit: 10, }); @@ -80,12 +84,16 @@ describe('Meeting Operations', () => { it('should create a new meeting', async () => { const title = TestData.createMeetingTitle(); - const result = await executeInApp<{ id?: string; title?: string; state?: string; created_at?: number; error?: string }>( - { - type: 'createMeeting', - title, - } - ); + const result = await executeInApp<{ + id?: string; + title?: string; + state?: string; + created_at?: number; + error?: string; + }>({ + type: 'createMeeting', + title, + }); if (!result?.error && result?.id) { expect(result).toHaveProperty('id'); @@ -103,7 +111,11 @@ describe('Meeting Operations', () => { const title = TestData.createMeetingTitle(); const metadata = { test_key: 'test_value', source: 'e2e-native' }; - const result = await executeInApp<{ id?: string; metadata?: Record; error?: string }>({ + const result = await executeInApp<{ + id?: string; + metadata?: Record; + error?: string; + }>({ type: 'createMeeting', title, metadata, diff --git a/client/e2e-native/recording.spec.ts b/client/e2e-native/recording.spec.ts index ccb4308..db8309c 100644 --- a/client/e2e-native/recording.spec.ts +++ b/client/e2e-native/recording.spec.ts @@ -43,7 +43,6 @@ describe('Audio Devices', () => { return; } expect(result.success).toBe(true); - }); }); @@ -105,10 +104,12 @@ describe('Recording Operations', () => { testMeetingId = meeting.id; // Start transcription - const result = await executeInApp<{ success?: boolean; hasStream?: boolean; error?: string }>({ - type: 'startTranscription', - meetingId: meeting.id, - }); + const result = await executeInApp<{ success?: boolean; hasStream?: boolean; error?: string }>( + { + type: 'startTranscription', + meetingId: meeting.id, + } + ); // May fail if no audio device available expect(result).toBeDefined(); @@ -129,7 +130,11 @@ describe('Playback Operations', () => { describe('getPlaybackState', () => { it('should return playback state', async () => { - const result = await executeInApp<{ success?: boolean; state?: Record; error?: string }>({ + const result = await executeInApp<{ + success?: boolean; + state?: Record; + error?: string; + }>({ type: 'getPlaybackState', }); @@ -153,7 +158,9 @@ describe('Playback Operations', () => { return; } - const result = await executeInApp<{ success?: boolean; error?: string }>({ type: 'pausePlayback' }); + const result = await executeInApp<{ success?: boolean; error?: string }>({ + type: 'pausePlayback', + }); expect(result).toBeDefined(); }); @@ -169,7 +176,9 @@ describe('Playback Operations', () => { return; } - const result = await executeInApp<{ success?: boolean; error?: string }>({ type: 'stopPlayback' }); + const result = await executeInApp<{ success?: boolean; error?: string }>({ + type: 'stopPlayback', + }); expect(result).toBeDefined(); }); }); diff --git a/client/e2e-native/roundtrip.spec.ts b/client/e2e-native/roundtrip.spec.ts index 2c16cb8..76ffe91 100644 --- a/client/e2e-native/roundtrip.spec.ts +++ b/client/e2e-native/roundtrip.spec.ts @@ -101,13 +101,7 @@ describe('Round-trip flow', () => { throw new Error('Meeting ID missing'); } - const wavPath = path.resolve( - process.cwd(), - '..', - 'tests', - 'fixtures', - 'sample_discord.wav' - ); + const wavPath = path.resolve(process.cwd(), '..', 'tests', 'fixtures', 'sample_discord.wav'); const startResult = await executeInApp({ type: 'startTranscriptionWithInjection', @@ -121,9 +115,10 @@ describe('Round-trip flow', () => { throw new Error(`Recording/injection failed: ${startResult.error ?? 'unknown error'}`); } - const injectResult = startResult.inject as - | { chunksSent?: number; durationSeconds?: number } - | null; + const injectResult = startResult.inject as { + chunksSent?: number; + durationSeconds?: number; + } | null; if (!injectResult || (injectResult.chunksSent ?? 0) <= 0) { console.log('[e2e] injection_debug', startResult.debug ?? null); throw new Error('Audio injection did not send any chunks'); diff --git a/client/e2e/fixtures.ts b/client/e2e/fixtures.ts index 12ada5e..46e25d3 100644 --- a/client/e2e/fixtures.ts +++ b/client/e2e/fixtures.ts @@ -128,17 +128,14 @@ export async function waitForNetworkIdle(page: Page, timeout = 5000): Promise(page: Page, fn: (api: unknown) => Promise): Promise { await waitForAPI(page, E2E_TIMEOUTS.PAGE_LOAD_MS); - return page.evaluate( - async (fnInPage) => { - // Access API through window.__NOTEFLOW_API__ which we expose for testing - const api = window.__NOTEFLOW_API__; - if (!api) { - throw new Error('API not exposed on window. Ensure test mode is enabled.'); - } - return fnInPage(api); - }, - fn - ); + return page.evaluate(async (fnInPage) => { + // Access API through window.__NOTEFLOW_API__ which we expose for testing + const api = window.__NOTEFLOW_API__; + if (!api) { + throw new Error('API not exposed on window. Ensure test mode is enabled.'); + } + return fnInPage(api); + }, fn); } /** diff --git a/client/e2e/oidc-providers.spec.ts b/client/e2e/oidc-providers.spec.ts index fbd13de..a28d864 100644 --- a/client/e2e/oidc-providers.spec.ts +++ b/client/e2e/oidc-providers.spec.ts @@ -202,11 +202,7 @@ test.describe('oidc provider api integration', () => { const created = await callAPI(page, 'registerOidcProvider', testData); // Test connection (may fail for mock issuer, but API call should succeed) - const result = await callAPI( - page, - 'testOidcConnection', - created.id - ); + const result = await callAPI(page, 'testOidcConnection', created.id); expect(result).toHaveProperty('results'); expect(result).toHaveProperty('success_count'); diff --git a/client/e2e/post-processing.spec.ts b/client/e2e/post-processing.spec.ts index 1698ec4..25d13c6 100644 --- a/client/e2e/post-processing.spec.ts +++ b/client/e2e/post-processing.spec.ts @@ -21,7 +21,9 @@ import { const shouldRun = process.env.NOTEFLOW_E2E === '1'; const meetingDetailPath = (meeting: { id: string; project_id?: string }) => - meeting.project_id ? `/projects/${meeting.project_id}/meetings/${meeting.id}` : `/meetings/${meeting.id}`; + meeting.project_id + ? `/projects/${meeting.project_id}/meetings/${meeting.id}` + : `/meetings/${meeting.id}`; test.describe('post-processing pipeline', () => { test.skip(!shouldRun, 'Set NOTEFLOW_E2E=1 to enable end-to-end tests.'); @@ -81,7 +83,7 @@ test.describe('post-processing pipeline', () => { page, 'createMeeting', { - title: 'E2E Meeting Detail Test', + title: 'E2E Meeting Detail Test', } ); @@ -90,7 +92,9 @@ test.describe('post-processing pipeline', () => { await waitForLoadingComplete(page); // Verify meeting title is displayed - const titleElement = page.locator(`h1:has-text("E2E Meeting Detail Test"), h2:has-text("E2E Meeting Detail Test"), [data-testid="meeting-title"]`); + const titleElement = page.locator( + `h1:has-text("E2E Meeting Detail Test"), h2:has-text("E2E Meeting Detail Test"), [data-testid="meeting-title"]` + ); await expect(titleElement.first()).toBeVisible({ timeout: 10000 }); }); @@ -246,7 +250,10 @@ test.describe('post-processing pipeline', () => { // Click on the meeting card or link const meetingLink = page.locator(`a[href*="${meeting.id}"], [data-testid="meeting-card"]`); - const isClickable = await meetingLink.first().isVisible().catch(() => false); + const isClickable = await meetingLink + .first() + .isVisible() + .catch(() => false); if (isClickable) { await meetingLink.first().click(); diff --git a/client/e2e/settings-ui.spec.ts b/client/e2e/settings-ui.spec.ts index 385e942..9efb597 100644 --- a/client/e2e/settings-ui.spec.ts +++ b/client/e2e/settings-ui.spec.ts @@ -6,13 +6,7 @@ */ import { expect, test } from '@playwright/test'; -import { - callAPI, - E2E_TIMEOUTS, - navigateTo, - waitForAPI, - waitForLoadingComplete, -} from './fixtures'; +import { callAPI, E2E_TIMEOUTS, navigateTo, waitForAPI, waitForLoadingComplete } from './fixtures'; const shouldRun = process.env.NOTEFLOW_E2E === '1'; @@ -133,7 +127,10 @@ test.describe('Audio Devices Section', () => { const detectBtn = audioCard.locator( 'button:has-text("Detect"), button:has-text("Grant"), button:has-text("Refresh")' ); - const detectVisible = await detectBtn.first().isVisible().catch(() => false); + const detectVisible = await detectBtn + .first() + .isVisible() + .catch(() => false); expect(detectVisible).toBe(true); }); diff --git a/client/src-tauri/src/audio/drift_compensation/detector.rs b/client/src-tauri/src/audio/drift_compensation/detector.rs index fe075fe..6941a12 100644 --- a/client/src-tauri/src/audio/drift_compensation/detector.rs +++ b/client/src-tauri/src/audio/drift_compensation/detector.rs @@ -172,6 +172,10 @@ impl DriftDetector { mod tests { use super::*; + const BASE_BUFFER_LEN: usize = 1000; + const EXTREME_BUFFER_LEN: usize = 10_000; + const EXTREME_DRIFT_MULTIPLIER: usize = 10; + #[test] fn test_drift_detector_new() { let detector = DriftDetector::new(); @@ -186,8 +190,8 @@ mod tests { // Simulate primary buffer growing faster than secondary // This indicates primary source is producing more samples (faster clock) for i in 0..200 { - let primary_len = 1000 + i * 2; // Growing faster - let secondary_len = 1000 + i; // Growing slower + let primary_len = BASE_BUFFER_LEN + i * 2; // Growing faster + let secondary_len = BASE_BUFFER_LEN + i; // Growing slower detector.update(primary_len, secondary_len); } @@ -205,8 +209,8 @@ mod tests { // Simulate secondary buffer growing faster than primary for i in 0..200 { - let primary_len = 1000 + i; - let secondary_len = 1000 + i * 2; + let primary_len = BASE_BUFFER_LEN + i; + let secondary_len = BASE_BUFFER_LEN + i * 2; detector.update(primary_len, secondary_len); } @@ -222,7 +226,7 @@ mod tests { // Both buffers at same level for _ in 0..100 { - detector.update(1000, 1000); + detector.update(BASE_BUFFER_LEN, BASE_BUFFER_LEN); } assert!( @@ -237,7 +241,7 @@ mod tests { // Build up some state for i in 0..50 { - detector.update(1000 + i, 1000); + detector.update(BASE_BUFFER_LEN + i, BASE_BUFFER_LEN); } detector.reset(); @@ -254,8 +258,8 @@ mod tests { let mut ratio_received = false; for i in 0..10000 { // Extreme drift to trigger ratio calculation - let primary_len = 10000 + i * 10; - let secondary_len = 10000; + let primary_len = EXTREME_BUFFER_LEN + i * EXTREME_DRIFT_MULTIPLIER; + let secondary_len = EXTREME_BUFFER_LEN; if let Some(ratio) = detector.update(primary_len, secondary_len) { ratio_received = true; // Ratio should be close to 1.0 but not exactly 1.0 diff --git a/client/src-tauri/src/audio/drift_compensation/metrics.rs b/client/src-tauri/src/audio/drift_compensation/metrics.rs index 57721ba..c947b26 100644 --- a/client/src-tauri/src/audio/drift_compensation/metrics.rs +++ b/client/src-tauri/src/audio/drift_compensation/metrics.rs @@ -186,6 +186,13 @@ impl DriftMetrics { mod tests { use super::*; + const TEST_DRIFT_PPM: f64 = 50.0; + const TEST_RATIO: f64 = 1.00005; + const TEST_DRIFT_PPM_ALT: f64 = 25.0; + const TEST_RATIO_ALT: f64 = 1.000025; + const TEST_DRIFT_PPM_UPDATE: f64 = 30.0; + const TEST_RATIO_UPDATE: f64 = 1.00003; + #[test] fn test_drift_metrics_new() { let metrics = DriftMetrics::new(); @@ -205,22 +212,22 @@ mod tests { #[test] fn test_record_adjustment() { let mut metrics = DriftMetrics::new(); - metrics.record_adjustment(50.0, 1.00005); + metrics.record_adjustment(TEST_DRIFT_PPM, TEST_RATIO); assert_eq!(metrics.adjustment_count(), 1); - assert_eq!(metrics.last_drift_ppm(), 50.0); - assert!((metrics.last_ratio() - 1.00005).abs() < f64::EPSILON); + assert_eq!(metrics.last_drift_ppm(), TEST_DRIFT_PPM); + assert!((metrics.last_ratio() - TEST_RATIO).abs() < f64::EPSILON); } #[test] fn test_snapshot() { let mut metrics = DriftMetrics::new(); metrics.record_overflow(); - metrics.record_adjustment(25.0, 1.000025); + metrics.record_adjustment(TEST_DRIFT_PPM_ALT, TEST_RATIO_ALT); let snapshot = metrics.snapshot(); assert_eq!(snapshot.overflow_count, 1); assert_eq!(snapshot.adjustment_count, 1); - assert_eq!(snapshot.drift_ppm, 25.0); + assert_eq!(snapshot.drift_ppm, TEST_DRIFT_PPM_ALT); assert!(snapshot.enabled); } @@ -240,7 +247,7 @@ mod tests { fn test_reset() { let mut metrics = DriftMetrics::new(); metrics.record_overflow(); - metrics.record_adjustment(50.0, 1.00005); + metrics.record_adjustment(TEST_DRIFT_PPM, TEST_RATIO); metrics.set_enabled(false); metrics.reset(); @@ -263,10 +270,10 @@ mod tests { #[test] fn test_update_values() { let mut metrics = DriftMetrics::new(); - metrics.update_values(30.0, 1.00003); + metrics.update_values(TEST_DRIFT_PPM_UPDATE, TEST_RATIO_UPDATE); - assert_eq!(metrics.last_drift_ppm(), 30.0); - assert!((metrics.last_ratio() - 1.00003).abs() < f64::EPSILON); + assert_eq!(metrics.last_drift_ppm(), TEST_DRIFT_PPM_UPDATE); + assert!((metrics.last_ratio() - TEST_RATIO_UPDATE).abs() < f64::EPSILON); // Should not increment adjustment count assert_eq!(metrics.adjustment_count(), 0); } diff --git a/client/src-tauri/src/audio/drift_compensation/resampler.rs b/client/src-tauri/src/audio/drift_compensation/resampler.rs index 06a2b56..e65c2ff 100644 --- a/client/src-tauri/src/audio/drift_compensation/resampler.rs +++ b/client/src-tauri/src/audio/drift_compensation/resampler.rs @@ -6,6 +6,10 @@ use crate::constants::drift_compensation::{RATIO_BYPASS_THRESHOLD, RATIO_SLEW_RATE}; use rubato::{Resampler, SincFixedIn, SincInterpolationParameters, SincInterpolationType, WindowFunction}; +const DEFAULT_CHUNK_SIZE: usize = 1024; +const RESAMPLER_SINC_LEN: usize = 256; +const RESAMPLER_OVERSAMPLING_FACTOR: usize = 256; + /// Adaptive resampler for drift compensation. /// /// Uses rubato's sinc interpolation for high-quality resampling with @@ -45,7 +49,7 @@ impl AdaptiveResampler { /// * `sample_rate` - Audio sample rate in Hz /// * `channels` - Number of audio channels (1 = mono, 2 = stereo) pub fn new(sample_rate: u32, channels: usize) -> Self { - let chunk_size = 1024; // Default chunk size for processing + let chunk_size = DEFAULT_CHUNK_SIZE; // Default chunk size for processing let mut resampler = Self { resampler: None, @@ -111,7 +115,10 @@ impl AdaptiveResampler { let deinterleaved = Self::deinterleave_static(samples, self.channels); // Now get mutable reference to resampler - let resampler = self.resampler.as_mut().unwrap(); + let resampler = match self.resampler.as_mut() { + Some(resampler) => resampler, + None => return samples.to_vec(), + }; // Process through rubato - returns Vec> match resampler.process(&deinterleaved, None) { @@ -167,10 +174,10 @@ impl AdaptiveResampler { } let params = SincInterpolationParameters { - sinc_len: 256, + sinc_len: RESAMPLER_SINC_LEN, f_cutoff: 0.95, interpolation: SincInterpolationType::Linear, - oversampling_factor: 256, + oversampling_factor: RESAMPLER_OVERSAMPLING_FACTOR, window: WindowFunction::BlackmanHarris2, }; @@ -270,6 +277,8 @@ mod tests { const TEST_SAMPLE_RATE: u32 = 48000; const TEST_CHANNELS: usize = 2; + const TEST_TARGET_RATIO: f64 = 1.001; + const TEST_INPUT_SAMPLES: usize = 2048; #[test] fn test_adaptive_resampler_new() { @@ -294,8 +303,8 @@ mod tests { fn test_adaptive_resampler_set_ratio() { let mut resampler = AdaptiveResampler::new(TEST_SAMPLE_RATE, TEST_CHANNELS); - resampler.set_target_ratio(1.001); - assert_eq!(resampler.target_ratio(), 1.001); + resampler.set_target_ratio(TEST_TARGET_RATIO); + assert_eq!(resampler.target_ratio(), TEST_TARGET_RATIO); // Current ratio should still be 1.0 until process is called assert_eq!(resampler.current_ratio(), 1.0); @@ -305,10 +314,10 @@ mod tests { fn test_adaptive_resampler_slew_limiting() { let mut resampler = AdaptiveResampler::new(TEST_SAMPLE_RATE, TEST_CHANNELS); - resampler.set_target_ratio(1.001); + resampler.set_target_ratio(TEST_TARGET_RATIO); // Process some samples to trigger ratio update - let input: Vec = vec![0.0; 2048]; + let input: Vec = vec![0.0; TEST_INPUT_SAMPLES]; resampler.process(&input); // Ratio should have moved towards target but not jumped there instantly @@ -321,7 +330,7 @@ mod tests { fn test_adaptive_resampler_reset() { let mut resampler = AdaptiveResampler::new(TEST_SAMPLE_RATE, TEST_CHANNELS); - resampler.set_target_ratio(1.001); + resampler.set_target_ratio(TEST_TARGET_RATIO); resampler.reset(); assert_eq!(resampler.current_ratio(), 1.0); diff --git a/client/src-tauri/src/audio/mixer.rs b/client/src-tauri/src/audio/mixer.rs index 44917ea..05c64c0 100644 --- a/client/src-tauri/src/audio/mixer.rs +++ b/client/src-tauri/src/audio/mixer.rs @@ -309,6 +309,10 @@ mod tests { const TEST_SAMPLE_RATE: u32 = 48_000; const TEST_CHANNELS: u16 = 1; const EPSILON: f32 = 0.001; + const TEST_BUFFER_FILL_SAMPLES: usize = 1000; + const TEST_DRAIN_SAMPLES: usize = 500; + const TEST_PRIMARY_SAMPLE: f32 = 0.5; + const TEST_SECONDARY_SAMPLE: f32 = 0.3; #[test] fn test_mixer_basic() { @@ -407,11 +411,11 @@ mod tests { let mixer = AudioMixer::new(TEST_SAMPLE_RATE, TEST_CHANNELS, 1.0, 1.0); // Push some data - mixer.push_primary(&[0.5; 1000]); - mixer.push_secondary(&[0.3; 1000]); + mixer.push_primary(&[TEST_PRIMARY_SAMPLE; TEST_BUFFER_FILL_SAMPLES]); + mixer.push_secondary(&[TEST_SECONDARY_SAMPLE; TEST_BUFFER_FILL_SAMPLES]); // Drain to trigger drift detection - mixer.drain_mixed(500); + mixer.drain_mixed(TEST_DRAIN_SAMPLES); // Clear should reset everything mixer.clear(); diff --git a/client/src/App.tsx b/client/src/App.tsx index b7be21a..02fd3e8 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -74,7 +74,10 @@ const App = () => ( }> } /> } /> - } /> + } + /> } /> = { +export const cachedAppsAPI: Pick = { async listInstalledApps(_options?: ListInstalledAppsRequest): Promise { return { apps: [], diff --git a/client/src/api/cached/streaming.ts b/client/src/api/cached/streaming.ts index 81e59c8..b83b12c 100644 --- a/client/src/api/cached/streaming.ts +++ b/client/src/api/cached/streaming.ts @@ -1,8 +1,5 @@ import type { NoteFlowAPI } from '../interface'; -import type { - StreamingConfiguration, - UpdateStreamingConfigurationRequest, -} from '../types'; +import type { StreamingConfiguration, UpdateStreamingConfigurationRequest } from '../types'; import { rejectReadOnly } from './readonly'; const offlineStreamingConfiguration: StreamingConfiguration = { diff --git a/client/src/api/error-utils.ts b/client/src/api/error-utils.ts index 9847372..c6c8bc1 100644 --- a/client/src/api/error-utils.ts +++ b/client/src/api/error-utils.ts @@ -64,7 +64,10 @@ function extractGrpcStatusCodeFromMessage(message: string): number | undefined { if (!match?.[1]) { return undefined; } - const normalized = match[1].replace(/([a-z0-9])([A-Z])/g, '$1_$2').replace(/__/g, '_').toUpperCase(); + const normalized = match[1] + .replace(/([a-z0-9])([A-Z])/g, '$1_$2') + .replace(/__/g, '_') + .toUpperCase(); const code = GRPC_STATUS_CODES[normalized as keyof typeof GRPC_STATUS_CODES]; return typeof code === 'number' ? code : undefined; } diff --git a/client/src/api/index.ts b/client/src/api/index.ts index 83a75f9..097f996 100644 --- a/client/src/api/index.ts +++ b/client/src/api/index.ts @@ -164,7 +164,10 @@ if (typeof window !== 'undefined') { const current = preferences.get(); preferences.replace({ ...current, ...updates }); }, - forceConnectionState: (mode: 'connected' | 'disconnected' | 'cached' | 'mock', serverUrl?: string | null) => { + forceConnectionState: ( + mode: 'connected' | 'disconnected' | 'cached' | 'mock', + serverUrl?: string | null + ) => { setConnectionMode(mode); setConnectionServerUrl(serverUrl ?? null); }, diff --git a/client/src/api/interface.ts b/client/src/api/interface.ts index c119548..b3611e7 100644 --- a/client/src/api/interface.ts +++ b/client/src/api/interface.ts @@ -443,9 +443,7 @@ export interface NoteFlowAPI { * Set a HuggingFace token with optional validation * @see gRPC endpoint: SetHuggingFaceToken (unary) */ - setHuggingFaceToken( - request: SetHuggingFaceTokenRequest - ): Promise; + setHuggingFaceToken(request: SetHuggingFaceTokenRequest): Promise; /** * Get the status of the configured HuggingFace token diff --git a/client/src/api/mock-adapter.ts b/client/src/api/mock-adapter.ts index ad39ced..26c7030 100644 --- a/client/src/api/mock-adapter.ts +++ b/client/src/api/mock-adapter.ts @@ -1737,8 +1737,7 @@ export const mockAPI: NoteFlowAPI = { name: request.name ?? provider.name, scopes: requestedScopes.length > 0 ? requestedScopes : provider.scopes, claim_mapping: request.claim_mapping ?? provider.claim_mapping, - allowed_groups: - requestedGroups.length > 0 ? requestedGroups : provider.allowed_groups, + allowed_groups: requestedGroups.length > 0 ? requestedGroups : provider.allowed_groups, require_email_verified: request.require_email_verified ?? provider.require_email_verified, enabled: request.enabled ?? provider.enabled, updated_at: Date.now(), diff --git a/client/src/api/tauri-adapter.ts b/client/src/api/tauri-adapter.ts index ce7d17f..d8f845a 100644 --- a/client/src/api/tauri-adapter.ts +++ b/client/src/api/tauri-adapter.ts @@ -309,24 +309,21 @@ export class TauriTranscriptionStream implements TranscriptionStream { this.unlistenFn = null; } - const unlisten = await this.listen( - TauriEvents.TRANSCRIPT_UPDATE, - (event) => { - if (this.isClosed) { - return; - } - if (event.payload.meeting_id === this.meetingId) { - // Track latest ack_sequence for monitoring - if ( - typeof event.payload.ack_sequence === 'number' && - event.payload.ack_sequence > this.lastAckedSequence - ) { - this.lastAckedSequence = event.payload.ack_sequence; - } - callback(event.payload); - } + const unlisten = await this.listen(TauriEvents.TRANSCRIPT_UPDATE, (event) => { + if (this.isClosed) { + return; } - ); + if (event.payload.meeting_id === this.meetingId) { + // Track latest ack_sequence for monitoring + if ( + typeof event.payload.ack_sequence === 'number' && + event.payload.ack_sequence > this.lastAckedSequence + ) { + this.lastAckedSequence = event.payload.ack_sequence; + } + callback(event.payload); + } + }); if (this.isClosed) { unlisten(); return; @@ -788,13 +785,16 @@ export function createTauriAPI(invoke: TauriInvoke, listen: TauriListen): NoteFl async listSummarizationTemplates( request: ListSummarizationTemplatesRequest ): Promise { - return invoke(TauriCommands.LIST_SUMMARIZATION_TEMPLATES, { - workspace_id: request.workspace_id, - include_system: request.include_system ?? true, - include_archived: request.include_archived ?? false, - limit: request.limit, - offset: request.offset, - }); + return invoke( + TauriCommands.LIST_SUMMARIZATION_TEMPLATES, + { + workspace_id: request.workspace_id, + include_system: request.include_system ?? true, + include_archived: request.include_archived ?? false, + limit: request.limit, + offset: request.offset, + } + ); }, async getSummarizationTemplate( @@ -992,11 +992,7 @@ export function createTauriAPI(invoke: TauriInvoke, listen: TauriListen): NoteFl clientLog.exportCompleted(meetingId, format); return result; } catch (error) { - clientLog.exportFailed( - meetingId, - format, - extractErrorMessage(error, 'Export failed') - ); + clientLog.exportFailed(meetingId, format, extractErrorMessage(error, 'Export failed')); throw error; } }, @@ -1108,7 +1104,10 @@ export function createTauriAPI(invoke: TauriInvoke, listen: TauriListen): NoteFl await invoke(TauriCommands.SET_DUAL_CAPTURE_ENABLED, { enabled }); }, async setAudioMixLevels(micGain: number, systemGain: number): Promise { - await invoke(TauriCommands.SET_AUDIO_MIX_LEVELS, { mic_gain: micGain, system_gain: systemGain }); + await invoke(TauriCommands.SET_AUDIO_MIX_LEVELS, { + mic_gain: micGain, + system_gain: systemGain, + }); }, async getDualCaptureConfig(): Promise { return invoke(TauriCommands.GET_DUAL_CAPTURE_CONFIG); @@ -1172,7 +1171,9 @@ export function createTauriAPI(invoke: TauriInvoke, listen: TauriListen): NoteFl }; }, - async listInstalledApps(options?: ListInstalledAppsRequest): Promise { + async listInstalledApps( + options?: ListInstalledAppsRequest + ): Promise { return invoke(TauriCommands.LIST_INSTALLED_APPS, { common_only: options?.commonOnly ?? false, page: options?.page ?? 0, @@ -1278,13 +1279,17 @@ export function createTauriAPI(invoke: TauriInvoke, listen: TauriListen): NoteFl }); }, async disconnectCalendar(provider: string): Promise { - const response = await invoke(TauriCommands.DISCONNECT_OAUTH, { provider }); + const response = await invoke(TauriCommands.DISCONNECT_OAUTH, { + provider, + }); clientLog.calendarDisconnected(provider); return response; }, async registerWebhook(r: RegisterWebhookRequest): Promise { - const webhook = await invoke(TauriCommands.REGISTER_WEBHOOK, { request: r }); + const webhook = await invoke(TauriCommands.REGISTER_WEBHOOK, { + request: r, + }); clientLog.webhookRegistered(webhook.id, webhook.name); return webhook; }, @@ -1297,7 +1302,9 @@ export function createTauriAPI(invoke: TauriInvoke, listen: TauriListen): NoteFl return invoke(TauriCommands.UPDATE_WEBHOOK, { request: r }); }, async deleteWebhook(webhookId: string): Promise { - const response = await invoke(TauriCommands.DELETE_WEBHOOK, { webhook_id: webhookId }); + const response = await invoke(TauriCommands.DELETE_WEBHOOK, { + webhook_id: webhookId, + }); clientLog.webhookDeleted(webhookId); return response; }, @@ -1420,11 +1427,7 @@ export function isTauriEnvironment(): boolean { } // Tauri 2.x injects __TAURI_INTERNALS__ into the window // Only check for Tauri-injected globals, not our own globals like __NOTEFLOW_API__ - return ( - '__TAURI_INTERNALS__' in window || - '__TAURI__' in window || - 'isTauri' in window - ); + return '__TAURI_INTERNALS__' in window || '__TAURI__' in window || 'isTauri' in window; } /** Dynamically import Tauri APIs and create the adapter. */ diff --git a/client/src/api/tauri-transcription-stream.test.ts b/client/src/api/tauri-transcription-stream.test.ts index f83c344..e9c9e8f 100644 --- a/client/src/api/tauri-transcription-stream.test.ts +++ b/client/src/api/tauri-transcription-stream.test.ts @@ -104,9 +104,10 @@ describe('TauriTranscriptionStream', () => { expect(errorCallback).toHaveBeenCalledTimes(1); }); + // The StreamingQueue reports failures with its own message format const expectedError: Record = { code: 'stream_send_failed', - message: expect.stringContaining('Connection lost'), + message: expect.stringContaining('consecutive failures'), }; expect(errorCallback).toHaveBeenCalledWith(expectedError); }); @@ -151,12 +152,12 @@ describe('TauriTranscriptionStream', () => { timestamp: 1, }); + // StreamingQueue logs errors via the async-queue module await vi.waitFor(() => { expect(mockAddClientLog).toHaveBeenCalledWith( expect.objectContaining({ level: 'error', - source: 'api', - message: 'Tauri stream send_audio_chunk failed', + message: expect.stringContaining('operation failed'), }) ); }); @@ -180,15 +181,16 @@ describe('TauriTranscriptionStream', () => { const failingStream = new TauriTranscriptionStream('meeting-123', failingInvoke, mockListen); failingStream.onError(errorCallback); - failingStream.close(); - - await vi.waitFor(() => { - const expectedError: Record = { - code: 'stream_close_failed', - message: expect.stringContaining('Failed to stop'), - }; - expect(errorCallback).toHaveBeenCalledWith(expectedError); + // close() re-throws errors, so we need to catch it + await failingStream.close().catch(() => { + // Expected to throw }); + + const expectedError: Record = { + code: 'stream_close_failed', + message: expect.stringContaining('Failed to stop'), + }; + expect(errorCallback).toHaveBeenCalledWith(expectedError); }); it('logs close errors to clientLog', async () => { @@ -197,17 +199,18 @@ describe('TauriTranscriptionStream', () => { const failingInvoke = vi.fn().mockRejectedValue(new Error('Stop failed')); const failingStream = new TauriTranscriptionStream('meeting-123', failingInvoke, mockListen); - failingStream.close(); - - await vi.waitFor(() => { - expect(mockAddClientLog).toHaveBeenCalledWith( - expect.objectContaining({ - level: 'error', - source: 'api', - message: 'Tauri stream stop_recording failed', - }) - ); + // close() re-throws errors, so we need to catch it + await failingStream.close().catch(() => { + // Expected to throw }); + + expect(mockAddClientLog).toHaveBeenCalledWith( + expect.objectContaining({ + level: 'error', + source: 'api', + message: 'Tauri stream stop_recording failed', + }) + ); }); }); diff --git a/client/src/api/types/features/asr.ts b/client/src/api/types/features/asr.ts index ebe8391..32b54ec 100644 --- a/client/src/api/types/features/asr.ts +++ b/client/src/api/types/features/asr.ts @@ -29,13 +29,7 @@ export type ASRComputeType = 'unspecified' | 'int8' | 'float16' | 'float32'; /** * Job status for background tasks */ -export type JobStatus = - | 'unspecified' - | 'queued' - | 'running' - | 'completed' - | 'failed' - | 'cancelled'; +export type JobStatus = 'unspecified' | 'queued' | 'running' | 'completed' | 'failed' | 'cancelled'; /** * Current ASR configuration and capabilities @@ -97,12 +91,7 @@ export interface UpdateASRConfigurationResult { /** * ASR reconfiguration job phase */ -export type ASRJobPhase = - | 'validating' - | 'downloading' - | 'loading' - | 'completed' - | 'failed'; +export type ASRJobPhase = 'validating' | 'downloading' | 'loading' | 'completed' | 'failed'; /** * Status of an ASR reconfiguration job diff --git a/client/src/components/analytics/log-entry.tsx b/client/src/components/analytics/log-entry.tsx index 93f767a..ee0a693 100644 --- a/client/src/components/analytics/log-entry.tsx +++ b/client/src/components/analytics/log-entry.tsx @@ -10,6 +10,7 @@ import { Button } from '@/components/ui/button'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { formatRelativeTimeMs } from '@/lib/format'; import { toFriendlyMessage } from '@/lib/log-messages'; +import { sanitizeLogMetadata } from '@/lib/log-sanitizer'; import type { SummarizedLog } from '@/lib/log-summarizer'; import { cn } from '@/lib/utils'; import { levelConfig } from './log-entry-config'; @@ -46,22 +47,24 @@ export interface LogEntryProps { } export function LogEntry({ summarized, viewMode, isExpanded, onToggleExpanded }: LogEntryProps) { - const {log} = summarized; + const { log } = summarized; + const sanitizedMetadata = log.metadata ? sanitizeLogMetadata(log.metadata) : undefined; + const safeLog = sanitizedMetadata ? { ...log, metadata: sanitizedMetadata } : log; const config = levelConfig[log.level]; const Icon = config.icon; - const hasDetails = log.details || log.metadata || log.traceId || log.spanId; + const hasDetails = safeLog.details || safeLog.metadata || safeLog.traceId || safeLog.spanId; // Get display message based on view mode const displayMessage = viewMode === 'friendly' - ? toFriendlyMessage(log.message, (log.metadata as Record) ?? {}) - : log.message; + ? toFriendlyMessage(safeLog.message, (safeLog.metadata as Record) ?? {}) + : safeLog.message; // Get display timestamp based on view mode const displayTimestamp = viewMode === 'friendly' - ? formatRelativeTimeMs(log.timestamp) - : format(new Date(log.timestamp), 'HH:mm:ss.SSS'); + ? formatRelativeTimeMs(safeLog.timestamp) + : format(new Date(safeLog.timestamp), 'HH:mm:ss.SSS'); return ( @@ -91,9 +94,7 @@ export function LogEntry({ summarized, viewMode, isExpanded, onToggleExpanded }: {log.source} - - {log.origin} - + {log.origin} )} {summarized.isGroup && summarized.count > 1 && ( @@ -104,12 +105,19 @@ export function LogEntry({ summarized, viewMode, isExpanded, onToggleExpanded }:

{displayMessage}

{viewMode === 'friendly' && summarized.isGroup && summarized.count > 1 && ( -

{summarized.count} similar events

+

+ {summarized.count} similar events +

)} {(hasDetails || viewMode === 'friendly') && ( -

@@ -164,12 +155,7 @@ export function ModelAuthSection({ {/* Action buttons for existing token */} {status?.isConfigured && (

-
diff --git a/client/src/components/settings/advanced-local-ai-settings/transcription-engine-section.tsx b/client/src/components/settings/advanced-local-ai-settings/transcription-engine-section.tsx index 49674eb..b98cd92 100644 --- a/client/src/components/settings/advanced-local-ai-settings/transcription-engine-section.tsx +++ b/client/src/components/settings/advanced-local-ai-settings/transcription-engine-section.tsx @@ -105,10 +105,7 @@ export function TranscriptionEngineSection({ () => (config ? getComputeTypesForDevice(effectiveDevice, config) : []), [effectiveDevice, config] ); - const resourceSnapshot = useMemo( - () => getResourceSnapshot(serverInfo), - [serverInfo] - ); + const resourceSnapshot = useMemo(() => getResourceSnapshot(serverInfo), [serverInfo]); const suggestedComputeType = useMemo( () => getSuggestedComputeType( @@ -135,10 +132,7 @@ export function TranscriptionEngineSection({ [effectiveDevice, sortedModelSizes, computeTypeForEstimate, resourceSnapshot] ); const resourceEstimate = useMemo( - () => - modelSizeValue - ? estimateResourceUsage(modelSizeValue, computeTypeForEstimate) - : null, + () => (modelSizeValue ? estimateResourceUsage(modelSizeValue, computeTypeForEstimate) : null), [modelSizeValue, computeTypeForEstimate] ); const deviceValue = effectiveDevice === 'unspecified' ? '' : effectiveDevice; @@ -149,10 +143,7 @@ export function TranscriptionEngineSection({ const recommendedComputeType = suggestedComputeType ?? computeTypeForEstimate; useEffect(() => { - if ( - pendingComputeType !== null && - !availableComputeTypes.includes(pendingComputeType) - ) { + if (pendingComputeType !== null && !availableComputeTypes.includes(pendingComputeType)) { setPendingComputeType(null); } }, [availableComputeTypes, pendingComputeType]); @@ -253,18 +244,10 @@ export function TranscriptionEngineSection({ - @@ -346,11 +329,7 @@ export function TranscriptionEngineSection({ {/* Action buttons */}
-
+ {!encryptionAvailable && ( + + + Secure storage unavailable + + API keys will not be persisted in this environment. Use the desktop app or a + secure (HTTPS) origin to enable encrypted storage. + + + )}

- This only affects recording. App playback uses the system default output. + This only affects recording. App playback uses the system default + output.

)} diff --git a/client/src/components/settings/connection-diagnostics-panel.tsx b/client/src/components/settings/connection-diagnostics-panel.tsx index eb8808a..76ddcd6 100644 --- a/client/src/components/settings/connection-diagnostics-panel.tsx +++ b/client/src/components/settings/connection-diagnostics-panel.tsx @@ -8,7 +8,7 @@ import { motion } from 'framer-motion'; import { AlertCircle, Loader2, RefreshCw, Stethoscope, X } from 'lucide-react'; import { useCallback, useState } from 'react'; -import { getAPI } from '@/api'; +import { getAPI } from '@/api/interface'; import type { ConnectionDiagnostics, DiagnosticStep } from '@/api/types'; import { SuccessIcon } from '@/components/icons/status-icons'; import { Button } from '@/components/ui/button'; diff --git a/client/src/components/settings/export-ai-section.tsx b/client/src/components/settings/export-ai-section.tsx index 277f8ae..ccaf5ac 100644 --- a/client/src/components/settings/export-ai-section.tsx +++ b/client/src/components/settings/export-ai-section.tsx @@ -1,13 +1,6 @@ import { motion } from 'framer-motion'; import { useEffect, useState } from 'react'; -import { - Briefcase, - Code, - FileText, - FolderOpen, - MessageSquare, - Sparkles, -} from 'lucide-react'; +import { Briefcase, Code, FileText, FolderOpen, MessageSquare, Sparkles } from 'lucide-react'; import type { AIFormat, AITone, AIVerbosity, ExportFormat } from '@/api/types'; import { isTauriEnvironment } from '@/api/tauri-adapter'; import { Button } from '@/components/ui/button'; @@ -98,7 +91,8 @@ const detectOSFromTauriPath = (path: string): DetectedOS => { }; const getExportLocationPlaceholder = (os: DetectedOS, tauriLocation: string | null): string => - tauriLocation ?? (os === 'windows' ? 'C:\\Users\\\\Documents\\NoteFlow' : '~/Documents/NoteFlow'); + tauriLocation ?? + (os === 'windows' ? 'C:\\Users\\\\Documents\\NoteFlow' : '~/Documents/NoteFlow'); const getExportLocationHint = (os: DetectedOS, tauriLocation: string | null): string => { if (tauriLocation) { @@ -298,7 +292,8 @@ export function ExportAISection({

- Presets use tone, format, and verbosity. Custom templates override the default summary prompt. + Presets use tone, format, and verbosity. Custom templates override the default + summary prompt.

diff --git a/client/src/components/settings/integrations-section/custom-integration-dialog.tsx b/client/src/components/settings/integrations-section/custom-integration-dialog.tsx index e03dfb4..c24c88c 100644 --- a/client/src/components/settings/integrations-section/custom-integration-dialog.tsx +++ b/client/src/components/settings/integrations-section/custom-integration-dialog.tsx @@ -155,7 +155,11 @@ interface TestAllButtonProps { export function TestAllButton({ onTest, isTesting }: TestAllButtonProps) { return ( ); diff --git a/client/src/components/settings/integrations-section/index.tsx b/client/src/components/settings/integrations-section/index.tsx index 9cb54c2..71ce2ff 100644 --- a/client/src/components/settings/integrations-section/index.tsx +++ b/client/src/components/settings/integrations-section/index.tsx @@ -6,14 +6,14 @@ import { motion } from 'framer-motion'; import { Box, Link2 } from 'lucide-react'; -import { useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; -import type { SyncState } from '@/hooks/use-integration-sync'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { CustomIntegrationDialog, TestAllButton } from './custom-integration-dialog'; import { groupIntegrationsByType } from './helpers'; +import { IntegrationSettingsProvider } from './integration-settings-context'; import { IntegrationItem } from './integration-item'; import type { IntegrationsSectionProps } from './types'; import { useIntegrationHandlers } from './use-integration-handlers'; @@ -42,103 +42,122 @@ export function IntegrationsSection({ handleTestAllIntegrations, } = useIntegrationHandlers({ integrations, setIntegrations }); + const handleTestIntegrationWithState = useCallback( + (integration: Parameters[0]) => + handleTestIntegration(integration, setTestingIntegration), + [handleTestIntegration, setTestingIntegration] + ); + + const contextValue = useMemo( + () => ({ + syncStates, + triggerSync, + testingIntegrationId: testingIntegration, + testIntegration: handleTestIntegrationWithState, + oauthState, + resetOAuth, + pendingOAuthIntegrationIdRef, + toggleIntegration: handleIntegrationToggle, + calendarConnect: handleCalendarConnect, + calendarDisconnect: handleCalendarDisconnect, + updateIntegrationConfig: handleUpdateIntegrationConfig, + removeIntegration: handleRemoveIntegration, + }), + [ + syncStates, + triggerSync, + testingIntegration, + handleTestIntegrationWithState, + oauthState, + resetOAuth, + pendingOAuthIntegrationIdRef, + handleIntegrationToggle, + handleCalendarConnect, + handleCalendarDisconnect, + handleUpdateIntegrationConfig, + handleRemoveIntegration, + ] + ); + const groupedIntegrations = groupIntegrationsByType(integrations); return ( - - - -
-
-
- + + + + +
+
+
+ +
+
+ Integrations + Connect external services and tools +
-
- Integrations - Connect external services and tools +
+ handleTestAllIntegrations(setTestingAllIntegrations)} + isTesting={testingAllIntegrations} + /> + handleAddCustomIntegration(formState, onClose)} + />
-
- handleTestAllIntegrations(setTestingAllIntegrations)} - isTesting={testingAllIntegrations} - /> - handleAddCustomIntegration(formState, onClose)} - /> -
-
-
- - - - - Auth/SSO - - - Email - - - Calendar - - - PKM - - - OIDC - - - Custom - - + + + + + + Auth/SSO + + + Email + + + Calendar + + + PKM + + + OIDC + + + Custom + + - {Object.entries(groupedIntegrations).map(([type, items]) => ( - - {items.length === 0 ? ( -
- -

No {type} integrations configured

-
- ) : ( - items.map((integration) => { - const integrationSyncState: SyncState = syncStates[integration.id] ?? { - status: 'idle', - lastSync: null, - nextSync: null, - }; - - return ( + {Object.entries(groupedIntegrations).map(([type, items]) => ( + + {items.length === 0 ? ( +
+ +

No {type} integrations configured

+
+ ) : ( + items.map((integration) => ( handleTestIntegration(i, setTestingIntegration)} - onRemove={handleRemoveIntegration} - onResetOAuth={resetOAuth} - pendingOAuthRef={pendingOAuthIntegrationIdRef} /> - ); - }) - )} -
- ))} -
-
-
-
+ )) + )} + + ))} + + + + +
); } diff --git a/client/src/components/settings/integrations-section/integration-item.tsx b/client/src/components/settings/integrations-section/integration-item.tsx index db0f158..c523e3b 100644 --- a/client/src/components/settings/integrations-section/integration-item.tsx +++ b/client/src/components/settings/integrations-section/integration-item.tsx @@ -3,7 +3,6 @@ */ import { AlertTriangle, Clock, Loader2, X } from 'lucide-react'; -import type { MutableRefObject } from 'react'; import type { Integration } from '@/api/types'; import { IntegrationConfigPanel } from '@/components/integration-config-panel'; @@ -12,51 +11,41 @@ import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { Switch } from '@/components/ui/switch'; -import type { OAuthFlowState } from '@/hooks/use-oauth-flow'; -import type { SyncState } from '@/hooks/use-integration-sync'; import { formatTimestamp } from '@/lib/format'; import { getIntegrationIcon, hasRequiredIntegrationFields } from '@/lib/integration-utils'; import { iconWithMargin } from '@/lib/styles'; import { getCalendarProvider } from './helpers'; +import { useIntegrationSettingsContext } from './integration-settings-context'; interface IntegrationItemProps { integration: Integration; isExpanded: boolean; onToggleExpand: (id: string | null) => void; - syncState: SyncState; - triggerSync: (id: string) => void; - testingIntegration: string | null; - oauthState: OAuthFlowState; - onIntegrationToggle: (integration: Integration) => void; - onCalendarConnect: (integration: Integration) => void; - onCalendarDisconnect: (integration: Integration) => void; - onUpdateConfig: (id: string, config: Partial) => void; - onTest: (integration: Integration) => void; - onRemove: (id: string) => void; - onResetOAuth: () => void; - pendingOAuthRef: MutableRefObject; } export function IntegrationItem({ integration, isExpanded, onToggleExpand, - syncState, - triggerSync, - testingIntegration, - oauthState, - onIntegrationToggle, - onCalendarConnect, - onCalendarDisconnect, - onUpdateConfig, - onTest, - onRemove, - onResetOAuth, - pendingOAuthRef, }: IntegrationItemProps) { + const { + syncStates, + triggerSync, + testingIntegrationId, + testIntegration, + oauthState, + resetOAuth, + pendingOAuthIntegrationIdRef, + toggleIntegration, + calendarConnect, + calendarDisconnect, + updateIntegrationConfig, + removeIntegration, + } = useIntegrationSettingsContext(); const Icon = getIntegrationIcon(integration.type); - const calendarProvider = integration.type === 'calendar' ? getCalendarProvider(integration) : null; + const calendarProvider = + integration.type === 'calendar' ? getCalendarProvider(integration) : null; const isCalendarProviderSupported = Boolean(calendarProvider); const hasServerIntegration = Boolean(integration.integration_id); const hasRequiredFields = hasRequiredIntegrationFields(integration); @@ -67,18 +56,13 @@ export function IntegrationItem({ oauthState.status === 'awaiting_callback' || oauthState.status === 'completing'); const isCalendarConnected = - integration.type === 'calendar' && - integration.status === 'connected' && - hasServerIntegration; + integration.type === 'calendar' && integration.status === 'connected' && hasServerIntegration; const isCredentialMismatch = integration.status === 'connected' && !hasRequiredFields; - const displayStatus = - isCredentialMismatch - ? 'error' - : integration.type === 'calendar' && - integration.status === 'connected' && - !hasServerIntegration - ? 'disconnected' - : integration.status; + const displayStatus = isCredentialMismatch + ? 'error' + : integration.type === 'calendar' && integration.status === 'connected' && !hasServerIntegration + ? 'disconnected' + : integration.status; const isPkmSyncEnabled = integration.type === 'pkm' ? Boolean(integration.pkm_config?.sync_enabled) : true; const isSyncableConnected = @@ -88,6 +72,11 @@ export function IntegrationItem({ hasServerIntegration && hasRequiredFields; const canTestIntegration = integration.type !== 'calendar'; + const syncState = syncStates[integration.id] ?? { + status: 'idle', + lastSync: null, + nextSync: null, + }; return ( { - event.stopPropagation(); - if (isCalendarConnected) { - void onCalendarDisconnect(integration); - } else { - void onCalendarConnect(integration); - } - }} - disabled={!isCalendarProviderSupported || isOAuthPending} - > + event.stopPropagation(); + if (isCalendarConnected) { + void calendarDisconnect(integration); + } else { + void calendarConnect(integration); + } + }} + disabled={!isCalendarProviderSupported || isOAuthPending} + > {isOAuthPending ? : null} {isOAuthPending ? oauthState.status === 'awaiting_callback' @@ -166,8 +155,8 @@ export function IntegrationItem({ size="sm" onClick={(event) => { event.stopPropagation(); - onResetOAuth(); - pendingOAuthRef.current = null; + resetOAuth(); + pendingOAuthIntegrationIdRef.current = null; }} > Cancel @@ -177,7 +166,7 @@ export function IntegrationItem({ ) : ( onIntegrationToggle(integration)} + onCheckedChange={() => toggleIntegration(integration)} onClick={(e) => e.stopPropagation()} /> )} @@ -188,7 +177,7 @@ export function IntegrationItem({ className="h-8 w-8" onClick={(e) => { e.stopPropagation(); - onRemove(integration.id); + removeIntegration(integration.id); }} > @@ -213,9 +202,9 @@ export function IntegrationItem({ )} onUpdateConfig(integration.id, config)} - onTest={canTestIntegration ? () => onTest(integration) : undefined} - isTesting={canTestIntegration && testingIntegration === integration.id} + onUpdate={(config) => updateIntegrationConfig(integration.id, config)} + onTest={canTestIntegration ? () => void testIntegration(integration) : undefined} + isTesting={canTestIntegration && testingIntegrationId === integration.id} />
diff --git a/client/src/components/settings/integrations-section/integration-settings-context.tsx b/client/src/components/settings/integrations-section/integration-settings-context.tsx new file mode 100644 index 0000000..2990c92 --- /dev/null +++ b/client/src/components/settings/integrations-section/integration-settings-context.tsx @@ -0,0 +1,45 @@ +import { createContext, useContext } from 'react'; +import type { MutableRefObject, ReactNode } from 'react'; + +import type { Integration } from '@/api/types'; +import type { OAuthFlowState } from '@/hooks/use-oauth-flow'; +import type { SyncState } from '@/hooks/use-integration-sync'; + +export interface IntegrationSettingsContextValue { + syncStates: Record; + triggerSync: (integrationId: string) => void; + testingIntegrationId: string | null; + testIntegration: (integration: Integration) => Promise; + oauthState: OAuthFlowState; + resetOAuth: () => void; + pendingOAuthIntegrationIdRef: MutableRefObject; + toggleIntegration: (integration: Integration) => void; + calendarConnect: (integration: Integration) => void; + calendarDisconnect: (integration: Integration) => void; + updateIntegrationConfig: (integrationId: string, config: Partial) => void; + removeIntegration: (integrationId: string) => void; +} + +const IntegrationSettingsContext = createContext(null); + +export function IntegrationSettingsProvider({ + value, + children, +}: { + value: IntegrationSettingsContextValue; + children: ReactNode; +}) { + return ( + + {children} + + ); +} + +export function useIntegrationSettingsContext(): IntegrationSettingsContextValue { + const context = useContext(IntegrationSettingsContext); + if (!context) { + throw new Error('useIntegrationSettingsContext must be used within IntegrationSettingsProvider'); + } + return context; +} diff --git a/client/src/components/settings/integrations-section/use-integration-handlers.test.tsx b/client/src/components/settings/integrations-section/use-integration-handlers.test.tsx new file mode 100644 index 0000000..6c6544a --- /dev/null +++ b/client/src/components/settings/integrations-section/use-integration-handlers.test.tsx @@ -0,0 +1,319 @@ +import { act, renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { DEFAULT_OIDC_CLAIM_MAPPING } from '@/api/types'; +import type { Integration } from '@/api/types'; +import { toast } from '@/hooks/use-toast'; +import { preferences } from '@/lib/preferences'; +import { useIntegrationHandlers } from './use-integration-handlers'; + +const integrationState = vi.hoisted(() => ({ + integrations: [] as Integration[], +})); + +const mockPreferences = vi.hoisted(() => ({ + getIntegrations: vi.fn(() => integrationState.integrations), + updateIntegration: vi.fn((id: string, updates: Partial) => { + integrationState.integrations = integrationState.integrations.map((integration) => + integration.id === id ? { ...integration, ...updates } : integration + ); + }), + addCustomIntegration: vi.fn((name: string, config: Integration['webhook_config']) => { + const id = `custom-${integrationState.integrations.length + 1}`; + integrationState.integrations = [ + ...integrationState.integrations, + { + id, + name, + type: 'custom', + status: 'disconnected', + webhook_config: config, + }, + ]; + }), + removeIntegration: vi.fn((id: string) => { + integrationState.integrations = integrationState.integrations.filter( + (integration) => integration.id !== id + ); + }), +})); + +const secureStorageState = vi.hoisted(() => ({ + available: true, +})); + +const secureSecretsState = vi.hoisted(() => ({ + saveSecrets: vi.fn(), + clearSecrets: vi.fn(), +})); + +const oauthFlowState = vi.hoisted(() => ({ + state: { + status: 'idle', + provider: null, + authUrl: null, + error: null, + connection: null, + integrationId: null, + }, + initiateAuth: vi.fn(), + disconnect: vi.fn(), + reset: vi.fn(), +})); + +const oidcProvidersState = vi.hoisted(() => ({ + createProvider: vi.fn(), + updateProvider: vi.fn(), +})); + +const apiState = vi.hoisted(() => ({ + testOidcConnection: vi.fn(), +})); + +vi.mock('@/lib/preferences', () => ({ + preferences: mockPreferences, +})); + +vi.mock('@/lib/crypto', () => ({ + isSecureStorageAvailable: () => secureStorageState.available, +})); + +vi.mock('@/hooks/use-secure-integration-secrets', () => ({ + useSecureIntegrationSecrets: () => secureSecretsState, +})); + +vi.mock('@/hooks/use-oauth-flow', () => ({ + useOAuthFlow: () => oauthFlowState, +})); + +vi.mock('@/hooks/use-oidc-providers', () => ({ + useOidcProviders: () => oidcProvidersState, +})); + +vi.mock('@/api/interface', () => ({ + getAPI: () => apiState, +})); + +vi.mock('@/hooks/use-toast', () => ({ + toast: vi.fn(), +})); + +vi.mock('@/lib/error-reporting', () => ({ + toastError: vi.fn(() => 'Toast error'), +})); + +vi.mock('@/contexts/workspace-state', () => ({ + useWorkspace: () => ({ currentWorkspace: { id: 'workspace-1' } }), +})); + +function createIntegration(overrides: Partial): Integration { + return { + id: 'integration-1', + name: 'Test Integration', + type: 'custom', + status: 'disconnected', + webhook_config: { url: 'https://example.com', method: 'POST' }, + ...overrides, + }; +} + +describe('useIntegrationHandlers', () => { + beforeEach(() => { + integrationState.integrations = []; + secureStorageState.available = true; + secureSecretsState.saveSecrets.mockReset(); + secureSecretsState.clearSecrets.mockReset(); + oidcProvidersState.createProvider.mockReset(); + oidcProvidersState.updateProvider.mockReset(); + apiState.testOidcConnection.mockReset(); + vi.mocked(preferences.getIntegrations).mockClear(); + vi.mocked(preferences.updateIntegration).mockClear(); + vi.mocked(preferences.addCustomIntegration).mockClear(); + vi.mocked(preferences.removeIntegration).mockClear(); + vi.mocked(toast).mockClear(); + }); + + it('blocks toggling when credentials are missing', () => { + const integration = createIntegration({ + type: 'calendar', + name: 'Google Calendar', + oauth_config: undefined, + }); + integrationState.integrations = [integration]; + const setIntegrations = vi.fn(); + + const { result } = renderHook(() => + useIntegrationHandlers({ integrations: integrationState.integrations, setIntegrations }) + ); + + act(() => { + result.current.handleIntegrationToggle(integration); + }); + + expect(toast).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Missing credentials', + variant: 'destructive', + }) + ); + expect(preferences.updateIntegration).not.toHaveBeenCalled(); + }); + + it('toggles integration status when configured', () => { + const integration = createIntegration({ + id: 'integration-2', + type: 'custom', + status: 'disconnected', + webhook_config: { url: 'https://example.com', method: 'POST' }, + }); + integrationState.integrations = [integration]; + const setIntegrations = vi.fn(); + + const { result } = renderHook(() => + useIntegrationHandlers({ integrations: integrationState.integrations, setIntegrations }) + ); + + act(() => { + result.current.handleIntegrationToggle(integration); + }); + + expect(preferences.updateIntegration).toHaveBeenCalledWith(integration.id, { + status: 'connected', + }); + expect(setIntegrations).toHaveBeenCalledWith(preferences.getIntegrations()); + expect(toast).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Connected', + }) + ); + }); + + it('persists secrets when updating configs with secure storage', async () => { + const integration = createIntegration({ + id: 'integration-3', + type: 'email', + email_config: { provider_type: 'api', api_key: 'old-key' }, + }); + integrationState.integrations = [integration]; + const setIntegrations = vi.fn(); + + const { result } = renderHook(() => + useIntegrationHandlers({ integrations: integrationState.integrations, setIntegrations }) + ); + + await act(async () => { + await result.current.handleUpdateIntegrationConfig(integration.id, { + email_config: { provider_type: 'api', api_key: 'new-key' }, + }); + }); + + expect(secureSecretsState.saveSecrets).toHaveBeenCalledWith( + expect.objectContaining({ + id: integration.id, + email_config: { provider_type: 'api', api_key: 'new-key' }, + }) + ); + }); + + it('marks integration as error when testing without required fields', async () => { + const integration = createIntegration({ + id: 'integration-4', + type: 'email', + email_config: { provider_type: 'api' }, + }); + integrationState.integrations = [integration]; + const setIntegrations = vi.fn(); + const setTesting = vi.fn(); + + const { result } = renderHook(() => + useIntegrationHandlers({ integrations: integrationState.integrations, setIntegrations }) + ); + + await act(async () => { + await result.current.handleTestIntegration(integration, setTesting); + }); + + expect(preferences.updateIntegration).toHaveBeenCalledWith(integration.id, { + status: 'error', + error_message: 'Missing required fields', + }); + expect(setTesting).toHaveBeenCalledWith(integration.id); + expect(setTesting).toHaveBeenCalledWith(null); + }); + + it('tests OIDC integrations through the API when configured', async () => { + const integration = createIntegration({ + id: 'integration-5', + type: 'oidc', + name: 'Auth0', + integration_id: 'oidc-1', + oidc_config: { + preset: 'auth0', + issuer_url: 'https://auth0.test', + client_id: 'client-1', + scopes: ['openid', 'profile'], + claim_mapping: DEFAULT_OIDC_CLAIM_MAPPING, + require_email_verified: false, + allowed_groups: [], + }, + }); + integrationState.integrations = [integration]; + const setIntegrations = vi.fn(); + const setTesting = vi.fn(); + + apiState.testOidcConnection.mockResolvedValue({ + results: { 'oidc-1': '' }, + success_count: 1, + failure_count: 0, + }); + + const { result } = renderHook(() => + useIntegrationHandlers({ integrations: integrationState.integrations, setIntegrations }) + ); + + await act(async () => { + await result.current.handleTestIntegration(integration, setTesting); + }); + + expect(apiState.testOidcConnection).toHaveBeenCalledWith('oidc-1'); + expect(preferences.updateIntegration).toHaveBeenCalledWith( + integration.id, + expect.objectContaining({ + status: 'connected', + }) + ); + expect(toast).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Connection test passed', + }) + ); + }); + + it('short-circuits test-all when no integrations are configured', async () => { + const integration = createIntegration({ + id: 'integration-6', + type: 'calendar', + name: 'Google Calendar', + oauth_config: undefined, + }); + integrationState.integrations = [integration]; + const setIntegrations = vi.fn(); + const setTestingAll = vi.fn(); + + const { result } = renderHook(() => + useIntegrationHandlers({ integrations: integrationState.integrations, setIntegrations }) + ); + + await act(async () => { + await result.current.handleTestAllIntegrations(setTestingAll); + }); + + expect(setTestingAll).toHaveBeenCalledWith(true); + expect(setTestingAll).toHaveBeenCalledWith(false); + expect(toast).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'No configured integrations', + }) + ); + }); +}); diff --git a/client/src/components/settings/ollama-status-card.tsx b/client/src/components/settings/ollama-status-card.tsx index ad572fd..e449ced 100644 --- a/client/src/components/settings/ollama-status-card.tsx +++ b/client/src/components/settings/ollama-status-card.tsx @@ -6,13 +6,7 @@ */ import { useCallback, useEffect, useState } from 'react'; -import { - CheckCircle2, - Loader2, - RefreshCw, - Server, - XCircle, -} from 'lucide-react'; +import { CheckCircle2, Loader2, RefreshCw, Server, XCircle } from 'lucide-react'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; diff --git a/client/src/components/settings/provider-config-card.tsx b/client/src/components/settings/provider-config-card.tsx index b64c6e5..ba09414 100644 --- a/client/src/components/settings/provider-config-card.tsx +++ b/client/src/components/settings/provider-config-card.tsx @@ -189,9 +189,7 @@ export function ProviderConfigCard({ onChange={(e) => onModelChange(e.target.value)} placeholder="Enter model name" /> - {manualModelHint && ( -

{manualModelHint}

- )} + {manualModelHint &&

{manualModelHint}

}
)} @@ -244,9 +242,7 @@ export function ProviderConfigCard({
- diff --git a/client/src/components/settings/summarization-templates-list.tsx b/client/src/components/settings/summarization-templates-list.tsx index 91e38b4..35f55b8 100644 --- a/client/src/components/settings/summarization-templates-list.tsx +++ b/client/src/components/settings/summarization-templates-list.tsx @@ -244,13 +244,21 @@ export function SummarizationTemplatesList({ Versions {canManageTemplates && !template.is_system && !template.is_archived && ( - )} {canManageTemplates && !template.is_system && !template.is_archived && ( - @@ -263,7 +271,10 @@ export function SummarizationTemplatesList({
- setEditingName(event.target.value)} /> + setEditingName(event.target.value)} + />
diff --git a/client/src/components/tauri-event-listener.tsx b/client/src/components/tauri-event-listener.tsx index 742f37f..ec339b8 100644 --- a/client/src/components/tauri-event-listener.tsx +++ b/client/src/components/tauri-event-listener.tsx @@ -1,7 +1,7 @@ import { useRef } from 'react'; import { useNavigate } from 'react-router-dom'; -import { getAPI } from '@/api'; +import { getAPI } from '@/api/interface'; import { TauriEvents } from '@/api/tauri-adapter'; import { ToastAction } from '@/components/ui/toast'; import { toast } from '@/hooks/use-toast'; diff --git a/client/src/components/timestamped-notes-editor.tsx b/client/src/components/timestamped-notes-editor.tsx index 7ecda3a..6a54719 100644 --- a/client/src/components/timestamped-notes-editor.tsx +++ b/client/src/components/timestamped-notes-editor.tsx @@ -8,6 +8,7 @@ import { ScrollArea } from '@/components/ui/scroll-area'; import { Switch } from '@/components/ui/switch'; import { Textarea } from '@/components/ui/textarea'; import { formatElapsedTime } from '@/lib/format'; +import { generateUuid } from '@/lib/id-utils'; import { cn } from '@/lib/utils'; export interface NoteEdit { @@ -70,7 +71,7 @@ export function TimestampedNotesEditor({ const isFirstNote = notes.length === 0; const newNote: NoteEdit = { - id: crypto.randomUUID(), + id: generateUuid(), timestamp: elapsedTime, createdAt: new Date(), content: trimmed, diff --git a/client/src/components/ui/icon-circle.tsx b/client/src/components/ui/icon-circle.tsx index bcc699f..f046a2e 100644 --- a/client/src/components/ui/icon-circle.tsx +++ b/client/src/components/ui/icon-circle.tsx @@ -8,32 +8,29 @@ import * as React from 'react'; import { cva, type VariantProps } from '@/lib/cva'; import { cn } from '@/lib/utils'; -const iconCircleVariants = cva( - 'inline-flex items-center justify-center rounded-full shrink-0', - { - variants: { - variant: { - default: 'bg-muted text-muted-foreground', - primary: 'bg-primary/10 text-primary', - success: 'bg-success/10 text-success', - warning: 'bg-warning/10 text-warning', - destructive: 'bg-destructive/10 text-destructive', - info: 'bg-blue-500/10 text-blue-500', - outline: 'border border-border bg-transparent', - }, - size: { - sm: 'h-6 w-6 [&_svg]:h-3 [&_svg]:w-3', - default: 'h-8 w-8 [&_svg]:h-4 [&_svg]:w-4', - lg: 'h-10 w-10 [&_svg]:h-5 [&_svg]:w-5', - xl: 'h-12 w-12 [&_svg]:h-6 [&_svg]:w-6', - }, +const iconCircleVariants = cva('inline-flex items-center justify-center rounded-full shrink-0', { + variants: { + variant: { + default: 'bg-muted text-muted-foreground', + primary: 'bg-primary/10 text-primary', + success: 'bg-success/10 text-success', + warning: 'bg-warning/10 text-warning', + destructive: 'bg-destructive/10 text-destructive', + info: 'bg-blue-500/10 text-blue-500', + outline: 'border border-border bg-transparent', }, - defaultVariants: { - variant: 'default', - size: 'default', + size: { + sm: 'h-6 w-6 [&_svg]:h-3 [&_svg]:w-3', + default: 'h-8 w-8 [&_svg]:h-4 [&_svg]:w-4', + lg: 'h-10 w-10 [&_svg]:h-5 [&_svg]:w-5', + xl: 'h-12 w-12 [&_svg]:h-6 [&_svg]:w-6', }, - } -); + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, +}); export interface IconCircleProps extends React.HTMLAttributes, @@ -65,11 +62,7 @@ export interface IconCircleProps const IconCircle = React.forwardRef( ({ className, variant, size, children, ...props }, ref) => { return ( -
+
{children}
); diff --git a/client/src/components/ui/scroll-area.tsx b/client/src/components/ui/scroll-area.tsx index a36a9b5..69d2ab7 100644 --- a/client/src/components/ui/scroll-area.tsx +++ b/client/src/components/ui/scroll-area.tsx @@ -3,16 +3,23 @@ import * as React from 'react'; import { cn } from '@/lib/utils'; +type ScrollAreaProps = React.ComponentPropsWithoutRef & { + viewportRef?: React.Ref; +}; + const ScrollArea = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( + ScrollAreaProps +>(({ className, children, viewportRef, ...props }, ref) => ( - + {children} diff --git a/client/src/components/webhook-settings-panel.tsx b/client/src/components/webhook-settings-panel.tsx index 842b4f5..4c07940 100644 --- a/client/src/components/webhook-settings-panel.tsx +++ b/client/src/components/webhook-settings-panel.tsx @@ -1,16 +1,7 @@ // Webhook management settings panel component import { formatDistanceToNow } from 'date-fns'; -import { - AlertCircle, - History, - Loader2, - Plus, - Settings2, - Trash2, - Webhook, - X, -} from 'lucide-react'; +import { AlertCircle, History, Loader2, Plus, Settings2, Trash2, Webhook, X } from 'lucide-react'; import { useEffect, useState } from 'react'; import { Placeholders, Timing } from '@/api/constants'; import type { RegisteredWebhook, WebhookDelivery, WebhookEventType } from '@/api/types'; diff --git a/client/src/contexts/connection-context.tsx b/client/src/contexts/connection-context.tsx index c845da9..5e3dda3 100644 --- a/client/src/contexts/connection-context.tsx +++ b/client/src/contexts/connection-context.tsx @@ -2,7 +2,12 @@ // (Sprint GAP-007: Simulation Mode Clarity - expose mode and simulation state) import { useEffect, useMemo, useState } from 'react'; -import { getConnectionState, setConnectionMode, setConnectionServerUrl, subscribeConnectionState } from '@/api/connection-state'; +import { + getConnectionState, + setConnectionMode, + setConnectionServerUrl, + subscribeConnectionState, +} from '@/api/connection-state'; import type { ConnectionState } from '@/api/connection-state'; import { TauriEvents } from '@/api/tauri-adapter'; import { ConnectionContext, type ConnectionHelpers } from '@/contexts/connection-state'; diff --git a/client/src/contexts/project-context.tsx b/client/src/contexts/project-context.tsx index d6f8f2d..91a53e1 100644 --- a/client/src/contexts/project-context.tsx +++ b/client/src/contexts/project-context.tsx @@ -161,9 +161,7 @@ export function ProjectProvider({ children }: { children: React.ReactNode }) { const updated = await getAPI().archiveProject(projectId); // Use functional state update to avoid stale closure setProjects((prev) => { - const nextProjects = prev.map((project) => - project.id === updated.id ? updated : project - ); + const nextProjects = prev.map((project) => (project.id === updated.id ? updated : project)); // Handle active project switch inside the updater to use fresh state if (activeProjectId === projectId && currentWorkspace) { const nextActive = resolveActiveProject(nextProjects, null); diff --git a/client/src/contexts/workspace-context.tsx b/client/src/contexts/workspace-context.tsx index ec992e5..cb8dfd2 100644 --- a/client/src/contexts/workspace-context.tsx +++ b/client/src/contexts/workspace-context.tsx @@ -106,7 +106,8 @@ export function WorkspaceProvider({ children }: { children: React.ReactNode }) { const response = await api.switchWorkspace(workspaceId); // Use ref to get current workspaces without stale closure const selected = - response.workspace ?? workspacesRef.current.find((workspace) => workspace.id === workspaceId); + response.workspace ?? + workspacesRef.current.find((workspace) => workspace.id === workspaceId); if (!response.success || !selected) { throw new Error('Workspace not found'); } diff --git a/client/src/hooks/post-processing/state.ts b/client/src/hooks/post-processing/state.ts index de87eee..e10b4a8 100644 --- a/client/src/hooks/post-processing/state.ts +++ b/client/src/hooks/post-processing/state.ts @@ -137,9 +137,7 @@ export function shouldAutoStartProcessing( // If processing is already complete or in progress, don't restart const { summary, entities, diarization } = processingStatus; const anyFailed = - summary.status === 'failed' || - entities.status === 'failed' || - diarization.status === 'failed'; + summary.status === 'failed' || entities.status === 'failed' || diarization.status === 'failed'; const allTerminal = ['completed', 'failed', 'skipped'].includes(summary.status) && ['completed', 'failed', 'skipped'].includes(entities.status) && diff --git a/client/src/hooks/use-asr-config.ts b/client/src/hooks/use-asr-config.ts index d0f0c35..dde44dc 100644 --- a/client/src/hooks/use-asr-config.ts +++ b/client/src/hooks/use-asr-config.ts @@ -17,6 +17,8 @@ import { useConnectionState } from '@/contexts/connection-state'; /** Polling interval for job status (ms) */ const JOB_POLL_INTERVAL = 500; +/** Maximum polling duration before timeout (5 minutes) */ +const MAX_POLL_DURATION_MS = 5 * 60 * 1000; /** Load state for async operations. */ type LoadState = 'idle' | 'loading' | 'ready' | 'failed'; @@ -59,15 +61,23 @@ interface UseAsrConfigReturn { export function useAsrConfig(): UseAsrConfigReturn { const [state, setState] = useState(initialState); - const pollingRef = useRef(null); const { mode } = useConnectionState(); const lastModeRef = useRef(mode); + // Polling state with generation token for proper cancellation + const pollTimeoutRef = useRef | null>(null); + const pollGenerationRef = useRef(0); + const pollStartTimeRef = useRef(null); + const isMountedRef = useRef(true); + const cancelPolling = useCallback(() => { - if (pollingRef.current !== null) { - window.clearInterval(pollingRef.current); - pollingRef.current = null; + // Increment generation to invalidate any in-flight polls + pollGenerationRef.current++; + if (pollTimeoutRef.current !== null) { + clearTimeout(pollTimeoutRef.current); + pollTimeoutRef.current = null; } + pollStartTimeRef.current = null; }, []); // Load initial configuration @@ -86,6 +96,14 @@ export function useAsrConfig(): UseAsrConfigReturn { } }, []); + // Mount/unmount tracking + useEffect(() => { + isMountedRef.current = true; + return () => { + isMountedRef.current = false; + }; + }, []); + useEffect(() => { void refresh(); return cancelPolling; @@ -101,12 +119,37 @@ export function useAsrConfig(): UseAsrConfigReturn { } }, [mode, refresh]); - // Poll for job status updates + // Poll for job status updates with generation token for proper cancellation const pollJobStatus = useCallback( - async (jobId: string) => { + async (jobId: string, generation: number) => { + // Check if this poll has been cancelled (generation changed) + if (generation !== pollGenerationRef.current || !isMountedRef.current) { + return; + } + + // Check max poll duration + if (pollStartTimeRef.current !== null) { + const elapsed = Date.now() - pollStartTimeRef.current; + if (elapsed > MAX_POLL_DURATION_MS) { + cancelPolling(); + setState((prev) => ({ + ...prev, + isReconfiguring: false, + errorMessage: 'Reconfiguration timed out', + })); + return; + } + } + try { const api = getAPI(); const status = await api.getAsrJobStatus(jobId); + + // Re-check generation after async work + if (generation !== pollGenerationRef.current || !isMountedRef.current) { + return; + } + setState((prev) => ({ ...prev, jobStatus: status })); if (status.status === 'completed') { @@ -136,8 +179,18 @@ export function useAsrConfig(): UseAsrConfigReturn { isReconfiguring: false, errorMessage: status.errorMessage || 'Reconfiguration cancelled', })); + } else { + // Continue polling with setTimeout (not setInterval) for sequential execution + pollTimeoutRef.current = setTimeout( + () => void pollJobStatus(jobId, generation), + JOB_POLL_INTERVAL + ); } } catch (err) { + // Re-check generation after async work + if (generation !== pollGenerationRef.current || !isMountedRef.current) { + return; + } cancelPolling(); setState((prev) => ({ ...prev, @@ -151,6 +204,8 @@ export function useAsrConfig(): UseAsrConfigReturn { const updateConfig = useCallback( async (request: UpdateASRConfigurationRequest): Promise => { + // Cancel any existing polling before starting new config update + cancelPolling(); setState((prev) => ({ ...prev, errorMessage: null, isReconfiguring: true })); try { @@ -166,7 +221,10 @@ export function useAsrConfig(): UseAsrConfigReturn { return false; } - // Start polling for job status + // Start polling for job status with new generation token + const generation = ++pollGenerationRef.current; + pollStartTimeRef.current = Date.now(); + setState((prev) => ({ ...prev, jobStatus: { @@ -179,9 +237,11 @@ export function useAsrConfig(): UseAsrConfigReturn { }, })); - pollingRef.current = window.setInterval(() => { - void pollJobStatus(result.jobId); - }, JOB_POLL_INTERVAL); + // Use setTimeout for first poll (sequential polling, not overlapping setInterval) + pollTimeoutRef.current = setTimeout( + () => void pollJobStatus(result.jobId, generation), + JOB_POLL_INTERVAL + ); return true; } catch (err) { @@ -193,7 +253,7 @@ export function useAsrConfig(): UseAsrConfigReturn { return false; } }, - [pollJobStatus] + [cancelPolling, pollJobStatus] ); return { diff --git a/client/src/hooks/use-audio-devices.helpers.ts b/client/src/hooks/use-audio-devices.helpers.ts index 86e033a..725163f 100644 --- a/client/src/hooks/use-audio-devices.helpers.ts +++ b/client/src/hooks/use-audio-devices.helpers.ts @@ -27,7 +27,7 @@ export async function resolveStoredDevice( kind: AudioDeviceMatchKind, setSelected: (id: string) => void ): Promise { - const hadStoredSelection = Boolean(storedId); + const hadStoredSelection = Boolean(storedId || storedName); let resolvedId = storedId; // If stored ID matches a current device, use it directly @@ -46,8 +46,8 @@ export async function resolveStoredDevice( return { resolvedId, hadStoredSelection }; } - // Try to resolve the stored ID to a current device - if (storedId) { + // Try to resolve the stored ID/name to a current device + if (storedId || storedName) { const resolved = resolveAudioDeviceId(devices, storedId, kind, storedName); if (resolved) { resolvedId = resolved; @@ -59,7 +59,7 @@ export async function resolveStoredDevice( level: 'info', source: 'app', message: `${kind} device resolved for session (original preserved)`, - metadata: { stored_id: storedId, resolved_id: resolved }, + metadata: { stored_id: storedId, stored_name: storedName, resolved_id: resolved }, }); } catch (error) { addClientLog({ @@ -75,7 +75,7 @@ export async function resolveStoredDevice( level: 'warning', source: 'app', message: `Stored ${kind} device not available`, - metadata: { device_id: storedId }, + metadata: { device_id: storedId, device_name: storedName }, }); } } diff --git a/client/src/hooks/use-audio-devices.test.ts b/client/src/hooks/use-audio-devices.test.ts index aa5cdc2..aca58ed 100644 --- a/client/src/hooks/use-audio-devices.test.ts +++ b/client/src/hooks/use-audio-devices.test.ts @@ -1,6 +1,6 @@ import { act, renderHook } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { getAPI } from '@/api'; +import { getAPI } from '@/api/interface'; import { TauriCommands } from '@/api/constants'; import { isTauriEnvironment } from '@/api/tauri-adapter'; import { toast } from '@/hooks/use-toast'; @@ -8,7 +8,7 @@ import { preferences } from '@/lib/preferences'; import { useTauriEvent } from '@/lib/tauri-events'; import { useAudioDevices } from './use-audio-devices'; -vi.mock('@/api', () => ({ +vi.mock('@/api/interface', () => ({ getAPI: vi.fn(), })); @@ -388,9 +388,9 @@ describe('useAudioDevices', () => { const selectAudioDevice = vi.fn(); vi.mocked(getAPI).mockReturnValue({ - getPreferences: vi.fn().mockImplementation(() => - Promise.resolve({ audio_devices: prefsState.audio_devices }) - ), + getPreferences: vi + .fn() + .mockImplementation(() => Promise.resolve({ audio_devices: prefsState.audio_devices })), listAudioDevices: vi.fn().mockResolvedValue([ { id: 'input:Mic', name: 'Mic', is_input: true }, { id: 'output:Speakers', name: 'Speakers', is_input: false }, diff --git a/client/src/hooks/use-audio-devices.ts b/client/src/hooks/use-audio-devices.ts index c5758d2..90c9b2a 100644 --- a/client/src/hooks/use-audio-devices.ts +++ b/client/src/hooks/use-audio-devices.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState } from 'react'; -import { getAPI } from '@/api'; +import { getAPI } from '@/api/interface'; import { clientLog } from '@/lib/client-log-events'; import { addClientLog } from '@/lib/client-logs'; import { isTauriEnvironment } from '@/api/tauri-adapter'; @@ -18,7 +18,12 @@ import type { export type { AudioDevice, UseAudioDevicesOptions, UseAudioDevicesReturn }; -addClientLog({ level: 'debug', source: 'app', message: 'use-audio-devices.ts MODULE LOADED', metadata: { isTauri: isTauriEnvironment() } }); +addClientLog({ + level: 'debug', + source: 'app', + message: 'use-audio-devices.ts MODULE LOADED', + metadata: { isTauri: isTauriEnvironment() }, +}); const log = debug('useAudioDevices'); @@ -101,21 +106,21 @@ export function useAudioDevices(options: UseAudioDevicesOptions = {}): UseAudioD useEffect(() => { return preferences.subscribe((prefs) => { - const newInputId = prefs.audio_devices.input_device_id; - const newOutputId = prefs.audio_devices.output_device_id; - const newSystemId = prefs.audio_devices.system_device_id ?? ''; - const newDualCapture = prefs.audio_devices.dual_capture_enabled ?? false; - const newMicGain = prefs.audio_devices.mic_gain ?? 1.0; - const newSystemGain = prefs.audio_devices.system_gain ?? 1.0; - setSelectedInputDevice((current) => (current !== newInputId ? newInputId : current)); - setSelectedOutputDevice((current) => (current !== newOutputId ? newOutputId : current)); - setSelectedSystemDevice((current) => (current !== newSystemId ? newSystemId : current)); - setDualCaptureEnabledState((current) => - current !== newDualCapture ? newDualCapture : current - ); - setMicGain((current) => (current !== newMicGain ? newMicGain : current)); - setSystemGain((current) => (current !== newSystemGain ? newSystemGain : current)); - }); + const newInputId = prefs.audio_devices.input_device_id; + const newOutputId = prefs.audio_devices.output_device_id; + const newSystemId = prefs.audio_devices.system_device_id ?? ''; + const newDualCapture = prefs.audio_devices.dual_capture_enabled ?? false; + const newMicGain = prefs.audio_devices.mic_gain ?? 1.0; + const newSystemGain = prefs.audio_devices.system_gain ?? 1.0; + setSelectedInputDevice((current) => (current !== newInputId ? newInputId : current)); + setSelectedOutputDevice((current) => (current !== newOutputId ? newOutputId : current)); + setSelectedSystemDevice((current) => (current !== newSystemId ? newSystemId : current)); + setDualCaptureEnabledState((current) => + current !== newDualCapture ? newDualCapture : current + ); + setMicGain((current) => (current !== newMicGain ? newMicGain : current)); + setSystemGain((current) => (current !== newSystemGain ? newSystemGain : current)); + }); }, []); const selectedInputDeviceRef = useRef(selectedInputDevice); @@ -129,7 +134,12 @@ export function useAudioDevices(options: UseAudioDevicesOptions = {}): UseAudioD const loadDevices = useCallback(async () => { setIsLoading(true); - addClientLog({ level: 'debug', source: 'app', message: 'loadDevices: ENTRY', metadata: { isTauri: isTauriEnvironment() } }); + addClientLog({ + level: 'debug', + source: 'app', + message: 'loadDevices: ENTRY', + metadata: { isTauri: isTauriEnvironment() }, + }); try { if (isTauriEnvironment()) { @@ -224,10 +234,20 @@ export function useAudioDevices(options: UseAudioDevicesOptions = {}): UseAudioD } const inputResult = await resolveStoredDevice( - api, inputs, storedInputId, storedInputName, 'input', setSelectedInputDevice + api, + inputs, + storedInputId, + storedInputName, + 'input', + setSelectedInputDevice ); const outputResult = await resolveStoredDevice( - api, outputs, storedOutputId, storedOutputName, 'output', setSelectedOutputDevice + api, + outputs, + storedOutputId, + storedOutputName, + 'output', + setSelectedOutputDevice ); if (inputs.length > 0 && !inputResult.resolvedId && !inputResult.hadStoredSelection) { @@ -434,7 +454,6 @@ export function useAudioDevices(options: UseAudioDevicesOptions = {}): UseAudioD }, [showToasts] ); - useEffect(() => { if (!autoLoad || !isHydratedState) { return; @@ -445,7 +464,6 @@ export function useAudioDevices(options: UseAudioDevicesOptions = {}): UseAudioD autoLoadRef.current = true; void loadDevices(); }, [autoLoad, isHydratedState, loadDevices]); - useEffect(() => { return () => { void stopInputTest(); diff --git a/client/src/hooks/use-audio-testing.ts b/client/src/hooks/use-audio-testing.ts index 0477921..6a7cfdc 100644 --- a/client/src/hooks/use-audio-testing.ts +++ b/client/src/hooks/use-audio-testing.ts @@ -5,7 +5,7 @@ * Extracted from use-audio-devices.ts to keep files under 500 lines. */ -import { useCallback, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { TauriCommands, Timing } from '@/api/constants'; import { isTauriEnvironment, TauriEvents } from '@/api/tauri-adapter'; import { toast } from '@/hooks/use-toast'; @@ -55,6 +55,9 @@ export function useAudioTesting({ const analyserRef = useRef(null); const mediaStreamRef = useRef(null); const animationFrameRef = useRef(null); + // Ref for output test timeout to ensure cleanup on unmount + const outputTestTimeoutRef = useRef | null>(null); + const isMountedRef = useRef(true); /** * Start testing the selected input device (microphone level visualization) @@ -162,6 +165,12 @@ export function useAudioTesting({ * Test the output device by playing a tone */ const testOutputDevice = useCallback(async () => { + // Clear any existing output test timeout + if (outputTestTimeoutRef.current) { + clearTimeout(outputTestTimeoutRef.current); + outputTestTimeoutRef.current = null; + } + if (isTauriEnvironment()) { try { const core: typeof import('@tauri-apps/api/core') = await import('@tauri-apps/api/core'); @@ -172,8 +181,13 @@ export function useAudioTesting({ if (showToasts) { toast({ title: 'Output test', description: 'Playing test tone' }); } - // Output test auto-stops after 2 seconds - setTimeout(() => setIsTestingOutput(false), Timing.TWO_SECONDS_MS); + // Output test auto-stops after 2 seconds - track timeout for cleanup + outputTestTimeoutRef.current = setTimeout(() => { + if (isMountedRef.current) { + setIsTestingOutput(false); + } + outputTestTimeoutRef.current = null; + }, Timing.TWO_SECONDS_MS); } catch (err) { if (showToasts) { toastError({ @@ -208,9 +222,13 @@ export function useAudioTesting({ toast({ title: 'Output test', description: 'Playing test tone' }); } - setTimeout(() => { - setIsTestingOutput(false); + // Track timeout for cleanup on unmount + outputTestTimeoutRef.current = setTimeout(() => { + if (isMountedRef.current) { + setIsTestingOutput(false); + } audioContext.close(); + outputTestTimeoutRef.current = null; }, 500); } catch { if (showToasts) { @@ -238,6 +256,18 @@ export function useAudioTesting({ [isTestingInput] ); + // Cleanup on unmount - track mount state and clear output timeout + useEffect(() => { + isMountedRef.current = true; + return () => { + isMountedRef.current = false; + if (outputTestTimeoutRef.current) { + clearTimeout(outputTestTimeoutRef.current); + outputTestTimeoutRef.current = null; + } + }; + }, []); + return { isTestingInput, isTestingOutput, diff --git a/client/src/hooks/use-calendar-sync.ts b/client/src/hooks/use-calendar-sync.ts index 6db9df9..4461085 100644 --- a/client/src/hooks/use-calendar-sync.ts +++ b/client/src/hooks/use-calendar-sync.ts @@ -30,6 +30,7 @@ interface UseCalendarSyncReturn { hoursAhead?: number; limit?: number; provider?: string; + background?: boolean; }) => Promise; fetchProviders: () => Promise; refresh: () => Promise; @@ -56,7 +57,9 @@ export function useCalendarSync(options: UseCalendarSyncOptions = {}): UseCalend const [state, setState] = useState(initialState); const [isAutoRefreshing, setIsAutoRefreshing] = useState(false); - const intervalRef = useRef(null); + const intervalRef = useRef | null>(null); + // In-flight guard to prevent overlapping refreshes + const inFlightRef = useRef(false); const optionsRef = useRef({ hoursAhead: defaultHoursAhead, limit: defaultLimit, @@ -87,18 +90,31 @@ export function useCalendarSync(options: UseCalendarSyncOptions = {}): UseCalend }, []); const fetchEvents = useCallback( - async (fetchOptions?: { hoursAhead?: number; limit?: number; provider?: string }) => { + async (fetchOptions?: { + hoursAhead?: number; + limit?: number; + provider?: string; + background?: boolean; + }) => { const { hoursAhead = optionsRef.current.hoursAhead, limit = optionsRef.current.limit, provider = optionsRef.current.provider, + background = false, } = fetchOptions || {}; - setState((prev) => ({ - ...prev, - status: 'loading', - error: null, - })); + if (inFlightRef.current) { + return; + } + inFlightRef.current = true; + + if (!background) { + setState((prev) => ({ + ...prev, + status: 'loading', + error: null, + })); + } try { const api = getAPI(); @@ -109,6 +125,7 @@ export function useCalendarSync(options: UseCalendarSyncOptions = {}): UseCalend status: 'success', events: response.events, lastSync: Date.now(), + error: null, })); } catch (error) { // Check if this is a stale integration (not found on server) @@ -146,6 +163,8 @@ export function useCalendarSync(options: UseCalendarSyncOptions = {}): UseCalend fallback: 'Failed to fetch calendar events', }); } + } finally { + inFlightRef.current = false; } }, [isAutoRefreshing] @@ -169,7 +188,7 @@ export function useCalendarSync(options: UseCalendarSyncOptions = {}): UseCalend setIsAutoRefreshing(true); intervalRef.current = setInterval(() => { // Use ref to get current fetchEvents without stale closure - fetchEventsRef.current(); + fetchEventsRef.current({ background: true }); }, autoRefreshInterval); }, [autoRefreshInterval]); diff --git a/client/src/hooks/use-diarization.test.ts b/client/src/hooks/use-diarization.test.ts index f2ed5e9..de10f33 100644 --- a/client/src/hooks/use-diarization.test.ts +++ b/client/src/hooks/use-diarization.test.ts @@ -12,13 +12,13 @@ import { act, renderHook } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import * as api from '@/api'; +import * as api from '@/api/interface'; import type { DiarizationJobStatus, JobStatus } from '@/api/types'; import { toast } from '@/hooks/use-toast'; import { useDiarization } from './use-diarization'; // Mock the API module -vi.mock('@/api', () => ({ +vi.mock('@/api/interface', () => ({ getAPI: vi.fn(), })); diff --git a/client/src/hooks/use-diarization.ts b/client/src/hooks/use-diarization.ts index 5da9a24..bda9aa0 100644 --- a/client/src/hooks/use-diarization.ts +++ b/client/src/hooks/use-diarization.ts @@ -6,7 +6,7 @@ */ import { useCallback, useEffect, useRef, useState } from 'react'; -import { getAPI } from '@/api'; +import { getAPI } from '@/api/interface'; import type { DiarizationJobStatus, JobStatus } from '@/api/types'; import { toast } from '@/hooks/use-toast'; import { PollingConfig } from '@/lib/config'; @@ -109,9 +109,13 @@ export function useDiarization(options: UseDiarizationOptions = {}): UseDiarizat const isMountedRef = useRef(true); /** Track poll start time for max duration check */ const pollStartTimeRef = useRef(null); + /** Generation token to invalidate in-flight polls on stop/reset */ + const pollGenerationRef = useRef(0); /** Stop polling */ const stopPolling = useCallback(() => { + // Increment generation to invalidate any in-flight polls + pollGenerationRef.current++; if (pollTimeoutRef.current) { clearTimeout(pollTimeoutRef.current); pollTimeoutRef.current = null; @@ -123,8 +127,9 @@ export function useDiarization(options: UseDiarizationOptions = {}): UseDiarizat /** Poll for job status with backoff */ const poll = useCallback( - async (jobId: string) => { - if (!isMountedRef.current) { + async (jobId: string, generation: number) => { + // Check if this poll has been invalidated (generation changed) + if (generation !== pollGenerationRef.current || !isMountedRef.current) { return; } @@ -154,7 +159,8 @@ export function useDiarization(options: UseDiarizationOptions = {}): UseDiarizat const api = getAPI(); const status = await api.getDiarizationJobStatus(jobId); - if (!isMountedRef.current) { + // Re-check generation after async work + if (generation !== pollGenerationRef.current || !isMountedRef.current) { return; } @@ -211,7 +217,7 @@ export function useDiarization(options: UseDiarizationOptions = {}): UseDiarizat if (status.status === 'failed') { stopPolling(); - setState((prev) => ({ ...prev, isActive: false, error: status.error_message })); + setState((prev) => ({ ...prev, isActive: false, error: status.error_message ?? null })); onError?.(status.error_message || 'Diarization failed'); if (showToasts) { toast({ @@ -235,14 +241,18 @@ export function useDiarization(options: UseDiarizationOptions = {}): UseDiarizat return; } - // Continue polling with backoff for running/queued jobs + // Continue polling with backoff for running/queued jobs (pass generation for cancellation) currentPollIntervalRef.current = Math.min( currentPollIntervalRef.current * POLL_BACKOFF_MULTIPLIER, MAX_POLL_INTERVAL_MS ); - pollTimeoutRef.current = setTimeout(() => poll(jobId), currentPollIntervalRef.current); + pollTimeoutRef.current = setTimeout( + () => poll(jobId, generation), + currentPollIntervalRef.current + ); } catch (error) { - if (!isMountedRef.current) { + // Re-check generation after async work + if (generation !== pollGenerationRef.current || !isMountedRef.current) { return; } @@ -253,7 +263,7 @@ export function useDiarization(options: UseDiarizationOptions = {}): UseDiarizat retryCountRef.current += 1; const retryDelay = INITIAL_RETRY_DELAY_MS * RETRY_BACKOFF_MULTIPLIER ** (retryCountRef.current - 1); - pollTimeoutRef.current = setTimeout(() => poll(jobId), retryDelay); + pollTimeoutRef.current = setTimeout(() => poll(jobId, generation), retryDelay); return; } @@ -307,12 +317,13 @@ export function useDiarization(options: UseDiarizationOptions = {}): UseDiarizat if (status.status === 'queued' || status.status === 'running') { // Track poll start time for max duration timeout pollStartTimeRef.current = Date.now(); - pollTimeoutRef.current = setTimeout(() => poll(status.job_id), pollInterval); + const generation = pollGenerationRef.current; + pollTimeoutRef.current = setTimeout(() => poll(status.job_id, generation), pollInterval); } else if (status.status === 'completed') { setState((prev) => ({ ...prev, isActive: false })); onComplete?.(status); } else if (status.status === 'failed') { - setState((prev) => ({ ...prev, isActive: false, error: status.error_message })); + setState((prev) => ({ ...prev, isActive: false, error: status.error_message ?? null })); onError?.(status.error_message || 'Diarization failed'); } } catch (error) { @@ -425,7 +436,8 @@ export function useDiarization(options: UseDiarizationOptions = {}): UseDiarizat // Resume polling if job is still active if (job.status === 'queued' || job.status === 'running') { pollStartTimeRef.current = Date.now(); - pollTimeoutRef.current = setTimeout(() => poll(job.job_id), pollInterval); + const generation = pollGenerationRef.current; + pollTimeoutRef.current = setTimeout(() => poll(job.job_id, generation), pollInterval); if (showToasts) { toast({ diff --git a/client/src/hooks/use-huggingface-token.ts b/client/src/hooks/use-huggingface-token.ts index 12350b6..baf66a8 100644 --- a/client/src/hooks/use-huggingface-token.ts +++ b/client/src/hooks/use-huggingface-token.ts @@ -88,47 +88,44 @@ export function useHuggingFaceToken(): UseHuggingFaceTokenReturn { } }, [mode, refresh]); - const setToken = useCallback( - async (token: string, validate = true): Promise => { - setState((prev) => ({ ...prev, isSaving: true, errorMessage: null })); + const setToken = useCallback(async (token: string, validate = true): Promise => { + setState((prev) => ({ ...prev, isSaving: true, errorMessage: null })); - try { - const api = getAPI(); - const result = await api.setHuggingFaceToken({ token, validate }); + try { + const api = getAPI(); + const result = await api.setHuggingFaceToken({ token, validate }); - if (!result.success) { - setState((prev) => ({ - ...prev, - isSaving: false, - errorMessage: result.validationError || 'Failed to save token', - })); - return false; - } - - // Update local status + if (!result.success) { setState((prev) => ({ ...prev, isSaving: false, - tokenStatus: { - isConfigured: true, - isValidated: validate && result.valid === true, - username: result.username, - validatedAt: validate && result.valid === true ? Date.now() / 1000 : null, - }, - })); - - return true; - } catch (err) { - setState((prev) => ({ - ...prev, - isSaving: false, - errorMessage: extractErrorMessage(err, 'Failed to save HuggingFace token'), + errorMessage: result.validationError || 'Failed to save token', })); return false; } - }, - [] - ); + + // Update local status + setState((prev) => ({ + ...prev, + isSaving: false, + tokenStatus: { + isConfigured: true, + isValidated: validate && result.valid === true, + username: result.username, + validatedAt: validate && result.valid === true ? Date.now() / 1000 : null, + }, + })); + + return true; + } catch (err) { + setState((prev) => ({ + ...prev, + isSaving: false, + errorMessage: extractErrorMessage(err, 'Failed to save HuggingFace token'), + })); + return false; + } + }, []); const deleteToken = useCallback(async (): Promise => { setState((prev) => ({ ...prev, isSaving: true, errorMessage: null })); diff --git a/client/src/hooks/use-integration-sync.ts b/client/src/hooks/use-integration-sync.ts index ae4ffa2..1998d1d 100644 --- a/client/src/hooks/use-integration-sync.ts +++ b/client/src/hooks/use-integration-sync.ts @@ -84,7 +84,6 @@ function sendNotification(type: 'success' | 'error', integrationName: string, me variant: type === 'error' ? 'destructive' : 'default', }); } - } /** Result of a sync operation with optional not-found flag for cache invalidation. */ @@ -147,11 +146,13 @@ export function useIntegrationSync(): UseSyncSchedulerReturn { const [syncStates, setSyncStates] = useState({}); const [isSchedulerRunning, setIsSchedulerRunning] = useState(false); const [isPaused, setIsPaused] = useState(() => preferences.isSyncSchedulerPaused()); - const intervalsRef = useRef>(new Map()); + const intervalsRef = useRef>>(new Map()); const initialTimeoutsRef = useRef>>(new Map()); const integrationsRef = useRef([]); const pausedRef = useRef(isPaused); const mountedRef = useRef(true); + // In-flight guard per integration to prevent overlapping syncs + const inFlightRef = useRef>(new Set()); useEffect(() => { mountedRef.current = true; @@ -169,6 +170,11 @@ export function useIntegrationSync(): UseSyncSchedulerReturn { return; } + // In-flight guard: skip if sync is already running for this integration + if (inFlightRef.current.has(integrationId)) { + return; + } + const integration = integrationsRef.current.find((i) => i.id === integrationId); if (!integration) { return; @@ -180,9 +186,12 @@ export function useIntegrationSync(): UseSyncSchedulerReturn { return; } + // Mark as in-flight + inFlightRef.current.add(integrationId); const startTime = Date.now(); if (!mountedRef.current) { + inFlightRef.current.delete(integrationId); return; } setSyncStates((prev) => ({ @@ -199,6 +208,7 @@ export function useIntegrationSync(): UseSyncSchedulerReturn { const result = await performSync(integration.integration_id); if (!mountedRef.current) { + inFlightRef.current.delete(integrationId); return; } const now = Date.now(); @@ -243,6 +253,7 @@ export function useIntegrationSync(): UseSyncSchedulerReturn { 'This integration was removed because it no longer exists on the server. Please reconnect if needed.', variant: 'destructive', }); + inFlightRef.current.delete(integrationId); return; } @@ -282,7 +293,9 @@ export function useIntegrationSync(): UseSyncSchedulerReturn { } else { sendNotification('error', integration.name, result.error); } + inFlightRef.current.delete(integrationId); } catch (error) { + inFlightRef.current.delete(integrationId); if (!mountedRef.current) { return; } diff --git a/client/src/hooks/use-meeting-reminders.ts b/client/src/hooks/use-meeting-reminders.ts index 993c0a3..19e2266 100644 --- a/client/src/hooks/use-meeting-reminders.ts +++ b/client/src/hooks/use-meeting-reminders.ts @@ -75,7 +75,7 @@ function saveNotifiedReminder(reminderId: string): void { export function useMeetingReminders(events: CalendarEvent[]) { const [permission, setPermission] = useState('default'); const [settings, setSettings] = useState(loadSettings); - const checkIntervalRef = useRef(null); + const checkIntervalRef = useRef | null>(null); // Check current notification permission useEffect(() => { diff --git a/client/src/hooks/use-oauth-flow.ts b/client/src/hooks/use-oauth-flow.ts index 58bebec..7016cee 100644 --- a/client/src/hooks/use-oauth-flow.ts +++ b/client/src/hooks/use-oauth-flow.ts @@ -8,11 +8,7 @@ import { isTauriEnvironment } from '@/api/tauri-adapter'; import type { OAuthConnection } from '@/api/types'; import { toast } from '@/hooks/use-toast'; import { toastError } from '@/lib/error-reporting'; -import { - extractOAuthCallback, - setupDeepLinkListener, - validateOAuthState, -} from '@/lib/oauth-utils'; +import { extractOAuthCallback, setupDeepLinkListener, validateOAuthState } from '@/lib/oauth-utils'; export type OAuthFlowStatus = | 'idle' diff --git a/client/src/hooks/use-post-processing.test.ts b/client/src/hooks/use-post-processing.test.ts index 5a9b4b4..38a0691 100644 --- a/client/src/hooks/use-post-processing.test.ts +++ b/client/src/hooks/use-post-processing.test.ts @@ -35,7 +35,7 @@ const { mockAPI, mockGetAPI } = vi.hoisted(() => { }); // Mock the API module -vi.mock('@/api', () => ({ +vi.mock('@/api/interface', () => ({ getAPI: mockGetAPI, })); diff --git a/client/src/hooks/use-post-processing.ts b/client/src/hooks/use-post-processing.ts index cb7dd7d..305a79e 100644 --- a/client/src/hooks/use-post-processing.ts +++ b/client/src/hooks/use-post-processing.ts @@ -11,7 +11,7 @@ */ import { useCallback, useEffect, useRef, useState } from 'react'; -import { getAPI } from '@/api'; +import { getAPI } from '@/api/interface'; import type { DiarizationJobStatus, MeetingState, ProcessingStatus } from '@/api/types'; import { toast } from '@/hooks/use-toast'; import { setEntitiesFromExtraction } from '@/lib/entity-store'; @@ -63,9 +63,13 @@ export function usePostProcessing(options: UsePostProcessingOptions = {}): UsePo const pollStartTimeRef = useRef(null); const diarizationJobIdRef = useRef(null); const completedMeetingRef = useRef(null); + // Generation token to invalidate in-flight polls on stop/reset + const pollGenerationRef = useRef(0); /** Stop diarization polling */ const stopPolling = useCallback(() => { + // Increment generation to invalidate any in-flight polls + pollGenerationRef.current++; if (pollTimeoutRef.current) { clearTimeout(pollTimeoutRef.current); pollTimeoutRef.current = null; @@ -94,8 +98,9 @@ export function usePostProcessing(options: UsePostProcessingOptions = {}): UsePo /** Poll for diarization job status */ const pollDiarization = useCallback( - async (jobId: string) => { - if (!isMountedRef.current) { + async (jobId: string, generation: number) => { + // Check if this poll has been invalidated (generation changed) + if (generation !== pollGenerationRef.current || !isMountedRef.current) { return; } @@ -119,7 +124,8 @@ export function usePostProcessing(options: UsePostProcessingOptions = {}): UsePo const api = getAPI(); const status: DiarizationJobStatus = await api.getDiarizationJobStatus(jobId); - if (!isMountedRef.current) { + // Re-check generation after async work + if (generation !== pollGenerationRef.current || !isMountedRef.current) { return; } @@ -167,23 +173,27 @@ export function usePostProcessing(options: UsePostProcessingOptions = {}): UsePo return; } - // Continue polling with backoff + // Continue polling with backoff (pass generation for cancellation check) currentPollIntervalRef.current = Math.min( currentPollIntervalRef.current * POLL_BACKOFF_MULTIPLIER, MAX_POLL_INTERVAL_MS ); pollTimeoutRef.current = setTimeout( - () => pollDiarization(jobId), + () => pollDiarization(jobId, generation), currentPollIntervalRef.current ); } catch { + // Re-check generation after async work + if (generation !== pollGenerationRef.current || !isMountedRef.current) { + return; + } // Network error - continue polling currentPollIntervalRef.current = Math.min( currentPollIntervalRef.current * POLL_BACKOFF_MULTIPLIER, MAX_POLL_INTERVAL_MS ); pollTimeoutRef.current = setTimeout( - () => pollDiarization(jobId), + () => pollDiarization(jobId, generation), currentPollIntervalRef.current ); } @@ -317,7 +327,11 @@ export function usePostProcessing(options: UsePostProcessingOptions = {}): UsePo if (response.status === 'queued' || response.status === 'running') { diarizationJobIdRef.current = response.job_id; pollStartTimeRef.current = Date.now(); - pollTimeoutRef.current = setTimeout(() => pollDiarization(response.job_id), pollInterval); + const generation = pollGenerationRef.current; + pollTimeoutRef.current = setTimeout( + () => pollDiarization(response.job_id, generation), + pollInterval + ); return; } diff --git a/client/src/hooks/use-project-members.ts b/client/src/hooks/use-project-members.ts index 7623877..511ed57 100644 --- a/client/src/hooks/use-project-members.ts +++ b/client/src/hooks/use-project-members.ts @@ -1,7 +1,7 @@ // Hook for loading project members -import { useCallback, useEffect, useState } from 'react'; -import { getAPI } from '@/api'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { getAPI } from '@/api/interface'; import { extractErrorMessage } from '@/api/helpers'; import type { ProjectMembership } from '@/api/types'; @@ -10,19 +10,37 @@ export function useProjectMembers(projectId?: string) { const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); + // Stale-request protection: track current request ID and mount state + const requestIdRef = useRef(0); + const isMountedRef = useRef(true); + const loadMembers = useCallback(async () => { if (!projectId) { return; } + + // Increment request ID to invalidate any in-flight requests + const thisRequestId = ++requestIdRef.current; + setIsLoading(true); setError(null); try { const response = await getAPI().listProjectMembers({ project_id: projectId, limit: 100 }); - setMembers(response.members); + + // Only update state if this is still the current request and component is mounted + if (thisRequestId === requestIdRef.current && isMountedRef.current) { + setMembers(response.members); + } } catch (err) { - setError(extractErrorMessage(err, 'Failed to load project members')); + // Only update state if this is still the current request and component is mounted + if (thisRequestId === requestIdRef.current && isMountedRef.current) { + setError(extractErrorMessage(err, 'Failed to load project members')); + } } finally { - setIsLoading(false); + // Only update loading state if this is still the current request and component is mounted + if (thisRequestId === requestIdRef.current && isMountedRef.current) { + setIsLoading(false); + } } }, [projectId]); @@ -30,5 +48,13 @@ export function useProjectMembers(projectId?: string) { void loadMembers(); }, [loadMembers]); + // Mount/unmount tracking + useEffect(() => { + isMountedRef.current = true; + return () => { + isMountedRef.current = false; + }; + }, []); + return { members, isLoading, error, refresh: loadMembers }; } diff --git a/client/src/hooks/use-recording-app-policy.ts b/client/src/hooks/use-recording-app-policy.ts index 3c2799c..b99a408 100644 --- a/client/src/hooks/use-recording-app-policy.ts +++ b/client/src/hooks/use-recording-app-policy.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { getAPI } from '@/api'; +import { getAPI } from '@/api/interface'; import { isTauriEnvironment } from '@/api/tauri-adapter'; import type { AppMatcher, @@ -231,9 +231,7 @@ export function useRecordingAppPolicy(): RecordingAppPolicyState { // Append new apps, avoiding duplicates setInstalledApps((prev) => { const existingIds = new Set(prev.map((app) => canonicalIdForApp(app))); - const newApps = response.apps.filter( - (app) => !existingIds.has(canonicalIdForApp(app)) - ); + const newApps = response.apps.filter((app) => !existingIds.has(canonicalIdForApp(app))); return [...prev, ...newApps]; }); currentPageRef.current += 1; diff --git a/client/src/hooks/use-recording-session.ts b/client/src/hooks/use-recording-session.ts index 0938ada..9df4c7e 100644 --- a/client/src/hooks/use-recording-session.ts +++ b/client/src/hooks/use-recording-session.ts @@ -4,13 +4,9 @@ import { useCallback, useEffect, useRef, useState } from 'react'; -import { - getAPI, - isTauriEnvironment, - mockAPI, - type NoteFlowAPI, - type TranscriptionStream, -} from '@/api'; +import { isTauriEnvironment, mockAPI } from '@/api'; +import { getAPI } from '@/api/interface'; +import type { NoteFlowAPI, TranscriptionStream } from '@/api'; import { TauriEvents } from '@/api/tauri-adapter'; import type { FinalSegment, Meeting, TranscriptUpdate } from '@/api/types'; import { useConnectionState } from '@/contexts/connection-state'; @@ -224,7 +220,10 @@ export function useRecordingSession( const streamState = await getAPI().getStreamState(); if (streamState.state === 'starting' && (streamState.started_at_secs_ago ?? 0) > 10) { await getAPI().resetStreamState(); - toast({ title: 'Stream recovered', description: 'A stuck stream was automatically reset.' }); + toast({ + title: 'Stream recovered', + description: 'A stuck stream was automatically reset.', + }); } } catch (error) { addClientLog({ diff --git a/client/src/hooks/use-secure-integration-secrets.ts b/client/src/hooks/use-secure-integration-secrets.ts index 757d15b..a18ff10 100644 --- a/client/src/hooks/use-secure-integration-secrets.ts +++ b/client/src/hooks/use-secure-integration-secrets.ts @@ -56,7 +56,11 @@ export function useSecureIntegrationSecrets() { source: 'app', message: `Secret retrieval failed for integration field`, details: error instanceof Error ? error.message : String(error), - metadata: { context: 'secure_integration_secrets', integration_id: integration.id, field }, + metadata: { + context: 'secure_integration_secrets', + integration_id: integration.id, + field, + }, }); } } @@ -81,8 +85,17 @@ export function useSecureIntegrationSecrets() { for (const field of fields) { const value = getNestedValue(integration, field); const secretKey = getSecretKey(integration.id, field); + + // Enforce string type - only store non-empty strings, delete otherwise + const stringValue = typeof value === 'string' ? value : ''; + try { - await setSecureValue(secretKey, value); + if (stringValue) { + await setSecureValue(secretKey, stringValue); + } else { + // Clear the secret if value is empty/undefined/non-string + await setSecureValue(secretKey, ''); + } } catch (error) { // Secret storage failed - integration may work without persisted secret addClientLog({ @@ -90,7 +103,11 @@ export function useSecureIntegrationSecrets() { source: 'app', message: `Secret storage failed for integration field`, details: error instanceof Error ? error.message : String(error), - metadata: { context: 'secure_integration_secrets', integration_id: integration.id, field }, + metadata: { + context: 'secure_integration_secrets', + integration_id: integration.id, + field, + }, }); } } @@ -117,7 +134,11 @@ export function useSecureIntegrationSecrets() { source: 'app', message: `Secret clearing failed for integration field`, details: error instanceof Error ? error.message : String(error), - metadata: { context: 'secure_integration_secrets', integration_id: integration.id, field }, + metadata: { + context: 'secure_integration_secrets', + integration_id: integration.id, + field, + }, }); } } @@ -138,28 +159,36 @@ export function useSecureIntegrationSecrets() { /** * Check secure storage health and attempt migration if needed. * - * @returns Object with health status and whether migration was attempted/successful + * Migration state semantics: + * - 'not_needed': Storage was already healthy, no migration required + * - 'succeeded': Migration was performed and succeeded + * - 'failed': Migration was attempted but failed + * + * @returns Object with health status and migration result */ const checkHealthAndMigrate = useCallback(async (): Promise<{ status: SecureStorageStatus; - migrationAttempted: boolean; - migrationSucceeded: boolean; + migrationState: 'not_needed' | 'succeeded' | 'failed'; }> => { if (!available) { - return { status: 'unavailable', migrationAttempted: false, migrationSucceeded: false }; + return { status: 'unavailable', migrationState: 'not_needed' }; } - // First, attempt migration (only runs if needed) - const migrationSucceeded = await migrateSecureStorage(); - const migrationAttempted = !migrationSucceeded; // If it failed, migration was actually attempted + // Check pre-migration health to determine if migration is needed + const preStatus = await checkSecureStorageHealth(); + if (preStatus === 'healthy') { + return { status: 'healthy', migrationState: 'not_needed' }; + } - // Then check current health - const status = await checkSecureStorageHealth(); + // Attempt migration since storage is not healthy + const migrationSucceeded = await migrateSecureStorage(); + + // Check post-migration health + const postStatus = await checkSecureStorageHealth(); return { - status, - migrationAttempted: migrationAttempted || status === 'healthy', // If healthy after migration, it was migrated - migrationSucceeded, + status: postStatus, + migrationState: migrationSucceeded ? 'succeeded' : 'failed', }; }, [available]); diff --git a/client/src/hooks/use-streaming-config.ts b/client/src/hooks/use-streaming-config.ts index 0f6829f..b55e2e8 100644 --- a/client/src/hooks/use-streaming-config.ts +++ b/client/src/hooks/use-streaming-config.ts @@ -7,10 +7,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { getAPI } from '@/api/interface'; import { extractErrorMessage } from '@/api/helpers'; -import type { - StreamingConfiguration, - UpdateStreamingConfigurationRequest, -} from '@/api/types'; +import type { StreamingConfiguration, UpdateStreamingConfigurationRequest } from '@/api/types'; import { useConnectionState } from '@/contexts/connection-state'; type LoadState = 'idle' | 'loading' | 'ready' | 'failed'; diff --git a/client/src/lib/ai-providers/fetch-models.ts b/client/src/lib/ai-providers/fetch-models.ts index 78e4f94..061df98 100644 --- a/client/src/lib/ai-providers/fetch-models.ts +++ b/client/src/lib/ai-providers/fetch-models.ts @@ -1,28 +1,19 @@ /** * Model fetching functions for AI providers. + * + * Uses the strategy pattern to delegate to provider-specific implementations. */ -import { extractErrorMessage, getErrorMessage, isRecord } from '@/api/helpers'; -import type { AIProviderType, ModelCatalogEntry, TranscriptionProviderType } from '@/api/types'; +import type { AIProviderType, TranscriptionProviderType } from '@/api/types'; -import { - ANTHROPIC_API_VERSION, - AZURE_OPENAI_API_VERSION, - errorResult, - successResult, -} from './constants'; +import { errorResult } from './constants'; import { getCachedModelCatalog, isModelCatalogFresh, setCachedModelCatalog, } from './model-catalog-cache'; -import { - dedupeAndSortModels, - extractModelEntries, - filterGoogleModel, - filterOpenAIModel, - type ModelCatalogType, -} from './model-catalog-utils'; +import { dedupeAndSortModels, type ModelCatalogType } from './model-catalog-utils'; +import { getStrategy, requiresApiKey as strategyRequiresApiKey } from './strategies'; import type { FetchModelsResult } from './types'; type ConfigType = ModelCatalogType; @@ -31,277 +22,23 @@ type FetchModelsOptions = { forceRefresh?: boolean; }; -const AZURE_SPEECH_API_VERSIONS = ['v3.2', 'v3.1']; - -/** Fetch models from OpenAI-compatible API. */ -async function fetchOpenAIModels( - baseUrl: string, - apiKey: string, - type: ConfigType -): Promise { - try { - const response = await fetch(`${baseUrl}/models`, { - headers: { - Authorization: `Bearer ${apiKey}`, - }, - }); - - if (!response.ok) { - const errorPayload: unknown = await response.json().catch(() => null); - return errorResult(getErrorMessage(errorPayload) || `HTTP ${response.status}`); - } - - const data: unknown = await response.json(); - const items = isRecord(data) && Array.isArray(data.data) ? data.data : []; - const models = extractModelEntries(items, ['id']) - .filter((model) => filterOpenAIModel(model.id, type)); - - return successResult(dedupeAndSortModels(models)); - } catch (error: unknown) { - return errorResult(extractErrorMessage(error, 'Failed to fetch models')); - } -} - -/** Fetch Anthropic models. */ -async function fetchAnthropicModels(baseUrl: string, apiKey: string): Promise { - try { - const response = await fetch(`${baseUrl}/models`, { - headers: { - 'x-api-key': apiKey, - 'anthropic-version': ANTHROPIC_API_VERSION, - }, - }); - - if (!response.ok) { - return errorResult(`HTTP ${response.status}`); - } - - const data: unknown = await response.json(); - const items = isRecord(data) && Array.isArray(data.data) ? data.data : []; - const models = extractModelEntries(items, ['id', 'name']); - return successResult(dedupeAndSortModels(models)); - } catch (error: unknown) { - return errorResult(extractErrorMessage(error, 'Failed to fetch models')); - } -} - -/** Fetch models from Google AI API. */ -async function fetchGoogleModels( - baseUrl: string, - apiKey: string, - type: ConfigType -): Promise { - try { - const response = await fetch(`${baseUrl}/models?key=${apiKey}`); - if (!response.ok) { - return errorResult(`HTTP ${response.status}`); - } - const data: unknown = await response.json(); - const items = isRecord(data) && Array.isArray(data.models) ? data.models : []; - const models = extractModelEntries(items, ['name'], (name) => name.replace(/^models\//, '')) - .filter((model) => filterGoogleModel(model.id, type)); - return successResult(dedupeAndSortModels(models)); - } catch (error: unknown) { - return errorResult(extractErrorMessage(error, 'Failed to fetch models')); - } -} - -/** Fetch models from local Ollama instance. */ -async function fetchOllamaModels(baseUrl: string): Promise { - try { - const response = await fetch(`${baseUrl}/tags`); - if (!response.ok) { - return errorResult(`HTTP ${response.status}`); - } - const data: unknown = await response.json(); - const items = isRecord(data) && Array.isArray(data.models) ? data.models : []; - const models = extractModelEntries(items, ['name']); - return successResult(dedupeAndSortModels(models)); - } catch (error: unknown) { - return errorResult(extractErrorMessage(error, 'Could not connect to Ollama')); - } -} - -function extractDeepgramModelEntries(data: unknown): ModelCatalogEntry[] { - if (!isRecord(data)) { - return []; - } - const directArray = Array.isArray(data.models) ? data.models : []; - if (directArray.length > 0) { - return extractModelEntries(directArray, ['name', 'id', 'model_id']); - } - const sttModels = Array.isArray(data.stt) ? data.stt : []; - if (sttModels.length > 0) { - return extractModelEntries(sttModels, ['name', 'id', 'model_id']); - } - if (isRecord(data.models) && Array.isArray(data.models.stt)) { - return extractModelEntries(data.models.stt, ['name', 'id', 'model_id']); - } - return []; -} - -/** Fetch Deepgram models. */ -async function fetchDeepgramModels(baseUrl: string, apiKey: string): Promise { - const base = baseUrl.replace(/\/v1\/?$/, ''); - try { - const projectsResponse = await fetch(`${base}/v1/projects`, { - headers: { Authorization: `Token ${apiKey}` }, - }); - if (projectsResponse.ok) { - const projectsData: unknown = await projectsResponse.json(); - const projects = isRecord(projectsData) && Array.isArray(projectsData.projects) - ? projectsData.projects - : []; - const projectId = projects - .map((project) => (isRecord(project) ? project.project_id ?? project.id : null)) - .find((id): id is string => typeof id === 'string' && id.length > 0); - if (projectId) { - const modelsResponse = await fetch(`${base}/v1/projects/${projectId}/models`, { - headers: { Authorization: `Token ${apiKey}` }, - }); - if (modelsResponse.ok) { - const modelsData: unknown = await modelsResponse.json(); - const models = extractDeepgramModelEntries(modelsData); - return successResult(dedupeAndSortModels(models)); - } - } - } - } catch { - // Ignore and fall back to public models endpoint. - } - - try { - const response = await fetch(`${base}/v1/models`, { - headers: { Authorization: `Token ${apiKey}` }, - }); - if (!response.ok) { - return errorResult(`HTTP ${response.status}`); - } - const data: unknown = await response.json(); - const models = extractDeepgramModelEntries(data); - return successResult(dedupeAndSortModels(models)); - } catch (error: unknown) { - return errorResult(extractErrorMessage(error, 'Failed to fetch Deepgram models')); - } -} - -/** Fetch models from ElevenLabs API. */ -async function fetchElevenLabsModels( - baseUrl: string, - apiKey: string -): Promise { - try { - const response = await fetch(`${baseUrl}/models`, { headers: { 'xi-api-key': apiKey } }); - if (!response.ok) { - return errorResult(`HTTP ${response.status}`); - } - const data: unknown = await response.json(); - const items = Array.isArray(data) ? data : []; - const models: ModelCatalogEntry[] = []; - for (const item of items) { - if (!isRecord(item) || item.can_do_text_to_speech === false) { - continue; - } - models.push(...extractModelEntries([item], ['model_id', 'id', 'name'])); - } - return successResult(dedupeAndSortModels(models)); - } catch (error: unknown) { - return errorResult(extractErrorMessage(error, 'Failed to fetch models')); - } -} - -/** Fetch Azure OpenAI deployment list. */ -async function fetchAzureOpenAIModels( - baseUrl: string, - apiKey: string -): Promise { - try { - const response = await fetch( - `${baseUrl.replace(/\/+$/, '')}/openai/deployments?api-version=${AZURE_OPENAI_API_VERSION}`, - { - headers: { 'api-key': apiKey }, - } - ); - if (!response.ok) { - return errorResult(`HTTP ${response.status}`); - } - const data: unknown = await response.json(); - const items = isRecord(data) && Array.isArray(data.data) - ? data.data - : isRecord(data) && Array.isArray(data.value) - ? data.value - : []; - const models = extractModelEntries(items, ['id', 'name', 'deployment_name']); - return successResult(dedupeAndSortModels(models)); - } catch (error: unknown) { - return errorResult(extractErrorMessage(error, 'Failed to fetch deployments')); - } -} - -/** Fetch Azure Speech-to-text models. */ -async function fetchAzureSpeechModels( - baseUrl: string, - apiKey: string -): Promise { - const trimmed = baseUrl.replace(/\/+$/, ''); - for (const version of AZURE_SPEECH_API_VERSIONS) { - try { - const response = await fetch(`${trimmed}/speechtotext/${version}/models/base`, { - headers: { 'Ocp-Apim-Subscription-Key': apiKey }, - }); - if (!response.ok) { - continue; - } - const data: unknown = await response.json(); - const items = isRecord(data) && Array.isArray(data.values) - ? data.values - : isRecord(data) && Array.isArray(data.models) - ? data.models - : []; - const models = extractModelEntries(items, ['shortName', 'name', 'id']); - return successResult(dedupeAndSortModels(models)); - } catch { - // Try the next API version. - } - } - return errorResult('Azure Speech endpoint not reachable'); -} - +/** + * Fetch models from the provider using its strategy. + */ async function fetchModelsFromProvider( provider: AIProviderType | TranscriptionProviderType, baseUrl: string, apiKey: string, type: ConfigType ): Promise { - switch (provider) { - case 'openai': - case 'whisper': - case 'custom': - return fetchOpenAIModels(baseUrl, apiKey, type); + const isTranscription = type === 'transcription'; + const strategy = getStrategy(provider, isTranscription); - case 'anthropic': - return fetchAnthropicModels(baseUrl, apiKey); - - case 'google': - return fetchGoogleModels(baseUrl, apiKey, type); - - case 'ollama': - return fetchOllamaModels(baseUrl); - - case 'azure': - return type === 'transcription' - ? fetchAzureSpeechModels(baseUrl, apiKey) - : fetchAzureOpenAIModels(baseUrl, apiKey); - - case 'deepgram': - return fetchDeepgramModels(baseUrl, apiKey); - - case 'elevenlabs': - return fetchElevenLabsModels(baseUrl, apiKey); - - default: - return errorResult('Unknown provider'); + if (!strategy) { + return errorResult('Unknown provider'); } + + return strategy.fetchModels(baseUrl, apiKey, type); } /** Fetch available models from the specified AI provider (with caching). */ @@ -316,8 +53,9 @@ export async function fetchModels( if (!normalizedBaseUrl) { return errorResult('Base URL is required'); } - const requiresApiKey = provider !== 'ollama' && provider !== 'custom'; - if (requiresApiKey && !apiKey) { + + const needsApiKey = strategyRequiresApiKey(provider); + if (needsApiKey && !apiKey) { return errorResult('API key is required'); } diff --git a/client/src/lib/ai-providers/model-catalog-utils.ts b/client/src/lib/ai-providers/model-catalog-utils.ts index c128128..b82417a 100644 --- a/client/src/lib/ai-providers/model-catalog-utils.ts +++ b/client/src/lib/ai-providers/model-catalog-utils.ts @@ -70,7 +70,7 @@ function extractCostLabel(record: Record): string | undefined { } if (isRecord(record.pricing)) { - const {pricing} = record; + const { pricing } = record; const input = getStringOrNumber(pricing, ['prompt', 'input', 'input_cost', 'prompt_cost']); const output = getStringOrNumber(pricing, [ 'completion', diff --git a/client/src/lib/ai-providers/strategies/anthropic.ts b/client/src/lib/ai-providers/strategies/anthropic.ts new file mode 100644 index 0000000..b106ef1 --- /dev/null +++ b/client/src/lib/ai-providers/strategies/anthropic.ts @@ -0,0 +1,83 @@ +/** + * Anthropic Provider Strategy + * + * Handles model fetching and endpoint testing for the Anthropic Claude API. + */ + +import { HttpStatus } from '@/api/constants'; +import { extractErrorMessage, isRecord } from '@/api/helpers'; + +import { ANTHROPIC_API_VERSION, errorResult, successResult } from '../constants'; +import { dedupeAndSortModels, extractModelEntries } from '../model-catalog-utils'; +import type { FetchModelsResult, TestEndpointResult } from '../types'; + +import { BaseProviderStrategy, type EndpointTestType, type ModelCatalogType } from './types'; + +/** + * Anthropic provider strategy implementation. + */ +export class AnthropicStrategy extends BaseProviderStrategy { + readonly providerId = 'anthropic'; + readonly displayName = 'Anthropic'; + readonly requiresApiKey = true; + + async fetchModels( + baseUrl: string, + apiKey: string, + _type: ModelCatalogType + ): Promise { + try { + const response = await fetch(`${baseUrl}/models`, { + headers: { + 'x-api-key': apiKey, + 'anthropic-version': ANTHROPIC_API_VERSION, + }, + }); + + if (!response.ok) { + return errorResult(`HTTP ${response.status}`); + } + + const data: unknown = await response.json(); + const items = isRecord(data) && Array.isArray(data.data) ? data.data : []; + const models = extractModelEntries(items, ['id', 'name']); + + return successResult(dedupeAndSortModels(models)); + } catch (error: unknown) { + return errorResult(extractErrorMessage(error, 'Failed to fetch models')); + } + } + + async testEndpoint( + baseUrl: string, + apiKey: string, + model: string, + _type: EndpointTestType, + startTime: number + ): Promise { + const response = await fetch(`${baseUrl}/messages`, { + method: 'POST', + headers: { + 'x-api-key': apiKey, + 'anthropic-version': ANTHROPIC_API_VERSION, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model: model, + max_tokens: 10, + messages: [{ role: 'user', content: 'Say "ok"' }], + }), + }); + + // Rate limited but key is valid + if (response.ok || response.status === HttpStatus.TOO_MANY_REQUESTS) { + const message = + response.status === HttpStatus.TOO_MANY_REQUESTS + ? 'API key valid (rate limited)' + : 'Connection successful'; + return this.successTestResult(message, startTime); + } + + return this.failTestResult(`HTTP ${response.status}`); + } +} diff --git a/client/src/lib/ai-providers/strategies/azure.ts b/client/src/lib/ai-providers/strategies/azure.ts new file mode 100644 index 0000000..b40ce6c --- /dev/null +++ b/client/src/lib/ai-providers/strategies/azure.ts @@ -0,0 +1,141 @@ +/** + * Azure Provider Strategies + * + * Handles model fetching and endpoint testing for Azure OpenAI and Azure Speech services. + */ + +import { extractErrorMessage, isRecord } from '@/api/helpers'; + +import { AZURE_OPENAI_API_VERSION, errorResult, successResult } from '../constants'; +import { dedupeAndSortModels, extractModelEntries } from '../model-catalog-utils'; +import type { FetchModelsResult, TestEndpointResult } from '../types'; + +import { BaseProviderStrategy, type EndpointTestType, type ModelCatalogType } from './types'; + +/** Azure Speech API versions to try (in order). */ +const AZURE_SPEECH_API_VERSIONS = ['v3.2', 'v3.1']; + +/** + * Azure OpenAI provider strategy implementation. + * Handles Azure-hosted OpenAI deployments. + */ +export class AzureOpenAIStrategy extends BaseProviderStrategy { + readonly providerId = 'azure'; + readonly displayName = 'Azure OpenAI'; + readonly requiresApiKey = true; + + async fetchModels( + baseUrl: string, + apiKey: string, + _type: ModelCatalogType + ): Promise { + try { + const response = await fetch( + `${baseUrl.replace(/\/+$/, '')}/openai/deployments?api-version=${AZURE_OPENAI_API_VERSION}`, + { + headers: { 'api-key': apiKey }, + } + ); + + if (!response.ok) { + return errorResult(`HTTP ${response.status}`); + } + + const data: unknown = await response.json(); + const items = + isRecord(data) && Array.isArray(data.data) + ? data.data + : isRecord(data) && Array.isArray(data.value) + ? data.value + : []; + const models = extractModelEntries(items, ['id', 'name', 'deployment_name']); + + return successResult(dedupeAndSortModels(models)); + } catch (error: unknown) { + return errorResult(extractErrorMessage(error, 'Failed to fetch deployments')); + } + } + + async testEndpoint( + baseUrl: string, + apiKey: string, + _model: string, + _type: EndpointTestType, + startTime: number + ): Promise { + // Azure OpenAI uses custom endpoint; test by fetching models + const response = await fetch(`${baseUrl.replace(/\/+$/, '')}/models`, { + headers: { + Authorization: `Bearer ${apiKey}`, + }, + }); + + if (response.ok) { + return this.successTestResult('Endpoint is responding', startTime); + } + + return this.failTestResult(`HTTP ${response.status}`); + } +} + +/** + * Azure Speech provider strategy implementation. + * Handles Azure Cognitive Services Speech-to-Text. + */ +export class AzureSpeechStrategy extends BaseProviderStrategy { + readonly providerId = 'azure-speech'; + readonly displayName = 'Azure Speech'; + readonly requiresApiKey = true; + + async fetchModels( + baseUrl: string, + apiKey: string, + _type: ModelCatalogType + ): Promise { + const trimmed = baseUrl.replace(/\/+$/, ''); + + for (const version of AZURE_SPEECH_API_VERSIONS) { + try { + const response = await fetch(`${trimmed}/speechtotext/${version}/models/base`, { + headers: { 'Ocp-Apim-Subscription-Key': apiKey }, + }); + + if (!response.ok) { + continue; + } + + const data: unknown = await response.json(); + const items = + isRecord(data) && Array.isArray(data.values) + ? data.values + : isRecord(data) && Array.isArray(data.models) + ? data.models + : []; + const models = extractModelEntries(items, ['shortName', 'name', 'id']); + + return successResult(dedupeAndSortModels(models)); + } catch { + // Try the next API version. + } + } + + return errorResult('Azure Speech endpoint not reachable'); + } + + async testEndpoint( + baseUrl: string, + apiKey: string, + _model: string, + _type: EndpointTestType, + startTime: number + ): Promise { + // Test by attempting to fetch models + const result = await this.fetchModels(baseUrl, apiKey, 'transcription'); + + if (result.success) { + return this.successTestResult('Azure Speech endpoint is working', startTime); + } + + return this.failTestResult(result.error ?? 'Azure Speech endpoint not reachable'); + } +} diff --git a/client/src/lib/ai-providers/strategies/custom.ts b/client/src/lib/ai-providers/strategies/custom.ts new file mode 100644 index 0000000..d6704e7 --- /dev/null +++ b/client/src/lib/ai-providers/strategies/custom.ts @@ -0,0 +1,124 @@ +/** + * Custom Provider Strategy + * + * Handles model fetching and endpoint testing for custom/unknown OpenAI-compatible APIs. + */ + +import { extractErrorMessage, getErrorMessage, isRecord } from '@/api/helpers'; + +import { errorResult, successResult } from '../constants'; +import { + dedupeAndSortModels, + extractModelEntries, + filterOpenAIModel, +} from '../model-catalog-utils'; +import type { FetchModelsResult, TestEndpointResult } from '../types'; + +import { BaseProviderStrategy, type EndpointTestType, type ModelCatalogType } from './types'; + +/** + * Custom/OpenAI-compatible provider strategy implementation. + * Assumes OpenAI-compatible API format. + */ +export class CustomStrategy extends BaseProviderStrategy { + readonly providerId = 'custom'; + readonly displayName = 'Custom'; + readonly requiresApiKey = false; + + async fetchModels( + baseUrl: string, + apiKey: string, + type: ModelCatalogType + ): Promise { + try { + const response = await fetch(`${baseUrl}/models`, { + headers: apiKey + ? { + Authorization: `Bearer ${apiKey}`, + } + : {}, + }); + + if (!response.ok) { + const errorPayload: unknown = await response.json().catch(() => null); + return errorResult(getErrorMessage(errorPayload) || `HTTP ${response.status}`); + } + + const data: unknown = await response.json(); + const items = isRecord(data) && Array.isArray(data.data) ? data.data : []; + const models = extractModelEntries(items, ['id']).filter((model) => + filterOpenAIModel(model.id, type) + ); + + return successResult(dedupeAndSortModels(models)); + } catch (error: unknown) { + return errorResult(extractErrorMessage(error, 'Failed to fetch models')); + } + } + + async testEndpoint( + baseUrl: string, + apiKey: string, + model: string, + type: EndpointTestType, + startTime: number + ): Promise { + if (type === 'transcription') { + return this.testCustomTranscription(baseUrl, apiKey, startTime); + } + + // For summary/embedding, use OpenAI-compatible chat completions + return this.testChatEndpoint(baseUrl, apiKey, model, startTime); + } + + private async testCustomTranscription( + baseUrl: string, + apiKey: string, + startTime: number + ): Promise { + const response = await fetch(`${baseUrl}/models`, { + headers: apiKey + ? { + Authorization: `Bearer ${apiKey}`, + } + : {}, + }); + + if (response.ok) { + return this.successTestResult('Endpoint is responding', startTime); + } + + return this.failTestResult(`HTTP ${response.status}`); + } + + private async testChatEndpoint( + baseUrl: string, + apiKey: string, + model: string, + startTime: number + ): Promise { + const response = await fetch(`${baseUrl}/chat/completions`, { + method: 'POST', + headers: apiKey + ? { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + } + : { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model: model, + messages: [{ role: 'user', content: 'Say "test successful" in exactly 2 words.' }], + max_tokens: 10, + }), + }); + + if (!response.ok) { + const errorPayload: unknown = await response.json().catch(() => null); + return this.failTestResult(getErrorMessage(errorPayload) || `HTTP ${response.status}`); + } + + return this.successTestResult('Chat completion endpoint is working', startTime); + } +} diff --git a/client/src/lib/ai-providers/strategies/deepgram.ts b/client/src/lib/ai-providers/strategies/deepgram.ts new file mode 100644 index 0000000..2ac0e12 --- /dev/null +++ b/client/src/lib/ai-providers/strategies/deepgram.ts @@ -0,0 +1,129 @@ +/** + * Deepgram Provider Strategy + * + * Handles model fetching and endpoint testing for Deepgram speech-to-text API. + */ + +import { extractErrorMessage, isRecord } from '@/api/helpers'; + +import { errorResult, successResult } from '../constants'; +import { dedupeAndSortModels, extractModelEntries } from '../model-catalog-utils'; +import type { FetchModelsResult, TestEndpointResult } from '../types'; + +import { BaseProviderStrategy, type EndpointTestType, type ModelCatalogType } from './types'; + +/** + * Extract model entries from various Deepgram API response formats. + */ +function extractDeepgramModelEntries(data: unknown): ReturnType { + if (!isRecord(data)) { + return []; + } + + // Try direct models array + const directArray = Array.isArray(data.models) ? data.models : []; + if (directArray.length > 0) { + return extractModelEntries(directArray, ['name', 'id', 'model_id']); + } + + // Try stt array + const sttModels = Array.isArray(data.stt) ? data.stt : []; + if (sttModels.length > 0) { + return extractModelEntries(sttModels, ['name', 'id', 'model_id']); + } + + // Try nested models.stt + if (isRecord(data.models) && Array.isArray(data.models.stt)) { + return extractModelEntries(data.models.stt, ['name', 'id', 'model_id']); + } + + return []; +} + +/** + * Deepgram provider strategy implementation. + */ +export class DeepgramStrategy extends BaseProviderStrategy { + readonly providerId = 'deepgram'; + readonly displayName = 'Deepgram'; + readonly requiresApiKey = true; + + async fetchModels( + baseUrl: string, + apiKey: string, + _type: ModelCatalogType + ): Promise { + const base = baseUrl.replace(/\/v1\/?$/, ''); + + // Try project-specific models first + try { + const projectsResponse = await fetch(`${base}/v1/projects`, { + headers: { Authorization: `Token ${apiKey}` }, + }); + + if (projectsResponse.ok) { + const projectsData: unknown = await projectsResponse.json(); + const projects = + isRecord(projectsData) && Array.isArray(projectsData.projects) + ? projectsData.projects + : []; + + const projectId = projects + .map((project) => (isRecord(project) ? (project.project_id ?? project.id) : null)) + .find((id): id is string => typeof id === 'string' && id.length > 0); + + if (projectId) { + const modelsResponse = await fetch(`${base}/v1/projects/${projectId}/models`, { + headers: { Authorization: `Token ${apiKey}` }, + }); + + if (modelsResponse.ok) { + const modelsData: unknown = await modelsResponse.json(); + const models = extractDeepgramModelEntries(modelsData); + return successResult(dedupeAndSortModels(models)); + } + } + } + } catch { + // Ignore and fall back to public models endpoint. + } + + // Fall back to public models endpoint + try { + const response = await fetch(`${base}/v1/models`, { + headers: { Authorization: `Token ${apiKey}` }, + }); + + if (!response.ok) { + return errorResult(`HTTP ${response.status}`); + } + + const data: unknown = await response.json(); + const models = extractDeepgramModelEntries(data); + + return successResult(dedupeAndSortModels(models)); + } catch (error: unknown) { + return errorResult(extractErrorMessage(error, 'Failed to fetch Deepgram models')); + } + } + + async testEndpoint( + baseUrl: string, + apiKey: string, + _model: string, + _type: EndpointTestType, + startTime: number + ): Promise { + const response = await fetch(`${baseUrl.replace('/v1', '')}/v1/projects`, { + headers: { + Authorization: `Token ${apiKey}`, + }, + }); + + if (response.ok) { + return this.successTestResult('Deepgram API key is valid', startTime); + } + + return this.failTestResult('Invalid API key or endpoint'); + } +} diff --git a/client/src/lib/ai-providers/strategies/elevenlabs.ts b/client/src/lib/ai-providers/strategies/elevenlabs.ts new file mode 100644 index 0000000..6838e21 --- /dev/null +++ b/client/src/lib/ai-providers/strategies/elevenlabs.ts @@ -0,0 +1,74 @@ +/** + * ElevenLabs Provider Strategy + * + * Handles model fetching and endpoint testing for ElevenLabs text-to-speech API. + */ + +import { extractErrorMessage, isRecord } from '@/api/helpers'; + +import { errorResult, successResult } from '../constants'; +import { dedupeAndSortModels, extractModelEntries } from '../model-catalog-utils'; +import type { FetchModelsResult, TestEndpointResult } from '../types'; + +import { BaseProviderStrategy, type EndpointTestType, type ModelCatalogType } from './types'; + +/** + * ElevenLabs provider strategy implementation. + */ +export class ElevenLabsStrategy extends BaseProviderStrategy { + readonly providerId = 'elevenlabs'; + readonly displayName = 'ElevenLabs'; + readonly requiresApiKey = true; + + async fetchModels( + baseUrl: string, + apiKey: string, + _type: ModelCatalogType + ): Promise { + try { + const response = await fetch(`${baseUrl}/models`, { + headers: { 'xi-api-key': apiKey }, + }); + + if (!response.ok) { + return errorResult(`HTTP ${response.status}`); + } + + const data: unknown = await response.json(); + const items = Array.isArray(data) ? data : []; + const models: ReturnType = []; + + for (const item of items) { + // Filter out models that can't do text-to-speech + if (!isRecord(item) || item.can_do_text_to_speech === false) { + continue; + } + models.push(...extractModelEntries([item], ['model_id', 'id', 'name'])); + } + + return successResult(dedupeAndSortModels(models)); + } catch (error: unknown) { + return errorResult(extractErrorMessage(error, 'Failed to fetch models')); + } + } + + async testEndpoint( + baseUrl: string, + apiKey: string, + _model: string, + _type: EndpointTestType, + startTime: number + ): Promise { + const response = await fetch(`${baseUrl}/user`, { + headers: { + 'xi-api-key': apiKey, + }, + }); + + if (response.ok) { + return this.successTestResult('ElevenLabs API key is valid', startTime); + } + + return this.failTestResult('Invalid API key'); + } +} diff --git a/client/src/lib/ai-providers/strategies/google.ts b/client/src/lib/ai-providers/strategies/google.ts new file mode 100644 index 0000000..07786f8 --- /dev/null +++ b/client/src/lib/ai-providers/strategies/google.ts @@ -0,0 +1,75 @@ +/** + * Google AI Provider Strategy + * + * Handles model fetching and endpoint testing for Google AI (Gemini) API. + */ + +import { extractErrorMessage, getErrorMessage, isRecord } from '@/api/helpers'; + +import { errorResult, successResult } from '../constants'; +import { + dedupeAndSortModels, + extractModelEntries, + filterGoogleModel, +} from '../model-catalog-utils'; +import type { FetchModelsResult, TestEndpointResult } from '../types'; + +import { BaseProviderStrategy, type EndpointTestType, type ModelCatalogType } from './types'; + +/** + * Google AI provider strategy implementation. + */ +export class GoogleStrategy extends BaseProviderStrategy { + readonly providerId = 'google'; + readonly displayName = 'Google AI'; + readonly requiresApiKey = true; + + async fetchModels( + baseUrl: string, + apiKey: string, + type: ModelCatalogType + ): Promise { + try { + const response = await fetch(`${baseUrl}/models?key=${apiKey}`); + + if (!response.ok) { + return errorResult(`HTTP ${response.status}`); + } + + const data: unknown = await response.json(); + const items = isRecord(data) && Array.isArray(data.models) ? data.models : []; + const models = extractModelEntries(items, ['name'], (name) => + name.replace(/^models\//, '') + ).filter((model) => filterGoogleModel(model.id, type)); + + return successResult(dedupeAndSortModels(models)); + } catch (error: unknown) { + return errorResult(extractErrorMessage(error, 'Failed to fetch models')); + } + } + + async testEndpoint( + baseUrl: string, + apiKey: string, + model: string, + _type: EndpointTestType, + startTime: number + ): Promise { + const response = await fetch(`${baseUrl}/models/${model}:generateContent?key=${apiKey}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + contents: [{ parts: [{ text: 'Say ok' }] }], + }), + }); + + if (!response.ok) { + const errorPayload: unknown = await response.json().catch(() => null); + return this.failTestResult(getErrorMessage(errorPayload) || `HTTP ${response.status}`); + } + + return this.successTestResult('Google AI endpoint is working', startTime); + } +} diff --git a/client/src/lib/ai-providers/strategies/index.ts b/client/src/lib/ai-providers/strategies/index.ts new file mode 100644 index 0000000..5975109 --- /dev/null +++ b/client/src/lib/ai-providers/strategies/index.ts @@ -0,0 +1,87 @@ +/** + * AI Provider Strategy Registry + * + * Central registry mapping provider IDs to their strategy implementations. + * Adding a new provider requires only creating a new strategy class and + * registering it here. + */ + +import type { ProviderStrategy } from './types'; + +import { AnthropicStrategy } from './anthropic'; +import { AzureOpenAIStrategy, AzureSpeechStrategy } from './azure'; +import { CustomStrategy } from './custom'; +import { DeepgramStrategy } from './deepgram'; +import { ElevenLabsStrategy } from './elevenlabs'; +import { GoogleStrategy } from './google'; +import { OllamaStrategy } from './ollama'; +import { OpenAIStrategy, WhisperStrategy } from './openai'; + +// Re-export all strategies for direct import +export { AnthropicStrategy } from './anthropic'; +export { AzureOpenAIStrategy, AzureSpeechStrategy } from './azure'; +export { CustomStrategy } from './custom'; +export { DeepgramStrategy } from './deepgram'; +export { ElevenLabsStrategy } from './elevenlabs'; +export { GoogleStrategy } from './google'; +export { OllamaStrategy } from './ollama'; +export { OpenAIStrategy, WhisperStrategy } from './openai'; + +// Re-export types +export type { + ProviderStrategy, + EndpointTestType, + ModelCatalogType, +} from './types'; +export { BaseProviderStrategy } from './types'; + +/** Strategy instances for each provider. */ +const strategies = new Map([ + ['openai', new OpenAIStrategy()], + ['anthropic', new AnthropicStrategy()], + ['google', new GoogleStrategy()], + ['ollama', new OllamaStrategy()], + ['azure', new AzureOpenAIStrategy()], + ['azure-speech', new AzureSpeechStrategy()], + ['deepgram', new DeepgramStrategy()], + ['elevenlabs', new ElevenLabsStrategy()], + ['whisper', new WhisperStrategy()], + ['custom', new CustomStrategy()], +]); + +/** + * Get the strategy for a provider. + * + * @param providerId - The provider identifier + * @param isTranscription - Whether this is for transcription (affects Azure handling) + * @returns The strategy for the provider, or undefined if not found + */ +export function getStrategy( + providerId: string, + isTranscription = false +): ProviderStrategy | undefined { + // Handle Azure specially: azure-speech for transcription, azure for OpenAI + if (providerId === 'azure' && isTranscription) { + return strategies.get('azure-speech'); + } + + return strategies.get(providerId); +} + +/** + * Get all registered strategies. + */ +export function getAllStrategies(): ReadonlyMap { + return strategies; +} + +/** + * Check if a provider requires an API key. + * + * @param providerId - The provider identifier + * @returns True if the provider requires an API key + */ +export function requiresApiKey(providerId: string): boolean { + const strategy = strategies.get(providerId); + return strategy?.requiresApiKey ?? true; +} diff --git a/client/src/lib/ai-providers/strategies/ollama.ts b/client/src/lib/ai-providers/strategies/ollama.ts new file mode 100644 index 0000000..e5e2662 --- /dev/null +++ b/client/src/lib/ai-providers/strategies/ollama.ts @@ -0,0 +1,70 @@ +/** + * Ollama Provider Strategy + * + * Handles model fetching and endpoint testing for local Ollama instances. + */ + +import { extractErrorMessage, isRecord } from '@/api/helpers'; + +import { errorResult, successResult } from '../constants'; +import { dedupeAndSortModels, extractModelEntries } from '../model-catalog-utils'; +import type { FetchModelsResult, TestEndpointResult } from '../types'; + +import { BaseProviderStrategy, type EndpointTestType, type ModelCatalogType } from './types'; + +/** + * Ollama provider strategy implementation. + */ +export class OllamaStrategy extends BaseProviderStrategy { + readonly providerId = 'ollama'; + readonly displayName = 'Ollama (Local)'; + readonly requiresApiKey = false; + + async fetchModels( + baseUrl: string, + _apiKey: string, + _type: ModelCatalogType + ): Promise { + try { + const response = await fetch(`${baseUrl}/tags`); + + if (!response.ok) { + return errorResult(`HTTP ${response.status}`); + } + + const data: unknown = await response.json(); + const items = isRecord(data) && Array.isArray(data.models) ? data.models : []; + const models = extractModelEntries(items, ['name']); + + return successResult(dedupeAndSortModels(models)); + } catch (error: unknown) { + return errorResult(extractErrorMessage(error, 'Could not connect to Ollama')); + } + } + + async testEndpoint( + baseUrl: string, + _apiKey: string, + model: string, + _type: EndpointTestType, + startTime: number + ): Promise { + const response = await fetch(`${baseUrl}/generate`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model: model, + prompt: 'Say ok', + stream: false, + }), + }); + + if (!response.ok) { + return this.failTestResult(`HTTP ${response.status} - Is Ollama running?`); + } + + return this.successTestResult('Ollama is responding', startTime); + } +} diff --git a/client/src/lib/ai-providers/strategies/openai.ts b/client/src/lib/ai-providers/strategies/openai.ts new file mode 100644 index 0000000..a7c1628 --- /dev/null +++ b/client/src/lib/ai-providers/strategies/openai.ts @@ -0,0 +1,134 @@ +/** + * OpenAI Provider Strategy + * + * Handles model fetching and endpoint testing for OpenAI and OpenAI-compatible APIs. + * Also used as the base implementation for Whisper transcription. + */ + +import { extractErrorMessage, getErrorMessage, isRecord } from '@/api/helpers'; + +import { errorResult, successResult } from '../constants'; +import { + dedupeAndSortModels, + extractModelEntries, + filterOpenAIModel, +} from '../model-catalog-utils'; +import type { FetchModelsResult, TestEndpointResult } from '../types'; + +import { BaseProviderStrategy, type EndpointTestType, type ModelCatalogType } from './types'; + +/** + * OpenAI provider strategy implementation. + */ +export class OpenAIStrategy extends BaseProviderStrategy { + readonly providerId = 'openai'; + readonly displayName = 'OpenAI'; + readonly requiresApiKey = true; + + async fetchModels( + baseUrl: string, + apiKey: string, + type: ModelCatalogType + ): Promise { + try { + const response = await fetch(`${baseUrl}/models`, { + headers: { + Authorization: `Bearer ${apiKey}`, + }, + }); + + if (!response.ok) { + const errorPayload: unknown = await response.json().catch(() => null); + return errorResult(getErrorMessage(errorPayload) || `HTTP ${response.status}`); + } + + const data: unknown = await response.json(); + const items = isRecord(data) && Array.isArray(data.data) ? data.data : []; + const models = extractModelEntries(items, ['id']).filter((model) => + filterOpenAIModel(model.id, type) + ); + + return successResult(dedupeAndSortModels(models)); + } catch (error: unknown) { + return errorResult(extractErrorMessage(error, 'Failed to fetch models')); + } + } + + async testEndpoint( + baseUrl: string, + apiKey: string, + model: string, + type: EndpointTestType, + startTime: number + ): Promise { + if (type === 'embedding') { + return this.testEmbeddingEndpoint(baseUrl, apiKey, model, startTime); + } else if (type === 'summary') { + return this.testChatEndpoint(baseUrl, apiKey, model, startTime); + } + + return this.failTestResult('Unsupported test for this provider'); + } + + private async testEmbeddingEndpoint( + baseUrl: string, + apiKey: string, + model: string, + startTime: number + ): Promise { + const response = await fetch(`${baseUrl}/embeddings`, { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model: model, + input: 'test', + }), + }); + + if (!response.ok) { + const errorPayload: unknown = await response.json().catch(() => null); + return this.failTestResult(getErrorMessage(errorPayload) || `HTTP ${response.status}`); + } + + return this.successTestResult('Embedding endpoint is working', startTime); + } + + private async testChatEndpoint( + baseUrl: string, + apiKey: string, + model: string, + startTime: number + ): Promise { + const response = await fetch(`${baseUrl}/chat/completions`, { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model: model, + messages: [{ role: 'user', content: 'Say "test successful" in exactly 2 words.' }], + max_tokens: 10, + }), + }); + + if (!response.ok) { + const errorPayload: unknown = await response.json().catch(() => null); + return this.failTestResult(getErrorMessage(errorPayload) || `HTTP ${response.status}`); + } + + return this.successTestResult('Chat completion endpoint is working', startTime); + } +} + +/** + * Whisper (OpenAI transcription) strategy. + * Uses OpenAI-compatible API for model fetching. + */ +export class WhisperStrategy extends OpenAIStrategy { + override readonly providerId = 'whisper'; + override readonly displayName = 'OpenAI Whisper'; +} diff --git a/client/src/lib/ai-providers/strategies/strategies.test.ts b/client/src/lib/ai-providers/strategies/strategies.test.ts new file mode 100644 index 0000000..0e59c6a --- /dev/null +++ b/client/src/lib/ai-providers/strategies/strategies.test.ts @@ -0,0 +1,1175 @@ +/** + * Unit tests for AI Provider Strategies + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { AnthropicStrategy } from './anthropic'; +import { AzureOpenAIStrategy, AzureSpeechStrategy } from './azure'; +import { CustomStrategy } from './custom'; +import { DeepgramStrategy } from './deepgram'; +import { ElevenLabsStrategy } from './elevenlabs'; +import { GoogleStrategy } from './google'; +import { getAllStrategies, getStrategy, requiresApiKey } from './index'; +import { OllamaStrategy } from './ollama'; +import { OpenAIStrategy, WhisperStrategy } from './openai'; +import type { ProviderStrategy } from './types'; + +// Mock global fetch +const mockFetch = vi.fn(); +global.fetch = mockFetch; + +beforeEach(() => { + mockFetch.mockReset(); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe('Strategy Registry', () => { + it('returns all registered strategies', () => { + const strategies = getAllStrategies(); + expect(strategies.size).toBeGreaterThanOrEqual(10); + expect(strategies.has('openai')).toBe(true); + expect(strategies.has('anthropic')).toBe(true); + expect(strategies.has('google')).toBe(true); + expect(strategies.has('ollama')).toBe(true); + expect(strategies.has('azure')).toBe(true); + expect(strategies.has('deepgram')).toBe(true); + expect(strategies.has('elevenlabs')).toBe(true); + expect(strategies.has('whisper')).toBe(true); + expect(strategies.has('custom')).toBe(true); + }); + + it('getStrategy returns correct strategy for provider', () => { + expect(getStrategy('openai')).toBeInstanceOf(OpenAIStrategy); + expect(getStrategy('anthropic')).toBeInstanceOf(AnthropicStrategy); + expect(getStrategy('google')).toBeInstanceOf(GoogleStrategy); + expect(getStrategy('ollama')).toBeInstanceOf(OllamaStrategy); + expect(getStrategy('deepgram')).toBeInstanceOf(DeepgramStrategy); + expect(getStrategy('elevenlabs')).toBeInstanceOf(ElevenLabsStrategy); + expect(getStrategy('custom')).toBeInstanceOf(CustomStrategy); + }); + + it('getStrategy returns azure-speech for azure with isTranscription=true', () => { + const strategy = getStrategy('azure', true); + expect(strategy).toBeInstanceOf(AzureSpeechStrategy); + }); + + it('getStrategy returns azure-openai for azure with isTranscription=false', () => { + const strategy = getStrategy('azure', false); + expect(strategy).toBeInstanceOf(AzureOpenAIStrategy); + }); + + it('getStrategy returns undefined for unknown provider', () => { + expect(getStrategy('unknown-provider')).toBeUndefined(); + }); + + it('requiresApiKey returns correct values', () => { + expect(requiresApiKey('openai')).toBe(true); + expect(requiresApiKey('anthropic')).toBe(true); + expect(requiresApiKey('ollama')).toBe(false); + expect(requiresApiKey('custom')).toBe(false); + expect(requiresApiKey('unknown')).toBe(true); // Default for unknown + }); +}); + +describe('Strategy Interface Compliance', () => { + const strategies: Array<[string, ProviderStrategy]> = [ + ['OpenAI', new OpenAIStrategy()], + ['Anthropic', new AnthropicStrategy()], + ['Google', new GoogleStrategy()], + ['Ollama', new OllamaStrategy()], + ['AzureOpenAI', new AzureOpenAIStrategy()], + ['AzureSpeech', new AzureSpeechStrategy()], + ['Deepgram', new DeepgramStrategy()], + ['ElevenLabs', new ElevenLabsStrategy()], + ['Whisper', new WhisperStrategy()], + ['Custom', new CustomStrategy()], + ]; + + it.each(strategies)('%s strategy has required properties', (_, strategy) => { + expect(typeof strategy.providerId).toBe('string'); + expect(strategy.providerId.length).toBeGreaterThan(0); + expect(typeof strategy.displayName).toBe('string'); + expect(strategy.displayName.length).toBeGreaterThan(0); + expect(typeof strategy.requiresApiKey).toBe('boolean'); + }); + + it.each(strategies)('%s strategy has fetchModels method', (_, strategy) => { + expect(typeof strategy.fetchModels).toBe('function'); + }); + + it.each(strategies)('%s strategy has testEndpoint method', (_, strategy) => { + expect(typeof strategy.testEndpoint).toBe('function'); + }); +}); + +describe('OpenAIStrategy', () => { + const strategy = new OpenAIStrategy(); + + it('has correct provider metadata', () => { + expect(strategy.providerId).toBe('openai'); + expect(strategy.displayName).toBe('OpenAI'); + expect(strategy.requiresApiKey).toBe(true); + }); + + it('fetchModels returns models on success', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + data: [{ id: 'gpt-4' }, { id: 'gpt-3.5-turbo' }], + }), + }); + + const result = await strategy.fetchModels('https://api.openai.com/v1', 'sk-test', 'summary'); + + expect(result.success).toBe(true); + expect(result.models.length).toBeGreaterThan(0); + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.openai.com/v1/models', + expect.objectContaining({ + headers: { Authorization: 'Bearer sk-test' }, + }) + ); + }); + + it('fetchModels returns error on HTTP failure', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 401, + json: async () => ({ error: { message: 'Invalid API key' } }), + }); + + const result = await strategy.fetchModels('https://api.openai.com/v1', 'bad-key', 'summary'); + + expect(result.success).toBe(false); + expect(result.error).toBeDefined(); + }); + + it('testEndpoint for embedding calls embeddings endpoint', async () => { + mockFetch.mockResolvedValueOnce({ ok: true }); + + const result = await strategy.testEndpoint( + 'https://api.openai.com/v1', + 'sk-test', + 'text-embedding-3-small', + 'embedding', + Date.now() + ); + + expect(result.success).toBe(true); + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.openai.com/v1/embeddings', + expect.objectContaining({ method: 'POST' }) + ); + }); + + it('testEndpoint for summary calls chat/completions endpoint', async () => { + mockFetch.mockResolvedValueOnce({ ok: true }); + + const result = await strategy.testEndpoint( + 'https://api.openai.com/v1', + 'sk-test', + 'gpt-4', + 'summary', + Date.now() + ); + + expect(result.success).toBe(true); + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.openai.com/v1/chat/completions', + expect.objectContaining({ method: 'POST' }) + ); + }); +}); + +describe('AnthropicStrategy', () => { + const strategy = new AnthropicStrategy(); + + it('has correct provider metadata', () => { + expect(strategy.providerId).toBe('anthropic'); + expect(strategy.displayName).toBe('Anthropic'); + expect(strategy.requiresApiKey).toBe(true); + }); + + it('fetchModels includes anthropic-version header', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: [{ id: 'claude-3-opus' }] }), + }); + + await strategy.fetchModels('https://api.anthropic.com/v1', 'sk-ant-test', 'summary'); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.anthropic.com/v1/models', + expect.objectContaining({ + headers: expect.objectContaining({ + 'x-api-key': 'sk-ant-test', + 'anthropic-version': expect.any(String), + }), + }) + ); + }); + + it('testEndpoint treats 429 rate limit as valid key', async () => { + mockFetch.mockResolvedValueOnce({ ok: false, status: 429 }); + + const result = await strategy.testEndpoint( + 'https://api.anthropic.com/v1', + 'sk-ant-test', + 'claude-3-opus', + 'summary', + Date.now() + ); + + expect(result.success).toBe(true); + expect(result.message).toContain('rate limited'); + }); +}); + +describe('OllamaStrategy', () => { + const strategy = new OllamaStrategy(); + + it('has correct provider metadata', () => { + expect(strategy.providerId).toBe('ollama'); + expect(strategy.displayName).toBe('Ollama (Local)'); + expect(strategy.requiresApiKey).toBe(false); + }); + + it('fetchModels calls /tags endpoint', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ models: [{ name: 'llama2' }, { name: 'mistral' }] }), + }); + + const result = await strategy.fetchModels('http://localhost:11434/api', '', 'summary'); + + expect(result.success).toBe(true); + expect(mockFetch).toHaveBeenCalledWith('http://localhost:11434/api/tags'); + }); + + it('testEndpoint calls /generate endpoint', async () => { + mockFetch.mockResolvedValueOnce({ ok: true }); + + const result = await strategy.testEndpoint( + 'http://localhost:11434/api', + '', + 'llama2', + 'summary', + Date.now() + ); + + expect(result.success).toBe(true); + expect(mockFetch).toHaveBeenCalledWith( + 'http://localhost:11434/api/generate', + expect.objectContaining({ + method: 'POST', + body: expect.stringContaining('llama2'), + }) + ); + }); +}); + +describe('GoogleStrategy', () => { + const strategy = new GoogleStrategy(); + + it('has correct provider metadata', () => { + expect(strategy.providerId).toBe('google'); + expect(strategy.displayName).toBe('Google AI'); + expect(strategy.requiresApiKey).toBe(true); + }); + + it('fetchModels includes API key in URL', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ models: [{ name: 'models/gemini-pro' }] }), + }); + + await strategy.fetchModels( + 'https://generativelanguage.googleapis.com/v1beta', + 'test-key', + 'summary' + ); + + expect(mockFetch).toHaveBeenCalled(); + const calledUrl = mockFetch.mock.calls[0][0] as string; + expect(calledUrl).toContain('key=test-key'); + }); +}); + +describe('DeepgramStrategy', () => { + const strategy = new DeepgramStrategy(); + + it('has correct provider metadata', () => { + expect(strategy.providerId).toBe('deepgram'); + expect(strategy.displayName).toBe('Deepgram'); + expect(strategy.requiresApiKey).toBe(true); + }); + + it('fetchModels uses Token authorization', async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ projects: [] }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ models: [{ name: 'nova-2' }] }), + }); + + await strategy.fetchModels('https://api.deepgram.com/v1', 'dg-test', 'transcription'); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('/projects'), + expect.objectContaining({ + headers: { Authorization: 'Token dg-test' }, + }) + ); + }); +}); + +describe('ElevenLabsStrategy', () => { + const strategy = new ElevenLabsStrategy(); + + it('has correct provider metadata', () => { + expect(strategy.providerId).toBe('elevenlabs'); + expect(strategy.displayName).toBe('ElevenLabs'); + expect(strategy.requiresApiKey).toBe(true); + }); + + it('fetchModels uses xi-api-key header', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => [{ model_id: 'eleven_monolingual_v1', can_do_text_to_speech: true }], + }); + + await strategy.fetchModels('https://api.elevenlabs.io/v1', 'xi-test', 'transcription'); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.elevenlabs.io/v1/models', + expect.objectContaining({ + headers: { 'xi-api-key': 'xi-test' }, + }) + ); + }); + + it('testEndpoint calls /user endpoint', async () => { + mockFetch.mockResolvedValueOnce({ ok: true }); + + const result = await strategy.testEndpoint( + 'https://api.elevenlabs.io/v1', + 'xi-test', + 'eleven_monolingual_v1', + 'transcription', + Date.now() + ); + + expect(result.success).toBe(true); + expect(mockFetch).toHaveBeenCalledWith('https://api.elevenlabs.io/v1/user', expect.anything()); + }); +}); + +describe('CustomStrategy', () => { + const strategy = new CustomStrategy(); + + it('has correct provider metadata', () => { + expect(strategy.providerId).toBe('custom'); + expect(strategy.displayName).toBe('Custom'); + expect(strategy.requiresApiKey).toBe(false); + }); + + it('fetchModels works without API key', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: [{ id: 'custom-model' }] }), + }); + + const result = await strategy.fetchModels('http://localhost:8080/v1', '', 'summary'); + + expect(result.success).toBe(true); + expect(mockFetch).toHaveBeenCalledWith( + 'http://localhost:8080/v1/models', + expect.objectContaining({ + headers: {}, + }) + ); + }); + + it('fetchModels includes Bearer token when API key provided', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: [{ id: 'custom-model' }] }), + }); + + await strategy.fetchModels('http://localhost:8080/v1', 'my-key', 'summary'); + + expect(mockFetch).toHaveBeenCalledWith( + 'http://localhost:8080/v1/models', + expect.objectContaining({ + headers: { Authorization: 'Bearer my-key' }, + }) + ); + }); +}); + +describe('WhisperStrategy', () => { + const strategy = new WhisperStrategy(); + + it('extends OpenAIStrategy', () => { + expect(strategy).toBeInstanceOf(OpenAIStrategy); + }); + + it('has whisper provider ID', () => { + expect(strategy.providerId).toBe('whisper'); + expect(strategy.displayName).toBe('OpenAI Whisper'); + }); +}); + +describe('AzureStrategies', () => { + describe('AzureOpenAIStrategy', () => { + const strategy = new AzureOpenAIStrategy(); + + it('has correct provider metadata', () => { + expect(strategy.providerId).toBe('azure'); + expect(strategy.displayName).toBe('Azure OpenAI'); + expect(strategy.requiresApiKey).toBe(true); + }); + + it('fetchModels calls deployments endpoint with api-version', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: [{ id: 'my-deployment' }] }), + }); + + await strategy.fetchModels('https://my-resource.openai.azure.com', 'azure-key', 'summary'); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringMatching(/openai\/deployments\?api-version=/), + expect.objectContaining({ + headers: { 'api-key': 'azure-key' }, + }) + ); + }); + }); + + describe('AzureSpeechStrategy', () => { + const strategy = new AzureSpeechStrategy(); + + it('has correct provider metadata', () => { + expect(strategy.providerId).toBe('azure-speech'); + expect(strategy.displayName).toBe('Azure Speech'); + expect(strategy.requiresApiKey).toBe(true); + }); + + it('fetchModels uses Ocp-Apim-Subscription-Key header', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ values: [{ shortName: 'en-US' }] }), + }); + + await strategy.fetchModels( + 'https://eastus.api.cognitive.microsoft.com', + 'speech-key', + 'transcription' + ); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('/speechtotext/'), + expect.objectContaining({ + headers: { 'Ocp-Apim-Subscription-Key': 'speech-key' }, + }) + ); + }); + }); +}); + +// ============================================================================= +// BEHAVIORAL TESTS - Response Parsing Edge Cases +// ============================================================================= + +describe('Response Parsing Edge Cases', () => { + describe('OpenAIStrategy response parsing', () => { + const strategy = new OpenAIStrategy(); + + it('handles empty data array gracefully', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: [] }), + }); + + const result = await strategy.fetchModels('https://api.openai.com/v1', 'sk-test', 'summary'); + + expect(result.success).toBe(true); + expect(result.models).toEqual([]); + }); + + it('handles missing data field gracefully', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({}), + }); + + const result = await strategy.fetchModels('https://api.openai.com/v1', 'sk-test', 'summary'); + + expect(result.success).toBe(true); + expect(result.models).toEqual([]); + }); + + it('handles malformed model entries (missing id)', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + data: [{ name: 'no-id-model' }, { id: 'valid-model' }, { id: '' }, null], + }), + }); + + const result = await strategy.fetchModels('https://api.openai.com/v1', 'sk-test', 'summary'); + + expect(result.success).toBe(true); + // Only valid-model should be extracted (no-id-model lacks 'id', empty id filtered, null skipped) + expect(result.models.some((m) => m.id === 'valid-model')).toBe(true); + }); + + it('handles network error gracefully', async () => { + mockFetch.mockRejectedValueOnce(new Error('Network timeout')); + + const result = await strategy.fetchModels('https://api.openai.com/v1', 'sk-test', 'summary'); + + expect(result.success).toBe(false); + expect(result.error).toContain('Network timeout'); + }); + + it('handles JSON parse error gracefully', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => { + throw new SyntaxError('Unexpected token'); + }, + }); + + const result = await strategy.fetchModels('https://api.openai.com/v1', 'sk-test', 'summary'); + + expect(result.success).toBe(false); + expect(result.error).toBeDefined(); + }); + + it('extracts error message from OpenAI error response', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 401, + json: async () => ({ error: { message: 'Invalid API key provided' } }), + }); + + const result = await strategy.fetchModels('https://api.openai.com/v1', 'bad-key', 'summary'); + + expect(result.success).toBe(false); + expect(result.error).toBe('Invalid API key provided'); + }); + + it('falls back to HTTP status when error body unparseable', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + json: async () => { + throw new Error('Not JSON'); + }, + }); + + const result = await strategy.fetchModels('https://api.openai.com/v1', 'sk-test', 'summary'); + + expect(result.success).toBe(false); + expect(result.error).toContain('500'); + }); + }); + + describe('OllamaStrategy response parsing', () => { + const strategy = new OllamaStrategy(); + + it('handles empty models array', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ models: [] }), + }); + + const result = await strategy.fetchModels('http://localhost:11434/api', '', 'summary'); + + expect(result.success).toBe(true); + expect(result.models).toEqual([]); + }); + + it('handles connection refused error', async () => { + mockFetch.mockRejectedValueOnce(new Error('ECONNREFUSED')); + + const result = await strategy.fetchModels('http://localhost:11434/api', '', 'summary'); + + expect(result.success).toBe(false); + expect(result.error).toBeDefined(); + }); + }); + + describe('AzureOpenAIStrategy response parsing', () => { + const strategy = new AzureOpenAIStrategy(); + + it('parses data array format', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: [{ id: 'deployment-1' }, { id: 'deployment-2' }] }), + }); + + const result = await strategy.fetchModels( + 'https://my-resource.openai.azure.com', + 'key', + 'summary' + ); + + expect(result.success).toBe(true); + expect(result.models).toHaveLength(2); + }); + + it('parses value array format (alternate Azure response)', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ value: [{ id: 'deployment-a' }, { name: 'deployment-b' }] }), + }); + + const result = await strategy.fetchModels( + 'https://my-resource.openai.azure.com', + 'key', + 'summary' + ); + + expect(result.success).toBe(true); + expect(result.models.length).toBeGreaterThanOrEqual(1); + }); + + it('strips trailing slashes from base URL', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: [] }), + }); + + await strategy.fetchModels('https://my-resource.openai.azure.com/', 'key', 'summary'); + + const calledUrl = mockFetch.mock.calls[0][0] as string; + expect(calledUrl).not.toContain('//openai'); + }); + }); +}); + +// ============================================================================= +// BEHAVIORAL TESTS - Model Filtering +// ============================================================================= + +describe('Model Filtering Behavior', () => { + describe('OpenAI model type filtering', () => { + const strategy = new OpenAIStrategy(); + + it('filters to embedding models when type=embedding', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + data: [ + { id: 'text-embedding-3-small' }, + { id: 'text-embedding-ada-002' }, + { id: 'gpt-4' }, + { id: 'gpt-3.5-turbo' }, + { id: 'whisper-1' }, + { id: 'dall-e-3' }, + ], + }), + }); + + const result = await strategy.fetchModels( + 'https://api.openai.com/v1', + 'sk-test', + 'embedding' + ); + + expect(result.success).toBe(true); + expect(result.models.every((m) => m.id.includes('embedding'))).toBe(true); + expect(result.models.some((m) => m.id === 'gpt-4')).toBe(false); + }); + + it('filters to transcription models when type=transcription', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + data: [ + { id: 'whisper-1' }, + { id: 'gpt-4' }, + { id: 'text-embedding-3-small' }, + { id: 'tts-1' }, + ], + }), + }); + + const result = await strategy.fetchModels( + 'https://api.openai.com/v1', + 'sk-test', + 'transcription' + ); + + expect(result.success).toBe(true); + expect(result.models.some((m) => m.id === 'whisper-1')).toBe(true); + expect(result.models.some((m) => m.id === 'gpt-4')).toBe(false); + }); + + it('excludes embedding/whisper/tts/dall-e for summary type', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + data: [ + { id: 'gpt-4' }, + { id: 'gpt-4-turbo' }, + { id: 'gpt-3.5-turbo' }, + { id: 'text-embedding-3-small' }, + { id: 'whisper-1' }, + { id: 'tts-1' }, + { id: 'dall-e-3' }, + ], + }), + }); + + const result = await strategy.fetchModels('https://api.openai.com/v1', 'sk-test', 'summary'); + + expect(result.success).toBe(true); + const ids = result.models.map((m) => m.id); + expect(ids).toContain('gpt-4'); + expect(ids).toContain('gpt-3.5-turbo'); + expect(ids).not.toContain('text-embedding-3-small'); + expect(ids).not.toContain('whisper-1'); + expect(ids).not.toContain('tts-1'); + expect(ids).not.toContain('dall-e-3'); + }); + }); + + describe('Google model filtering', () => { + const strategy = new GoogleStrategy(); + + it('strips models/ prefix from model names', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + models: [ + { name: 'models/gemini-pro' }, + { name: 'models/gemini-pro-vision' }, + { name: 'models/embedding-001' }, + ], + }), + }); + + const result = await strategy.fetchModels( + 'https://generativelanguage.googleapis.com/v1beta', + 'key', + 'summary' + ); + + expect(result.success).toBe(true); + expect(result.models.some((m) => m.id === 'gemini-pro')).toBe(true); + expect(result.models.some((m) => m.id === 'models/gemini-pro')).toBe(false); + }); + + it('filters to embedding models when type=embedding', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + models: [ + { name: 'models/gemini-pro' }, + { name: 'models/embedding-001' }, + { name: 'models/text-embedding-004' }, + ], + }), + }); + + const result = await strategy.fetchModels( + 'https://generativelanguage.googleapis.com/v1beta', + 'key', + 'embedding' + ); + + expect(result.success).toBe(true); + expect(result.models.every((m) => m.id.includes('embedding'))).toBe(true); + }); + }); + + describe('ElevenLabs model filtering', () => { + const strategy = new ElevenLabsStrategy(); + + it('filters out models without text-to-speech capability', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => [ + { model_id: 'eleven_monolingual_v1', can_do_text_to_speech: true }, + { model_id: 'eleven_multilingual_v2', can_do_text_to_speech: true }, + { model_id: 'some_stt_model', can_do_text_to_speech: false }, + { model_id: 'another_model' }, // undefined can_do_text_to_speech (should be included) + ], + }); + + const result = await strategy.fetchModels( + 'https://api.elevenlabs.io/v1', + 'xi-key', + 'transcription' + ); + + expect(result.success).toBe(true); + const ids = result.models.map((m) => m.id); + expect(ids).toContain('eleven_monolingual_v1'); + expect(ids).toContain('eleven_multilingual_v2'); + expect(ids).not.toContain('some_stt_model'); + }); + }); +}); + +// ============================================================================= +// BEHAVIORAL TESTS - Error Handling +// ============================================================================= + +describe('Error Handling Behavior', () => { + describe('OpenAI testEndpoint error handling', () => { + const strategy = new OpenAIStrategy(); + + it('returns failure with error message from response body', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 400, + json: async () => ({ error: { message: 'Invalid model specified' } }), + }); + + const result = await strategy.testEndpoint( + 'https://api.openai.com/v1', + 'sk-test', + 'invalid-model', + 'summary', + Date.now() + ); + + expect(result.success).toBe(false); + expect(result.message).toContain('Invalid model'); + }); + + it('returns unsupported test type error', async () => { + const result = await strategy.testEndpoint( + 'https://api.openai.com/v1', + 'sk-test', + 'model', + 'transcription' as 'embedding', // Cast to wrong type + Date.now() + ); + + expect(result.success).toBe(false); + expect(result.message).toContain('Unsupported'); + }); + }); + + describe('Anthropic testEndpoint special cases', () => { + const strategy = new AnthropicStrategy(); + + it('returns success on 200 OK', async () => { + mockFetch.mockResolvedValueOnce({ ok: true, status: 200 }); + + const result = await strategy.testEndpoint( + 'https://api.anthropic.com/v1', + 'sk-ant-test', + 'claude-3-opus', + 'summary', + Date.now() + ); + + expect(result.success).toBe(true); + expect(result.message).toContain('successful'); + }); + + it('returns failure on 401 unauthorized', async () => { + mockFetch.mockResolvedValueOnce({ ok: false, status: 401 }); + + const result = await strategy.testEndpoint( + 'https://api.anthropic.com/v1', + 'bad-key', + 'claude-3-opus', + 'summary', + Date.now() + ); + + expect(result.success).toBe(false); + expect(result.message).toContain('401'); + }); + + it('returns failure on 403 forbidden', async () => { + mockFetch.mockResolvedValueOnce({ ok: false, status: 403 }); + + const result = await strategy.testEndpoint( + 'https://api.anthropic.com/v1', + 'sk-ant-test', + 'claude-3-opus', + 'summary', + Date.now() + ); + + expect(result.success).toBe(false); + }); + }); + + describe('Ollama testEndpoint error handling', () => { + const strategy = new OllamaStrategy(); + + it('includes helpful message when Ollama not running', async () => { + mockFetch.mockResolvedValueOnce({ ok: false, status: 500 }); + + const result = await strategy.testEndpoint( + 'http://localhost:11434/api', + '', + 'llama2', + 'summary', + Date.now() + ); + + expect(result.success).toBe(false); + expect(result.message).toContain('Is Ollama running'); + }); + }); + + describe('ElevenLabs testEndpoint error handling', () => { + const strategy = new ElevenLabsStrategy(); + + it('returns failure on invalid API key', async () => { + mockFetch.mockResolvedValueOnce({ ok: false, status: 401 }); + + const result = await strategy.testEndpoint( + 'https://api.elevenlabs.io/v1', + 'invalid-key', + 'model', + 'transcription', + Date.now() + ); + + expect(result.success).toBe(false); + expect(result.message).toContain('Invalid'); + }); + }); +}); + +// ============================================================================= +// BEHAVIORAL TESTS - Complex Flows +// ============================================================================= + +describe('Complex Flow Behavior', () => { + describe('Deepgram project-based model lookup with fallback', () => { + const strategy = new DeepgramStrategy(); + + it('fetches project-specific models when project found', async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ projects: [{ project_id: 'proj-123' }] }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ models: [{ name: 'nova-2' }, { name: 'nova-2-phonecall' }] }), + }); + + const result = await strategy.fetchModels( + 'https://api.deepgram.com/v1', + 'dg-key', + 'transcription' + ); + + expect(result.success).toBe(true); + expect(mockFetch).toHaveBeenCalledTimes(2); + // Second call should be to project-specific models endpoint + expect(mockFetch.mock.calls[1][0]).toContain('/projects/proj-123/models'); + }); + + it('falls back to public models when no projects', async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ projects: [] }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ stt: [{ name: 'nova-2' }] }), + }); + + const result = await strategy.fetchModels( + 'https://api.deepgram.com/v1', + 'dg-key', + 'transcription' + ); + + expect(result.success).toBe(true); + // Should fall back to /v1/models + expect(mockFetch.mock.calls[1][0]).toContain('/v1/models'); + }); + + it('falls back to public models when projects endpoint fails', async () => { + mockFetch + .mockResolvedValueOnce({ ok: false, status: 403 }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ models: [{ name: 'nova-2' }] }), + }); + + const result = await strategy.fetchModels( + 'https://api.deepgram.com/v1', + 'dg-key', + 'transcription' + ); + + expect(result.success).toBe(true); + }); + + it('handles nested stt array in models response', async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ projects: [] }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + models: { + stt: [{ name: 'nova-2' }, { name: 'enhanced' }], + }, + }), + }); + + const result = await strategy.fetchModels( + 'https://api.deepgram.com/v1', + 'dg-key', + 'transcription' + ); + + expect(result.success).toBe(true); + expect(result.models).toHaveLength(2); + }); + + it('strips /v1 suffix to build correct URLs', async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ projects: [] }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ models: [] }), + }); + + await strategy.fetchModels('https://api.deepgram.com/v1/', 'dg-key', 'transcription'); + + // Should not have double /v1/v1 + const projectsUrl = mockFetch.mock.calls[0][0] as string; + expect(projectsUrl).not.toContain('/v1/v1'); + }); + }); + + describe('Azure Speech API version fallback', () => { + const strategy = new AzureSpeechStrategy(); + + it('tries v3.2 first, then falls back to v3.1', async () => { + mockFetch + .mockResolvedValueOnce({ ok: false, status: 404 }) // v3.2 fails + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ values: [{ shortName: 'en-US' }] }), + }); // v3.1 succeeds + + const result = await strategy.fetchModels( + 'https://eastus.api.cognitive.microsoft.com', + 'key', + 'transcription' + ); + + expect(result.success).toBe(true); + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(mockFetch.mock.calls[0][0]).toContain('v3.2'); + expect(mockFetch.mock.calls[1][0]).toContain('v3.1'); + }); + + it('returns error when all API versions fail', async () => { + mockFetch + .mockResolvedValueOnce({ ok: false, status: 401 }) + .mockResolvedValueOnce({ ok: false, status: 401 }); + + const result = await strategy.fetchModels( + 'https://eastus.api.cognitive.microsoft.com', + 'bad-key', + 'transcription' + ); + + expect(result.success).toBe(false); + expect(result.error).toContain('not reachable'); + }); + + it('testEndpoint delegates to fetchModels', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ values: [{ shortName: 'en-US' }] }), + }); + + const result = await strategy.testEndpoint( + 'https://eastus.api.cognitive.microsoft.com', + 'key', + 'model', + 'transcription', + Date.now() + ); + + expect(result.success).toBe(true); + expect(result.message).toContain('working'); + }); + + it('handles models array format', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ models: [{ name: 'en-US' }, { id: 'fr-FR' }] }), + }); + + const result = await strategy.fetchModels( + 'https://eastus.api.cognitive.microsoft.com', + 'key', + 'transcription' + ); + + expect(result.success).toBe(true); + expect(result.models.length).toBeGreaterThanOrEqual(1); + }); + }); + + describe('Model deduplication and sorting', () => { + const strategy = new OpenAIStrategy(); + + it('deduplicates models with same ID', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + data: [{ id: 'gpt-4' }, { id: 'gpt-4' }, { id: 'gpt-3.5-turbo' }], + }), + }); + + const result = await strategy.fetchModels('https://api.openai.com/v1', 'sk-test', 'summary'); + + expect(result.success).toBe(true); + const gpt4Count = result.models.filter((m) => m.id === 'gpt-4').length; + expect(gpt4Count).toBe(1); + }); + + it('sorts models alphabetically', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + data: [{ id: 'zebra-model' }, { id: 'alpha-model' }, { id: 'beta-model' }], + }), + }); + + const result = await strategy.fetchModels('https://api.openai.com/v1', 'sk-test', 'summary'); + + expect(result.success).toBe(true); + expect(result.models[0].id).toBe('alpha-model'); + expect(result.models[1].id).toBe('beta-model'); + expect(result.models[2].id).toBe('zebra-model'); + }); + }); +}); diff --git a/client/src/lib/ai-providers/strategies/types.ts b/client/src/lib/ai-providers/strategies/types.ts new file mode 100644 index 0000000..c10d0c3 --- /dev/null +++ b/client/src/lib/ai-providers/strategies/types.ts @@ -0,0 +1,111 @@ +/** + * AI Provider Strategy Pattern Types + * + * Defines the interface for provider-specific model fetching and endpoint testing. + * Each provider implements this interface to encapsulate its API-specific logic. + */ + +import type { FetchModelsResult, TestEndpointResult } from '../types'; + +/** The type of model catalog being fetched. */ +export type ModelCatalogType = 'summary' | 'embedding' | 'transcription'; + +/** The type of endpoint being tested. */ +export type EndpointTestType = 'transcription' | 'summary' | 'embedding'; + +/** + * Strategy interface for AI provider implementations. + * + * Each provider implements this interface to handle: + * - Model catalog fetching (with provider-specific API calls) + * - Endpoint connectivity testing (with provider-specific validation) + */ +export interface ProviderStrategy { + /** Unique identifier for this provider. */ + readonly providerId: string; + + /** Display name for UI. */ + readonly displayName: string; + + /** Whether this provider requires an API key. */ + readonly requiresApiKey: boolean; + + /** + * Fetch available models from the provider's API. + * + * @param baseUrl - The provider's API base URL + * @param apiKey - API key for authentication (may be empty for local providers) + * @param type - The type of models to fetch (summary, embedding, transcription) + * @returns Promise resolving to model list or error + */ + fetchModels(baseUrl: string, apiKey: string, type: ModelCatalogType): Promise; + + /** + * Test endpoint connectivity and API key validity. + * + * @param baseUrl - The provider's API base URL + * @param apiKey - API key for authentication + * @param model - Model to use for the test request + * @param type - The type of endpoint to test + * @param startTime - Timestamp when the test started (for latency calculation) + * @returns Promise resolving to test result + */ + testEndpoint( + baseUrl: string, + apiKey: string, + model: string, + type: EndpointTestType, + startTime: number + ): Promise; +} + +/** + * Base class for provider strategies with common helper methods. + */ +export abstract class BaseProviderStrategy implements ProviderStrategy { + abstract readonly providerId: string; + abstract readonly displayName: string; + abstract readonly requiresApiKey: boolean; + + abstract fetchModels( + baseUrl: string, + apiKey: string, + type: ModelCatalogType + ): Promise; + + abstract testEndpoint( + baseUrl: string, + apiKey: string, + model: string, + type: EndpointTestType, + startTime: number + ): Promise; + + /** + * Calculate latency from a start time. + */ + protected calculateLatency(startTime: number): number { + return Date.now() - startTime; + } + + /** + * Create a successful test result. + */ + protected successTestResult(message: string, startTime: number): TestEndpointResult { + return { + success: true, + message, + latency: this.calculateLatency(startTime), + }; + } + + /** + * Create a failed test result. + */ + protected failTestResult(message: string): TestEndpointResult { + return { + success: false, + message, + }; + } +} diff --git a/client/src/lib/ai-providers/test-endpoint.ts b/client/src/lib/ai-providers/test-endpoint.ts index 9555ca8..17bd230 100644 --- a/client/src/lib/ai-providers/test-endpoint.ts +++ b/client/src/lib/ai-providers/test-endpoint.ts @@ -1,261 +1,15 @@ /** * Endpoint testing functions for AI providers. + * + * Uses the strategy pattern to delegate to provider-specific implementations. */ -import { HttpStatus } from '@/api/constants'; -import { extractErrorMessage, getErrorMessage } from '@/api/helpers'; +import { extractErrorMessage } from '@/api/helpers'; import type { AIProviderType, TranscriptionProviderType } from '@/api/types'; -import { ANTHROPIC_API_VERSION } from './constants'; +import { getStrategy } from './strategies'; import type { TestEndpointResult } from './types'; -/** Test OpenAI endpoint connectivity. */ -async function testOpenAIEndpoint( - baseUrl: string, - apiKey: string, - model: string, - type: 'transcription' | 'summary' | 'embedding', - startTime: number -): Promise { - if (type === 'embedding') { - const response = await fetch(`${baseUrl}/embeddings`, { - method: 'POST', - headers: { - Authorization: `Bearer ${apiKey}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - model: model, - input: 'test', - }), - }); - - if (!response.ok) { - const errorPayload: unknown = await response.json().catch(() => null); - return { - success: false, - message: getErrorMessage(errorPayload) || `HTTP ${response.status}`, - }; - } - - return { - success: true, - message: 'Embedding endpoint is working', - latency: Date.now() - startTime, - }; - } else if (type === 'summary') { - const response = await fetch(`${baseUrl}/chat/completions`, { - method: 'POST', - headers: { - Authorization: `Bearer ${apiKey}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - model: model, - messages: [{ role: 'user', content: 'Say "test successful" in exactly 2 words.' }], - max_tokens: 10, - }), - }); - - if (!response.ok) { - const errorPayload: unknown = await response.json().catch(() => null); - return { - success: false, - message: getErrorMessage(errorPayload) || `HTTP ${response.status}`, - }; - } - - return { - success: true, - message: 'Chat completion endpoint is working', - latency: Date.now() - startTime, - }; - } - - return { success: false, message: 'Unsupported test for this provider' }; -} - -/** Test Anthropic endpoint connectivity. */ -async function testAnthropicEndpoint( - baseUrl: string, - apiKey: string, - model: string, - startTime: number -): Promise { - const response = await fetch(`${baseUrl}/messages`, { - method: 'POST', - headers: { - 'x-api-key': apiKey, - 'anthropic-version': ANTHROPIC_API_VERSION, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - model: model, - max_tokens: 10, - messages: [{ role: 'user', content: 'Say "ok"' }], - }), - }); - - // Rate limited but key is valid - if (response.ok || response.status === HttpStatus.TOO_MANY_REQUESTS) { - return { - success: true, - message: - response.status === HttpStatus.TOO_MANY_REQUESTS - ? 'API key valid (rate limited)' - : 'Connection successful', - latency: Date.now() - startTime, - }; - } - - return { - success: false, - message: `HTTP ${response.status}`, - }; -} - -/** Test Google AI endpoint connectivity. */ -async function testGoogleEndpoint( - baseUrl: string, - apiKey: string, - model: string, - startTime: number -): Promise { - const response = await fetch(`${baseUrl}/models/${model}:generateContent?key=${apiKey}`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - contents: [{ parts: [{ text: 'Say ok' }] }], - }), - }); - - if (!response.ok) { - const errorPayload: unknown = await response.json().catch(() => null); - return { - success: false, - message: getErrorMessage(errorPayload) || `HTTP ${response.status}`, - }; - } - - return { - success: true, - message: 'Google AI endpoint is working', - latency: Date.now() - startTime, - }; -} - -/** Test Ollama endpoint connectivity. */ -async function testOllamaEndpoint( - baseUrl: string, - model: string, - startTime: number -): Promise { - const response = await fetch(`${baseUrl}/generate`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - model: model, - prompt: 'Say ok', - stream: false, - }), - }); - - if (!response.ok) { - return { - success: false, - message: `HTTP ${response.status} - Is Ollama running?`, - }; - } - - return { - success: true, - message: 'Ollama is responding', - latency: Date.now() - startTime, - }; -} - -/** Test Deepgram endpoint connectivity. */ -async function testDeepgramEndpoint( - baseUrl: string, - apiKey: string, - startTime: number -): Promise { - const response = await fetch(`${baseUrl.replace('/v1', '')}/v1/projects`, { - headers: { - Authorization: `Token ${apiKey}`, - }, - }); - - if (response.ok) { - return { - success: true, - message: 'Deepgram API key is valid', - latency: Date.now() - startTime, - }; - } - - return { - success: false, - message: 'Invalid API key or endpoint', - }; -} - -/** Test ElevenLabs endpoint connectivity. */ -async function testElevenLabsEndpoint( - baseUrl: string, - apiKey: string, - startTime: number -): Promise { - const response = await fetch(`${baseUrl}/user`, { - headers: { - 'xi-api-key': apiKey, - }, - }); - - if (response.ok) { - return { - success: true, - message: 'ElevenLabs API key is valid', - latency: Date.now() - startTime, - }; - } - - return { - success: false, - message: 'Invalid API key', - }; -} - -/** Test custom/unknown endpoint connectivity. */ -async function testCustomEndpoint( - baseUrl: string, - apiKey: string, - startTime: number -): Promise { - const response = await fetch(`${baseUrl}/models`, { - headers: { - Authorization: `Bearer ${apiKey}`, - }, - }); - - if (response.ok) { - return { - success: true, - message: 'Endpoint is responding', - latency: Date.now() - startTime, - }; - } - - return { - success: false, - message: `HTTP ${response.status}`, - }; -} - /** Test endpoint connectivity for the specified AI provider. */ export async function testEndpoint( provider: AIProviderType | TranscriptionProviderType, @@ -267,34 +21,22 @@ export async function testEndpoint( const startTime = Date.now(); try { - switch (provider) { - case 'openai': - case 'whisper': - return await testOpenAIEndpoint(baseUrl, apiKey, model, type, startTime); + const isTranscription = type === 'transcription'; + const strategy = getStrategy(provider, isTranscription); - case 'anthropic': - return await testAnthropicEndpoint(baseUrl, apiKey, model, startTime); - - case 'google': - return await testGoogleEndpoint(baseUrl, apiKey, model, startTime); - - case 'ollama': - return await testOllamaEndpoint(baseUrl, model, startTime); - - case 'deepgram': - return await testDeepgramEndpoint(baseUrl, apiKey, startTime); - - case 'elevenlabs': - return await testElevenLabsEndpoint(baseUrl, apiKey, startTime); - - case 'custom': - return type === 'transcription' - ? await testCustomEndpoint(baseUrl, apiKey, startTime) - : await testOpenAIEndpoint(baseUrl, apiKey, model, type, startTime); - - default: - return await testCustomEndpoint(baseUrl, apiKey, startTime); + if (!strategy) { + // Fall back to custom strategy for unknown providers + const customStrategy = getStrategy('custom'); + if (!customStrategy) { + return { + success: false, + message: 'Unknown provider', + }; + } + return customStrategy.testEndpoint(baseUrl, apiKey, model, type, startTime); } + + return await strategy.testEndpoint(baseUrl, apiKey, model, type, startTime); } catch (error) { // CORS errors are common when testing from browser const errorMessage = extractErrorMessage(error, 'Unknown error'); diff --git a/client/src/lib/async-utils.ts b/client/src/lib/async-utils.ts index 3f879a5..599ab1d 100644 --- a/client/src/lib/async-utils.ts +++ b/client/src/lib/async-utils.ts @@ -34,12 +34,13 @@ export function fireAndForget( const level = options?.level ?? 'warning'; promise.catch((err: unknown) => { const message = err instanceof Error ? err.message : String(err); + const stack = err instanceof Error ? err.stack : undefined; addClientLog({ level, source: 'app', message: `[fire-and-forget] ${label} failed`, - details: message, - metadata: { label, ...options?.metadata }, + details: stack ?? message, // Use stack if available for better debugging + metadata: { label, errorMessage: message, ...options?.metadata }, }); }); } @@ -140,8 +141,8 @@ export class AsyncQueue { level: 'error', source: 'app', message: `[async-queue] ${this.label} operation failed`, - details: error.message, - metadata: { label: this.label, depth: String(this.depth) }, + details: error.stack ?? error.message, // Use stack if available for better debugging + metadata: { label: this.label, depth: String(this.depth), errorMessage: error.message }, }); } finally { this.depth--; @@ -207,10 +208,7 @@ export class StreamingQueue extends AsyncQueue { private handleError(_error: Error, _depth: number): void { this.consecutiveFailures++; - if ( - this.consecutiveFailures >= this.failureThreshold && - !this.hasNotifiedThreshold - ) { + if (this.consecutiveFailures >= this.failureThreshold && !this.hasNotifiedThreshold) { this.hasNotifiedThreshold = true; this.onThresholdReached?.(this.consecutiveFailures); } diff --git a/client/src/lib/audio-device-ids.test.ts b/client/src/lib/audio-device-ids.test.ts index 0a59cb9..81752a6 100644 --- a/client/src/lib/audio-device-ids.test.ts +++ b/client/src/lib/audio-device-ids.test.ts @@ -6,10 +6,7 @@ * the wrong device to be selected. */ import { describe, expect, it } from 'vitest'; -import { - resolveAudioDeviceId, - type AudioDeviceMatchCandidate, -} from './audio-device-ids'; +import { resolveAudioDeviceId, type AudioDeviceMatchCandidate } from './audio-device-ids'; describe('resolveAudioDeviceId', () => { describe('exact match scenarios', () => { diff --git a/client/src/lib/audio-device-ids.ts b/client/src/lib/audio-device-ids.ts index a51a10a..f5be08f 100644 --- a/client/src/lib/audio-device-ids.ts +++ b/client/src/lib/audio-device-ids.ts @@ -90,9 +90,7 @@ export function resolveAudioDeviceId( // and less likely to change than device ID format if (storedDeviceName) { // 2a. Exact label match(es) - const exactLabelMatches = devices.filter( - (device) => device.label === storedDeviceName - ); + const exactLabelMatches = devices.filter((device) => device.label === storedDeviceName); if (exactLabelMatches.length === 1) { return exactLabelMatches[0].deviceId; } @@ -124,9 +122,7 @@ export function resolveAudioDeviceId( } // 3. Fall back to matching by decoded name from device ID - const nameMatches = decodedDevices.filter( - ({ decoded }) => decoded.name === decodedStored.name - ); + const nameMatches = decodedDevices.filter(({ decoded }) => decoded.name === decodedStored.name); if (nameMatches.length === 1) { return nameMatches[0].device.deviceId; } @@ -140,9 +136,7 @@ export function resolveAudioDeviceId( if (normalizedMatches.length === 1) { return normalizedMatches[0].device.deviceId; } - const defaultMatches = normalizedMatches.filter( - ({ device }) => device.isDefault === true - ); + const defaultMatches = normalizedMatches.filter(({ device }) => device.isDefault === true); if (defaultMatches.length === 1) { return defaultMatches[0].device.deviceId; } diff --git a/client/src/lib/audio-device-persistence.integration.test.ts b/client/src/lib/audio-device-persistence.integration.test.ts index 3e6fdd7..b798d5a 100644 --- a/client/src/lib/audio-device-persistence.integration.test.ts +++ b/client/src/lib/audio-device-persistence.integration.test.ts @@ -22,7 +22,9 @@ function getStoredAudioDevices(): { input_device_id: string; output_device_id: s if (!raw) { return { input_device_id: '', output_device_id: '' }; } - const prefs = JSON.parse(raw) as { audio_devices?: { input_device_id?: string; output_device_id?: string } }; + const prefs = JSON.parse(raw) as { + audio_devices?: { input_device_id?: string; output_device_id?: string }; + }; return { input_device_id: prefs.audio_devices?.input_device_id ?? '', output_device_id: prefs.audio_devices?.output_device_id ?? '', diff --git a/client/src/lib/cache/meeting-cache.ts b/client/src/lib/cache/meeting-cache.ts index edbbca4..435fd6e 100644 --- a/client/src/lib/cache/meeting-cache.ts +++ b/client/src/lib/cache/meeting-cache.ts @@ -1,5 +1,6 @@ // Meeting cache for offline read-only mode with TTL-based invalidation // (Sprint GAP-002: State Synchronization) +// Implements debounced localStorage writes for performance. import type { Meeting } from '@/api/types'; import { addClientLog } from '@/lib/client-logs'; @@ -12,6 +13,9 @@ export const MEETING_CACHE_TTL_MS = 30_000; /** Staleness threshold: meetings older than this are considered potentially stale. */ export const STALENESS_THRESHOLD_MS = 30_000; +/** Debounce interval for cache writes (1 second). */ +const CACHE_WRITE_DEBOUNCE_MS = 1000; + interface MeetingCacheData { version: number; updated_at: number; @@ -39,6 +43,11 @@ export type CacheEventListener = (event: CacheEvent) => void; /** Listeners for cache events. */ const cacheListeners = new Set(); +// Debounce state for batched writes +let inMemoryCache: MeetingCacheData | null = null; +let cacheWriteTimeout: ReturnType | null = null; +let isDirty = false; + /** Emit cache event to all listeners. */ const emitCacheEvent = (event: CacheEvent): void => { for (const listener of cacheListeners) { @@ -64,36 +73,54 @@ const emptyCache = (): MeetingCacheData => ({ cached_times: {}, }); +/** + * Load cache from in-memory cache or localStorage. + * In-memory cache is the source of truth during debounce period. + */ const loadCache = (): MeetingCacheData => { + // Return in-memory cache if available (source of truth during debounce) + if (inMemoryCache !== null) { + return inMemoryCache; + } + if (typeof window === 'undefined') { return emptyCache(); } try { const stored = localStorage.getItem(MEETING_CACHE_KEY); if (!stored) { - return emptyCache(); + inMemoryCache = emptyCache(); + return inMemoryCache; } const storedCache = JSON.parse(stored) as MeetingCacheData; if (storedCache?.version !== CACHE_VERSION) { - return emptyCache(); + inMemoryCache = emptyCache(); + return inMemoryCache; } - return { + inMemoryCache = { ...storedCache, meetings: storedCache.meetings ?? {}, meeting_ids: Array.isArray(storedCache.meeting_ids) ? storedCache.meeting_ids : [], cached_times: storedCache.cached_times ?? {}, }; + return inMemoryCache; } catch { - return emptyCache(); + inMemoryCache = emptyCache(); + return inMemoryCache; } }; -const saveCache = (cache: MeetingCacheData): void => { - if (typeof window === 'undefined') { +/** + * Persist cache to localStorage. + * Called by debounce flush and beforeunload handler. + */ +const persistCache = (): void => { + if (typeof window === 'undefined' || inMemoryCache === null) { return; } try { - localStorage.setItem(MEETING_CACHE_KEY, JSON.stringify(cache)); + localStorage.setItem(MEETING_CACHE_KEY, JSON.stringify(inMemoryCache)); + isDirty = false; } catch (error) { addClientLog({ level: 'warning', @@ -105,6 +132,37 @@ const saveCache = (cache: MeetingCacheData): void => { } }; +/** + * Flush pending cache changes to localStorage. + * Called by debounce timeout and beforeunload handler. + */ +const flushCache = (): void => { + cacheWriteTimeout = null; + + if (!isDirty) { + return; + } + + persistCache(); +}; + +/** + * Update the in-memory cache and schedule a debounced persist. + */ +const saveCache = (cache: MeetingCacheData): void => { + inMemoryCache = cache; + isDirty = true; + + if (typeof window === 'undefined') { + return; + } + + // Schedule debounced write if not already scheduled + if (cacheWriteTimeout === null) { + cacheWriteTimeout = setTimeout(flushCache, CACHE_WRITE_DEBOUNCE_MS); + } +}; + const mergeMeeting = (existing: Meeting | undefined, incoming: Meeting): Meeting => { if (!existing) { return incoming; @@ -284,7 +342,14 @@ export const meetingCache = { * Clear all cached data. */ clear(): void { - saveCache(emptyCache()); + // Cancel any pending write + if (cacheWriteTimeout !== null) { + clearTimeout(cacheWriteTimeout); + cacheWriteTimeout = null; + } + isDirty = false; + inMemoryCache = emptyCache(); + persistCache(); emitCacheEvent({ type: 'invalidated', reason: 'clear' }); }, @@ -330,3 +395,8 @@ export const meetingCache = { }; }, }; + +// Flush cache on page unload to prevent data loss +if (typeof window !== 'undefined') { + window.addEventListener('beforeunload', flushCache); +} diff --git a/client/src/lib/client-log-events.ts b/client/src/lib/client-log-events.ts index 251132b..ff84e6a 100644 --- a/client/src/lib/client-log-events.ts +++ b/client/src/lib/client-log-events.ts @@ -58,7 +58,10 @@ export const clientLog = { emit('info', 'app', 'Summary generated', { meeting_id: meetingId, model: model ?? '' }); }, summaryFailed(meetingId: string, error?: string): void { - emit('error', 'app', 'Summary generation failed', { meeting_id: meetingId, error: error ?? '' }); + emit('error', 'app', 'Summary generation failed', { + meeting_id: meetingId, + error: error ?? '', + }); }, // Cloud Consent @@ -82,10 +85,18 @@ export const clientLog = { // Diarization diarizationStarted(meetingId: string, jobId: string): void { - emit('info', 'app', 'Diarization job', { meeting_id: meetingId, job_id: jobId, status: 'running' }); + emit('info', 'app', 'Diarization job', { + meeting_id: meetingId, + job_id: jobId, + status: 'running', + }); }, diarizationCompleted(meetingId: string, jobId: string): void { - emit('info', 'app', 'Diarization job', { meeting_id: meetingId, job_id: jobId, status: 'completed' }); + emit('info', 'app', 'Diarization job', { + meeting_id: meetingId, + job_id: jobId, + status: 'completed', + }); }, diarizationFailed(meetingId: string, jobId: string, error?: string): void { emit('error', 'app', 'Diarization job', { @@ -96,7 +107,11 @@ export const clientLog = { }); }, speakerRenamed(meetingId: string, oldName: string, newName: string): void { - emit('info', 'app', 'Speaker renamed', { meeting_id: meetingId, old_name: oldName, new_name: newName }); + emit('info', 'app', 'Speaker renamed', { + meeting_id: meetingId, + old_name: oldName, + new_name: newName, + }); }, // Entity Extraction diff --git a/client/src/lib/client-logs.ts b/client/src/lib/client-logs.ts index 8381761..6892893 100644 --- a/client/src/lib/client-logs.ts +++ b/client/src/lib/client-logs.ts @@ -1,4 +1,5 @@ // Client-side log buffer for Analytics/Logs merge. +// Implements debounced localStorage writes for performance. import type { LogLevel, LogSource } from '@/api/types'; import { CLIENT_LOGS_KEY } from '@/lib/storage-keys'; @@ -17,19 +18,33 @@ export interface ClientLogEntry { } const MAX_ENTRIES = 500; +const LOG_WRITE_DEBOUNCE_MS = 500; + const listeners = new Set<(logs: ClientLogEntry[]) => void>(); +// Debounce state for batched writes +let pendingLogs: ClientLogEntry[] = []; +let writeTimeout: ReturnType | null = null; +let cachedLogs: ClientLogEntry[] | null = null; + function loadLogs(): ClientLogEntry[] { if (typeof window === 'undefined') { return []; } + + // Return cached logs if available (during debounce period) + if (cachedLogs !== null) { + return cachedLogs; + } + try { const stored = localStorage.getItem(CLIENT_LOGS_KEY); if (!stored) { return []; } const storedLogs = JSON.parse(stored) as ClientLogEntry[]; - return Array.isArray(storedLogs) ? storedLogs : []; + cachedLogs = Array.isArray(storedLogs) ? storedLogs : []; + return cachedLogs; } catch { return []; } @@ -39,19 +54,46 @@ function saveLogs(logs: ClientLogEntry[]): void { if (typeof window === 'undefined') { return; } + cachedLogs = logs; localStorage.setItem(CLIENT_LOGS_KEY, JSON.stringify(logs)); +} + +function notifyListeners(logs: ClientLogEntry[]): void { for (const listener of listeners) { listener(logs); } } +/** + * Flush pending logs to localStorage immediately. + * Called by debounce timeout and beforeunload handler. + */ +function flushLogs(): void { + writeTimeout = null; + + if (pendingLogs.length === 0) { + return; + } + + const existing = loadLogs(); + // Prepend pending logs (newest first) and trim to max + const merged = [...pendingLogs.reverse(), ...existing].slice(0, MAX_ENTRIES); + pendingLogs = []; + saveLogs(merged); +} + export function getClientLogs(): ClientLogEntry[] { + // Include pending logs in the result for consistency + if (pendingLogs.length > 0) { + const existing = loadLogs(); + return [...pendingLogs.reverse(), ...existing].slice(0, MAX_ENTRIES); + } return loadLogs(); } export function subscribeClientLogs(listener: (logs: ClientLogEntry[]) => void): () => void { listeners.add(listener); - listener(loadLogs()); + listener(getClientLogs()); return () => listeners.delete(listener); } @@ -59,17 +101,41 @@ export function addClientLog(entry: Omit { export function isSecureStorageAvailable(): boolean { return Boolean(window.crypto?.subtle && window.crypto.getRandomValues); } + +/** + * Export all secure credentials as an encrypted backup string. + * + * The backup is encrypted with the user-provided passphrase, making it + * portable across devices or after localStorage is cleared. + * + * @param passphrase - User-provided passphrase for backup encryption + * @returns Base64-encoded encrypted backup string + * @throws Error if no credentials exist or encryption fails + */ +export async function exportCredentialsBackup(passphrase: string): Promise { + // Load current secure data + const encryptedStore = localStorage.getItem(SECURE_DATA_KEY); + if (!encryptedStore) { + throw new Error('No credentials to export'); + } + + const decrypted = await decryptData(encryptedStore); + if (!decrypted) { + throw new Error('Cannot decrypt current credentials - key mismatch'); + } + + // Validate the decrypted data + const secureData: unknown = JSON.parse(decrypted); + if (!isSecureData(secureData)) { + throw new Error('Secure data is corrupted'); + } + + // Generate a new salt specifically for the backup + const backupSalt = crypto.getRandomValues(new Uint8Array(CryptoConfig.SALT_LENGTH)); + const backupKey = await deriveKey(passphrase, backupSalt.buffer as ArrayBuffer); + + // Encrypt the secure data with the backup key + const encoder = new TextEncoder(); + const iv = crypto.getRandomValues(new Uint8Array(CryptoConfig.IV_LENGTH)); + + const encryptedData = await crypto.subtle.encrypt( + { name: ALGORITHM, iv }, + backupKey, + encoder.encode(decrypted) + ); + + // Package: salt (16 bytes) + iv (12 bytes) + encrypted data + const combined = new Uint8Array(backupSalt.length + iv.length + encryptedData.byteLength); + combined.set(backupSalt, 0); + combined.set(iv, backupSalt.length); + combined.set(new Uint8Array(encryptedData), backupSalt.length + iv.length); + + // Return as base64 for easy copy/paste + return btoa(String.fromCharCode(...combined)); +} + +/** + * Import credentials from an encrypted backup string. + * + * @param backup - Base64-encoded encrypted backup from exportCredentialsBackup + * @param passphrase - The passphrase used during export + * @throws Error if decryption fails or backup format is invalid + */ +export async function importCredentialsBackup(backup: string, passphrase: string): Promise { + // Decode from base64 + const combined = new Uint8Array( + atob(backup) + .split('') + .map((c) => c.charCodeAt(0)) + ); + + // Minimum size: salt (16) + iv (12) + some data + const minSize = CryptoConfig.SALT_LENGTH + CryptoConfig.IV_LENGTH + 1; + if (combined.length < minSize) { + throw new Error('Invalid backup format - data too short'); + } + + // Extract components + const backupSalt = combined.slice(0, CryptoConfig.SALT_LENGTH); + const iv = combined.slice( + CryptoConfig.SALT_LENGTH, + CryptoConfig.SALT_LENGTH + CryptoConfig.IV_LENGTH + ); + const encryptedData = combined.slice(CryptoConfig.SALT_LENGTH + CryptoConfig.IV_LENGTH); + + // Derive key from passphrase and backup salt + const backupKey = await deriveKey(passphrase, backupSalt.buffer as ArrayBuffer); + + // Decrypt + let decrypted: string; + try { + const decryptedData = await crypto.subtle.decrypt( + { name: ALGORITHM, iv }, + backupKey, + encryptedData + ); + decrypted = new TextDecoder().decode(decryptedData); + } catch { + throw new Error('Decryption failed - incorrect passphrase'); + } + + // Validate the decrypted data + let secureData: unknown; + try { + secureData = JSON.parse(decrypted); + } catch { + throw new Error('Invalid backup format - corrupted data'); + } + + if (!isSecureData(secureData)) { + throw new Error('Invalid backup format - not valid secure data'); + } + + // Re-encrypt with the current device key and store + const encrypted = await encryptData(decrypted); + localStorage.setItem(SECURE_DATA_KEY, encrypted); + + addClientLog({ + level: 'info', + source: 'system', + message: 'Credentials imported from backup successfully', + }); +} diff --git a/client/src/lib/download-utils.ts b/client/src/lib/download-utils.ts new file mode 100644 index 0000000..33c99e8 --- /dev/null +++ b/client/src/lib/download-utils.ts @@ -0,0 +1,32 @@ +import type { ExportFormat, ExportResult } from '@/api/types'; + +export function buildExportBlob(format: ExportFormat | 'json', data: string | ExportResult): Blob { + const content = typeof data === 'string' ? data : data.content; + + if (format === 'pdf') { + const binaryString = atob(content); + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + return new Blob([bytes], { type: 'application/pdf' }); + } + + const mimeType = + format === 'html' ? 'text/html' : + format === 'json' ? 'application/json' : + 'text/markdown'; + + return new Blob([content], { type: mimeType }); +} + +export function downloadBlob(blob: Blob, filename: string): void { + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + setTimeout(() => URL.revokeObjectURL(url), 1000); +} \ No newline at end of file diff --git a/client/src/lib/entity-store.test.ts b/client/src/lib/entity-store.test.ts index b5a5640..0039d53 100644 --- a/client/src/lib/entity-store.test.ts +++ b/client/src/lib/entity-store.test.ts @@ -4,14 +4,14 @@ import type { ExtractedEntity } from '@/api/types'; import type { Entity, EntityCategory } from '@/types/entity'; // Mock the API module before importing entity-store -vi.mock('@/api', () => ({ +vi.mock('@/api/interface', () => ({ getAPI: vi.fn(() => ({ updateEntity: vi.fn(), deleteEntity: vi.fn(), })), })); -import { getAPI } from '@/api'; +import { getAPI } from '@/api/interface'; // Must import after setting up any mocks import { addEntity, diff --git a/client/src/lib/entity-store.ts b/client/src/lib/entity-store.ts index 3907b5f..f764c3d 100644 --- a/client/src/lib/entity-store.ts +++ b/client/src/lib/entity-store.ts @@ -1,8 +1,9 @@ // Entity store for NER-extracted entities from meeting transcripts -import { getAPI } from '@/api'; +import { getAPI } from '@/api/interface'; import type { ExtractedEntity } from '@/api/types'; import type { Entity, EntityCategory } from '@/types/entity'; +import { generateUuid } from '@/lib/id-utils'; // Entity store - populated by backend extraction const entityStore: Entity[] = []; @@ -139,7 +140,7 @@ export function addEntity(entity: Omit): Entity { bumpStoreVersion(); const newEntity: Entity = { ...entity, - id: crypto.randomUUID(), + id: generateUuid(), extractedAt: new Date(), }; entityStore.push(newEntity); diff --git a/client/src/lib/event-emitter.ts b/client/src/lib/event-emitter.ts index 7cdf971..6139067 100644 --- a/client/src/lib/event-emitter.ts +++ b/client/src/lib/event-emitter.ts @@ -120,10 +120,7 @@ export function createEventEmitter(context?: string): EventEmitter { export function createMultiEventEmitter>( context?: string ): { - on: ( - event: K, - listener: EventListener - ) => Unsubscribe; + on: (event: K, listener: EventListener) => Unsubscribe; emit: (event: K, payload: EventMap[K]) => void; off: (event: K) => void; clear: () => void; @@ -132,7 +129,10 @@ export function createMultiEventEmitter const getEmitter = (event: K): EventEmitter => { if (!emitters.has(event)) { - emitters.set(event, createEventEmitter(context ? `${context}:${String(event)}` : String(event))); + emitters.set( + event, + createEventEmitter(context ? `${context}:${String(event)}` : String(event)) + ); } return emitters.get(event) as EventEmitter; }; diff --git a/client/src/lib/id-utils.ts b/client/src/lib/id-utils.ts new file mode 100644 index 0000000..24bb051 --- /dev/null +++ b/client/src/lib/id-utils.ts @@ -0,0 +1,15 @@ +const UUID_TEMPLATE = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'; +const UUID_PATTERN = /[xy]/g; + +export function generateUuid(): string { + const cryptoRef = globalThis.crypto; + if (cryptoRef?.randomUUID) { + return cryptoRef.randomUUID(); + } + + return UUID_TEMPLATE.replace(UUID_PATTERN, (char) => { + const random = Math.floor(Math.random() * 16); + const value = char === 'x' ? random : (random & 0x3) | 0x8; + return value.toString(16); + }); +} diff --git a/client/src/lib/log-groups.test.ts b/client/src/lib/log-groups.test.ts index 1dda501..3699636 100644 --- a/client/src/lib/log-groups.test.ts +++ b/client/src/lib/log-groups.test.ts @@ -55,9 +55,7 @@ describe('groupLogs', () => { }); it('uses meeting title in label when available', () => { - const logs = [ - createLog({ metadata: { entity_id: 'meeting-1', title: 'Q4 Planning' } }), - ]; + const logs = [createLog({ metadata: { entity_id: 'meeting-1', title: 'Q4 Planning' } })]; const result = groupLogs(logs, 'meeting'); expect(result[0].label).toBe('Meeting: Q4 Planning'); }); diff --git a/client/src/lib/log-groups.ts b/client/src/lib/log-groups.ts index 0f63da3..f2b7278 100644 --- a/client/src/lib/log-groups.ts +++ b/client/src/lib/log-groups.ts @@ -237,7 +237,9 @@ function groupByTime(logs: readonly LogEntryData[]): LogGroup[] { // Don't forget the last group if (currentGroup.length > 0) { const summary = summarizeLogGroup(currentGroup); - groups.push(createGroup(`time-${groupIndex}`, 'time', summary.text || 'Activity', currentGroup)); + groups.push( + createGroup(`time-${groupIndex}`, 'time', summary.text || 'Activity', currentGroup) + ); } return groups; diff --git a/client/src/lib/log-messages.test.ts b/client/src/lib/log-messages.test.ts index f255646..b7c174b 100644 --- a/client/src/lib/log-messages.test.ts +++ b/client/src/lib/log-messages.test.ts @@ -102,7 +102,10 @@ describe('toFriendlyMessage', () => { }); it('transforms "Speaker renamed"', () => { - const result = toFriendlyMessage('Speaker renamed', { old_name: 'Speaker 1', new_name: 'Alice' }); + const result = toFriendlyMessage('Speaker renamed', { + old_name: 'Speaker 1', + new_name: 'Alice', + }); expect(result).toBe('Renamed speaker "Speaker 1" to "Alice"'); }); }); diff --git a/client/src/lib/log-messages.ts b/client/src/lib/log-messages.ts index 2cee431..a86ba5c 100644 --- a/client/src/lib/log-messages.ts +++ b/client/src/lib/log-messages.ts @@ -42,7 +42,7 @@ const MESSAGE_TEMPLATES: Record = { const model = d.model || d.provider; return model ? `Summary ready (${model})` : 'Summary generated successfully'; }, - 'summarizing': (d) => { + summarizing: (d) => { const count = d.segment_count || d.segments; return count ? `Generating summary from ${count} segments...` : 'Generating summary...'; }, @@ -53,7 +53,7 @@ const MESSAGE_TEMPLATES: Record = { // Transcription 'segment processed': (d) => { - const {text} = d; + const { text } = d; if (text && text.length > 50) { return `Transcribed: "${text.substring(0, 50)}..."`; } @@ -70,7 +70,7 @@ const MESSAGE_TEMPLATES: Record = { // Diarization 'diarization job': (d) => { - const {status} = d; + const { status } = d; if (status === 'completed') { return 'Speaker identification complete'; } @@ -82,7 +82,7 @@ const MESSAGE_TEMPLATES: Record = { } return 'Speaker identification in progress'; }, - 'refinespeak': () => 'Refining speaker labels...', + refinespeak: () => 'Refining speaker labels...', 'speaker renamed': (d) => { const from = d.old_name || d.from; const to = d.new_name || d.to; @@ -121,7 +121,7 @@ const MESSAGE_TEMPLATES: Record = { // Calendar 'calendar sync': (d) => { - const {provider} = d; + const { provider } = d; const count = d.count || d.events_synced; if (count) { return `Synced ${count} calendar events${provider ? ` from ${provider}` : ''}`; @@ -130,7 +130,7 @@ const MESSAGE_TEMPLATES: Record = { }, // Entity extraction - 'extracted': (d) => { + extracted: (d) => { const count = d.count || d.entity_count; return count ? `Extracted ${count} entities from transcript` : 'Entities extracted'; }, @@ -145,19 +145,19 @@ const MESSAGE_TEMPLATES: Record = { // Connection 'connection timeout': () => 'Server connection timed out', 'connection failed': () => 'Failed to connect to server', - 'connected': () => 'Connected to server', - 'disconnected': () => 'Disconnected from server', + connected: () => 'Connected to server', + disconnected: () => 'Disconnected from server', // Server 'server started': (d) => { - const {port} = d; + const { port } = d; return port ? `Server running on port ${port}` : 'Server started'; }, 'server stopped': () => 'Server stopped', // RPC operations (generic) rpc: (d) => { - const {method, status} = d; + const { method, status } = d; if (method && status === 'OK') { const simpleName = method.split('/').pop() || method; return `${simpleName} completed`; @@ -207,7 +207,7 @@ const MESSAGE_TEMPLATES: Record = { 'reconnection callback execution failed': () => 'Reconnection partially completed', 'state sync after reconnect failed': () => 'Sync incomplete after reconnecting', 'scheduled reconnect attempt failed': (d) => { - const {attempt} = d; + const { attempt } = d; return attempt ? `Reconnection attempt ${attempt} failed` : 'Reconnection attempt failed'; }, 'online event reconnect failed': () => 'Could not reconnect when network came online', @@ -216,7 +216,8 @@ const MESSAGE_TEMPLATES: Record = { // Security and encryption 'decryption failed': () => 'Could not decrypt saved data - may need to re-enter API keys', 'failed to retrieve secure value': () => 'Could not load secure setting', - 'secure storage migration failed': () => 'Secure storage needs recovery - some settings may be lost', + 'secure storage migration failed': () => + 'Secure storage needs recovery - some settings may be lost', 'secure storage migrated': () => 'Secure storage upgraded successfully', 'secure storage key mismatch': () => 'Secure storage needs recovery', 'failed to decrypt api keys': () => 'Could not load saved API keys - please re-enter them', @@ -315,10 +316,13 @@ function cleanupTechnicalMessage(message: string): string { // Convert method-style prefixes to readable text // e.g., "getPreferences: received" → "Preferences received" - cleaned = cleaned.replace(/^(get|set|load|save|fetch|create|delete|update)([A-Z][a-zA-Z]*):\s*/i, (_, _verb, noun) => { - const readableNoun = noun.replace(/([A-Z])/g, ' $1').trim(); - return `${readableNoun} `; - }); + cleaned = cleaned.replace( + /^(get|set|load|save|fetch|create|delete|update)([A-Z][a-zA-Z]*):\s*/i, + (_, _verb, noun) => { + const readableNoun = noun.replace(/([A-Z])/g, ' $1').trim(); + return `${readableNoun} `; + } + ); // Convert "ENTRY" and similar debug markers to more readable text cleaned = cleaned.replace(/:\s*ENTRY$/i, ' started'); diff --git a/client/src/lib/log-sanitizer.ts b/client/src/lib/log-sanitizer.ts new file mode 100644 index 0000000..34f9c5f --- /dev/null +++ b/client/src/lib/log-sanitizer.ts @@ -0,0 +1,83 @@ +const REDACTED_VALUE = '[REDACTED]'; +const CIRCULAR_VALUE = '[CIRCULAR]'; +const TRUNCATED_VALUE = '[TRUNCATED]'; +const MAX_DEPTH = 6; + +const SENSITIVE_KEY_PATTERNS: readonly RegExp[] = [ + /api[-_]?key/i, + /access[-_]?key/i, + /client[-_]?secret/i, + /private[-_]?key/i, + /secret/i, + /token/i, + /authorization/i, + /password/i, + /passwd/i, + /refresh[-_]?token/i, + /access[-_]?token/i, + /id[-_]?token/i, + /session/i, + /cookie/i, + /bearer/i, +]; + +function isSensitiveKey(key: string): boolean { + return SENSITIVE_KEY_PATTERNS.some((pattern) => pattern.test(key)); +} + +function sanitizeValue(value: unknown, depth: number, seen: WeakSet): unknown { + if (depth > MAX_DEPTH) { + return TRUNCATED_VALUE; + } + + if (Array.isArray(value)) { + return value.map((entry) => sanitizeValue(entry, depth + 1, seen)); + } + + if (value && typeof value === 'object') { + if (value instanceof Date) { + return value.toISOString(); + } + + if (seen.has(value)) { + return CIRCULAR_VALUE; + } + + seen.add(value); + const result: Record = {}; + for (const [key, entry] of Object.entries(value)) { + result[key] = isSensitiveKey(key) + ? REDACTED_VALUE + : sanitizeValue(entry, depth + 1, seen); + } + return result; + } + + return value; +} + +export function sanitizeLogMetadata( + metadata?: Record +): Record | undefined { + if (!metadata) { + return undefined; + } + + const sanitized = sanitizeValue(metadata, 0, new WeakSet()); + if (sanitized && typeof sanitized === 'object' && !Array.isArray(sanitized)) { + return sanitized as Record; + } + + return undefined; +} + +export function sanitizeLogEntry }>(entry: T): T { + if (!entry.metadata) { + return entry; + } + + return { + ...entry, + metadata: sanitizeLogMetadata(entry.metadata), + }; +} diff --git a/client/src/lib/log-summarizer.test.ts b/client/src/lib/log-summarizer.test.ts index 7a80c9f..de0cdc6 100644 --- a/client/src/lib/log-summarizer.test.ts +++ b/client/src/lib/log-summarizer.test.ts @@ -195,10 +195,7 @@ describe('summarizeConsecutive', () => { describe('minGroupSize config', () => { it('only marks as group when count meets minGroupSize', () => { const now = Date.now(); - const logs = [ - createLog('1', 'Event', now), - createLog('2', 'Event', now + 100), - ]; + const logs = [createLog('1', 'Event', now), createLog('2', 'Event', now + 100)]; const result = summarizeConsecutive(logs, { minGroupSize: 3 }); @@ -210,10 +207,7 @@ describe('summarizeConsecutive', () => { it('respects custom minGroupSize', () => { const now = Date.now(); - const logs = [ - createLog('1', 'Event', now), - createLog('2', 'Event', now + 100), - ]; + const logs = [createLog('1', 'Event', now), createLog('2', 'Event', now + 100)]; const result = summarizeConsecutive(logs, { minGroupSize: 2 }); @@ -291,10 +285,7 @@ describe('expandAllGroups', () => { }); it('expands mixed grouped and non-grouped entries', () => { - const groupedLogs = [ - createLog('1', 'Segment', 1000), - createLog('2', 'Segment', 1100), - ]; + const groupedLogs = [createLog('1', 'Segment', 1000), createLog('2', 'Segment', 1100)]; const singleLog = createLog('3', 'Meeting', 2000); const summarized = [ @@ -346,10 +337,7 @@ describe('getSummarizationStats', () => { }); it('calculates correct stats for non-grouped logs', () => { - const logs = [ - createLog('1', 'Event 1', 1000), - createLog('2', 'Event 2', 2000), - ]; + const logs = [createLog('1', 'Event 1', 1000), createLog('2', 'Event 2', 2000)]; const summarized = logs.map((log) => ({ log, count: 1, isGroup: false })); const stats = getSummarizationStats(summarized); @@ -366,9 +354,7 @@ describe('getSummarizationStats', () => { createLog('2', 'Segment', 1100), createLog('3', 'Segment', 1200), ]; - const summarized = [ - { log: logs[0], count: 3, isGroup: true, groupedLogs: logs }, - ]; + const summarized = [{ log: logs[0], count: 3, isGroup: true, groupedLogs: logs }]; const stats = getSummarizationStats(summarized); diff --git a/client/src/lib/polling-utils.ts b/client/src/lib/polling-utils.ts new file mode 100644 index 0000000..4d7f685 --- /dev/null +++ b/client/src/lib/polling-utils.ts @@ -0,0 +1,324 @@ +/** + * Polling utilities with proper cancellation and overlap prevention. + * + * Solves common polling bugs: + * - Overlapping requests when async work exceeds poll interval + * - Ghost updates after stop/reset when in-flight call completes + * - Stale closure issues with setInterval + * + * Uses setTimeout recursion + generation token pattern for correctness. + */ + +import { useCallback, useRef } from 'react'; + +/** + * Options for creating a poller. + */ +export interface PollerOptions { + /** Initial poll interval in milliseconds. */ + intervalMs: number; + /** Backoff multiplier for retry/progressive delay (default: 1 = no backoff). */ + backoffMultiplier?: number; + /** Maximum interval in milliseconds after backoff. */ + maxIntervalMs?: number; + /** Maximum poll duration before auto-stop (optional). */ + maxDurationMs?: number; +} + +/** + * Result from usePoller hook. + */ +export interface UsePollerReturn { + /** Start polling with given async function. */ + start: (pollFn: () => Promise) => void; + /** Stop polling and cancel any in-flight operations. */ + stop: () => void; + /** Whether polling is currently active. */ + isPollingRef: React.RefObject; + /** Apply backoff to current interval. */ + applyBackoff: () => void; + /** Reset interval to initial value. */ + resetInterval: () => void; +} + +/** + * Hook for safe async polling with proper cancellation. + * + * Features: + * - Generation token prevents ghost updates after stop + * - In-flight guard prevents overlapping requests + * - setTimeout recursion ensures sequential execution + * - Supports backoff and max duration + * + * @example + * ```ts + * const { start, stop } = usePoller({ intervalMs: 1000 }); + * + * const pollStatus = useCallback(async () => { + * const status = await api.getStatus(jobId); + * if (status === 'completed') return false; // Stop polling + * return true; // Continue + * }, [jobId]); + * + * // Start polling + * start(pollStatus); + * + * // Stop when done or on unmount + * stop(); + * ``` + */ +export function usePoller(options: PollerOptions): UsePollerReturn { + const { + intervalMs, + backoffMultiplier = 1, + maxIntervalMs = intervalMs * 10, + maxDurationMs, + } = options; + + // Generation token - incremented on every stop to invalidate in-flight polls + const generationRef = useRef(0); + // In-flight guard prevents overlapping requests + const inFlightRef = useRef(false); + // Timer ref for cleanup + const timerRef = useRef | null>(null); + // Current interval (for backoff) + const currentIntervalRef = useRef(intervalMs); + // Poll start time (for max duration) + const startTimeRef = useRef(null); + // Whether polling is active + const isPollingRef = useRef(false); + + const stop = useCallback(() => { + generationRef.current++; + isPollingRef.current = false; + inFlightRef.current = false; + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + currentIntervalRef.current = intervalMs; + startTimeRef.current = null; + }, [intervalMs]); + + const applyBackoff = useCallback(() => { + currentIntervalRef.current = Math.min( + currentIntervalRef.current * backoffMultiplier, + maxIntervalMs + ); + }, [backoffMultiplier, maxIntervalMs]); + + const resetInterval = useCallback(() => { + currentIntervalRef.current = intervalMs; + }, [intervalMs]); + + const start = useCallback( + (pollFn: () => Promise) => { + // Stop any existing polling + stop(); + + // Start new generation + const generation = ++generationRef.current; + isPollingRef.current = true; + startTimeRef.current = Date.now(); + + const tick = async () => { + // Check if this generation is still valid + if (generation !== generationRef.current) { + return; + } + + // Check max duration + if (maxDurationMs && startTimeRef.current !== null) { + const elapsed = Date.now() - startTimeRef.current; + if (elapsed > maxDurationMs) { + stop(); + return; + } + } + + // In-flight guard - skip if previous poll still running + if (inFlightRef.current) { + // Schedule next tick without executing + timerRef.current = setTimeout(tick, currentIntervalRef.current); + return; + } + + inFlightRef.current = true; + let shouldContinue = true; + + try { + const result = await pollFn(); + // If pollFn returns false, stop polling + if (result === false) { + shouldContinue = false; + } + } finally { + inFlightRef.current = false; + } + + // Check generation again after async work + if (generation !== generationRef.current) { + return; + } + + if (shouldContinue) { + timerRef.current = setTimeout(tick, currentIntervalRef.current); + } else { + stop(); + } + }; + + // Start first tick immediately or with initial delay + timerRef.current = setTimeout(tick, 0); + }, + [maxDurationMs, stop] + ); + + return { + start, + stop, + isPollingRef, + applyBackoff, + resetInterval, + }; +} + +/** + * Non-hook version for use in class components or outside React. + * Returns a poller controller object. + */ +export function createPoller(options: PollerOptions): { + start: (pollFn: () => Promise) => void; + stop: () => void; + isPolling: () => boolean; + applyBackoff: () => void; + resetInterval: () => void; +} { + const { + intervalMs, + backoffMultiplier = 1, + maxIntervalMs = intervalMs * 10, + maxDurationMs, + } = options; + + let generation = 0; + let inFlight = false; + let timer: ReturnType | null = null; + let currentInterval = intervalMs; + let startTime: number | null = null; + let polling = false; + + const stop = () => { + generation++; + polling = false; + inFlight = false; + if (timer) { + clearTimeout(timer); + timer = null; + } + currentInterval = intervalMs; + startTime = null; + }; + + const applyBackoff = () => { + currentInterval = Math.min(currentInterval * backoffMultiplier, maxIntervalMs); + }; + + const resetInterval = () => { + currentInterval = intervalMs; + }; + + const start = (pollFn: () => Promise) => { + stop(); + + const myGeneration = ++generation; + polling = true; + startTime = Date.now(); + + const tick = async () => { + if (myGeneration !== generation) { + return; + } + + if (maxDurationMs && startTime !== null) { + const elapsed = Date.now() - startTime; + if (elapsed > maxDurationMs) { + stop(); + return; + } + } + + if (inFlight) { + timer = setTimeout(tick, currentInterval); + return; + } + + inFlight = true; + let shouldContinue = true; + + try { + const result = await pollFn(); + if (result === false) { + shouldContinue = false; + } + } finally { + inFlight = false; + } + + if (myGeneration !== generation) { + return; + } + + if (shouldContinue) { + timer = setTimeout(tick, currentInterval); + } else { + stop(); + } + }; + + timer = setTimeout(tick, 0); + }; + + return { + start, + stop, + isPolling: () => polling, + applyBackoff, + resetInterval, + }; +} + +/** + * Creates an in-flight guard for a single async operation. + * Prevents overlapping calls to the same async function. + * + * @example + * ```ts + * const guard = createInFlightGuard(); + * + * setInterval(() => { + * guard.run(async () => { + * await longRunningOperation(); + * }); + * }, 1000); + * ``` + */ +export function createInFlightGuard(): { + run: (fn: () => Promise) => void; + isRunning: () => boolean; +} { + let inFlight = false; + + return { + run: (fn: () => Promise) => { + if (inFlight) { + return; + } + inFlight = true; + fn().finally(() => { + inFlight = false; + }); + }, + isRunning: () => inFlight, + }; +} diff --git a/client/src/lib/preferences-sync.ts b/client/src/lib/preferences-sync.ts index af45edd..f4480e6 100644 --- a/client/src/lib/preferences-sync.ts +++ b/client/src/lib/preferences-sync.ts @@ -7,6 +7,7 @@ import type { UserPreferences } from '@/api/types'; import { extractErrorMessage } from '@/api/helpers'; import { addClientLog } from '@/lib/client-logs'; import { preferences } from '@/lib/preferences'; +import { LOCAL_ONLY_PREFERENCE_KEYS } from '@/lib/preferences/local-only-keys'; import { PREFERENCES_SYNC_META_KEY } from '@/lib/storage-keys'; export type PreferencesSyncStatus = @@ -45,11 +46,6 @@ interface SetPreferencesResult { const listeners = new Set<(meta: PreferencesSyncMeta) => void>(); let suppressNextPush = false; -const LOCAL_ONLY_PREFERENCE_KEYS = new Set([ - 'default_export_location', - 'audio_devices', -]); - const defaultMeta: PreferencesSyncMeta = { status: 'idle', etag: null, diff --git a/client/src/lib/preferences/local-only-keys.ts b/client/src/lib/preferences/local-only-keys.ts new file mode 100644 index 0000000..3bc3576 --- /dev/null +++ b/client/src/lib/preferences/local-only-keys.ts @@ -0,0 +1,30 @@ +/** + * Centralized definition of local-only preference keys. + * + * These preference fields are: + * - Never synced to the server + * - Preserved during Tauri disk hydration + * + * Having a single source of truth ensures consistency between + * preferences-sync.ts (server sync exclusion) and tauri.ts (disk hydration). + */ +import type { UserPreferences } from '@/api/types'; + +/** + * Set of preference keys that should remain local-only. + * These are excluded from server sync and preserved during Tauri hydration. + */ +export const LOCAL_ONLY_PREFERENCE_KEYS = new Set([ + 'audio_devices', + 'default_export_location', +]); + +/** + * Type guard to check if a key is a local-only preference key. + * + * @param key - The preference key to check + * @returns true if the key is local-only + */ +export function isLocalOnlyKey(key: string): key is keyof UserPreferences { + return LOCAL_ONLY_PREFERENCE_KEYS.has(key as keyof UserPreferences); +} diff --git a/client/src/lib/preferences/tauri.ts b/client/src/lib/preferences/tauri.ts index 2e48d69..862683f 100644 --- a/client/src/lib/preferences/tauri.ts +++ b/client/src/lib/preferences/tauri.ts @@ -8,6 +8,7 @@ import { addClientLog } from '@/lib/client-logs'; import { debug } from '@/lib/debug'; import { arePreferencesEqual, isTauriRuntime } from './core'; +import { isLocalOnlyKey } from './local-only-keys'; import { loadPreferences } from './storage'; import { emitValidationEvent } from './validation-events'; @@ -204,16 +205,15 @@ export async function hydratePreferencesFromTauri( // NEVER overwrite existing user selections. // Hydration merges Tauri values for NON-LOCAL-ONLY preferences. - // Audio devices are LOCAL-ONLY with respect to server sync, but in Tauri - // runtime we treat the Tauri disk as the authoritative local store to - // avoid UI/backend drift between localStorage and preferences.json. + // Local-only keys (audio_devices, default_export_location) are preserved + // from localStorage to avoid overwriting user's local settings with Tauri disk values. const mergedPrefs: UserPreferences = { ...localPrefs }; - // Merge non-audio settings from Tauri (server settings, etc.) - // but NEVER touch audio_devices - they are purely local + // Merge non-local-only settings from Tauri (server settings, etc.) + // but NEVER touch local-only keys - they are purely local for (const key of Object.keys(tauriPrefs) as Array) { - // Skip audio_devices entirely - local-only - if (key === 'audio_devices') { + // Skip local-only keys entirely - preserve local values + if (isLocalOnlyKey(key)) { continue; } // For other fields, only use Tauri value if local is missing/undefined @@ -226,6 +226,7 @@ export async function hydratePreferencesFromTauri( } // Sync audio_devices from Tauri to keep localStorage aligned with backend. + // This is specifically for audio device selection which needs Tauri as source of truth. mergedPrefs.audio_devices = { ...tauriPrefs.audio_devices }; addClientLog({ diff --git a/client/src/pages/Analytics.tsx b/client/src/pages/Analytics.tsx index f5c7f86..90840d4 100644 --- a/client/src/pages/Analytics.tsx +++ b/client/src/pages/Analytics.tsx @@ -14,7 +14,7 @@ import { TrendingUp, Users, } from 'lucide-react'; -import { useMemo, useState } from 'react'; +import { useId, useMemo, useState } from 'react'; import { Area, AreaChart, @@ -46,7 +46,14 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { formatDuration } from '@/lib/format'; -import { chartAxis, chartHeight, chartStrokes, flexLayout, overflow, typography } from '@/lib/styles'; +import { + chartAxis, + chartHeight, + chartStrokes, + flexLayout, + overflow, + typography, +} from '@/lib/styles'; import { cn } from '@/lib/utils'; const titleRowClass = flexLayout.itemsGap2; @@ -141,6 +148,7 @@ function MeetingsAnalyticsContent({ analytics: ReturnType; chartConfig: Record; }) { + const chartId = useId(); const gridProps = { strokeDasharray: '3 3', className: chartStrokes.muted }; const durationTooltip = ( formatDuration(Number(value))} /> @@ -196,7 +204,7 @@ function MeetingsAnalyticsContent({ > - + @@ -214,7 +222,7 @@ function MeetingsAnalyticsContent({ dataKey="totalDuration" name="Duration" stroke="hsl(var(--chart-2))" - fill="url(#durationGradient)" + fill={`url(#${chartId}-durationGradient)`} strokeWidth={2} /> @@ -285,14 +293,10 @@ function MeetingsAnalyticsContent({ label={speakerLabel} labelLine={false} > - {analytics.speakerStats.map((stat) => ( + {analytics.speakerStats.map((stat, idx) => ( ))} @@ -322,7 +326,7 @@ function MeetingsAnalyticsContent({ - + @@ -344,7 +348,7 @@ function MeetingsAnalyticsContent({ dataKey="wordCount" name="Words" stroke="hsl(var(--chart-3))" - fill="url(#wordsGradient)" + fill={`url(#${chartId}-wordsGradient)`} strokeWidth={2} /> @@ -369,9 +373,7 @@ function MeetingsAnalyticsContent({ return (
-
+
diff --git a/client/src/pages/Home.tsx b/client/src/pages/Home.tsx index 884e841..9281ebb 100644 --- a/client/src/pages/Home.tsx +++ b/client/src/pages/Home.tsx @@ -3,7 +3,7 @@ import { motion } from 'framer-motion'; import { ArrowRight, Calendar, CheckSquare, Circle } from 'lucide-react'; import { Link } from 'react-router-dom'; -import { getAPI } from '@/api'; +import { getAPI } from '@/api/interface'; import { EmptyState } from '@/components/empty-state'; import { MeetingCard } from '@/components/meeting-card'; import { PriorityBadge } from '@/components/priority-badge'; diff --git a/client/src/pages/Meetings.tsx b/client/src/pages/Meetings.tsx index f111d57..c3bf585 100644 --- a/client/src/pages/Meetings.tsx +++ b/client/src/pages/Meetings.tsx @@ -3,7 +3,7 @@ import { Calendar } from 'lucide-react'; import { useEffect, useMemo, useState } from 'react'; import { useParams } from 'react-router-dom'; -import { getAPI } from '@/api'; +import { getAPI } from '@/api/interface'; import type { MeetingState } from '@/api/types'; import type { ProjectScope } from '@/api/types/requests'; import { EmptyState } from '@/components/empty-state'; diff --git a/client/src/pages/People.tsx b/client/src/pages/People.tsx index 3061e25..d9ce747 100644 --- a/client/src/pages/People.tsx +++ b/client/src/pages/People.tsx @@ -3,7 +3,7 @@ import { useQuery } from '@tanstack/react-query'; import { AnimatePresence, motion } from 'framer-motion'; import { Calendar, Clock, Edit2, Mic, TrendingUp, Users, X } from 'lucide-react'; -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { getAPI } from '@/api/interface'; import { SuccessIcon } from '@/components/icons/status-icons'; import { Badge } from '@/components/ui/badge'; @@ -179,20 +179,23 @@ function SpeakerCard({ export default function PeoplePage() { const [searchQuery, setSearchQuery] = useState(''); - const [refreshKey, setRefreshKey] = useState(0); + const [prefsVersion, setPrefsVersion] = useState(0); const { data: meetingsData } = useQuery({ queryKey: ['meetings', 'all'], queryFn: () => getAPI().listMeetings({ limit: 100 }), }); + useEffect(() => { + return preferences.subscribe(() => setPrefsVersion((v) => v + 1)); + }, []); + const speakers = useMemo(() => { if (!meetingsData?.meetings) { return []; } - void refreshKey; return aggregateSpeakers(meetingsData.meetings); - }, [meetingsData, refreshKey]); + }, [meetingsData, prefsVersion]); const filteredSpeakers = useMemo(() => { if (!searchQuery.trim()) { @@ -215,7 +218,6 @@ export default function PeoplePage() { const handleRename = (speakerId: string, newName: string) => { preferences.setGlobalSpeakerName(speakerId, newName); - setRefreshKey((k) => k + 1); }; return ( diff --git a/client/src/pages/Recording.logic.test.tsx b/client/src/pages/Recording.logic.test.tsx index 1ce08c7..3b0ba5c 100644 --- a/client/src/pages/Recording.logic.test.tsx +++ b/client/src/pages/Recording.logic.test.tsx @@ -64,6 +64,9 @@ vi.mock('@/api', () => ({ mockAPI: mockApiInstance, isTauriEnvironment: () => isTauri, })); +vi.mock('@/api/interface', () => ({ + getAPI: () => apiInstance, +})); vi.mock('@/api/mock-transcription-stream', () => ({ MockTranscriptionStream: class MockTranscriptionStream { @@ -230,9 +233,9 @@ vi.mock('@/components/ui/resizable', async () => { const React = await import('react'); return { ResizablePanelGroup: ({ children }: { children: React.ReactNode }) =>
{children}
, - ResizablePanel: React.forwardRef(({ children }, _ref) => ( -
{children}
- )), + ResizablePanel: React.forwardRef( + ({ children }, _ref) =>
{children}
+ ), ResizableHandle: () =>
, }; }); diff --git a/client/src/pages/Recording.test.tsx b/client/src/pages/Recording.test.tsx index 2150778..5cb1ee5 100644 --- a/client/src/pages/Recording.test.tsx +++ b/client/src/pages/Recording.test.tsx @@ -12,23 +12,27 @@ const mockConnect = vi.fn(); const mockCreateMeeting = vi.fn(); const mockStartTranscription = vi.fn(); const mockIsTauriEnvironment = vi.fn(() => false); +const mockGetAPI = vi.fn(() => ({ + listWorkspaces: vi.fn().mockResolvedValue({ workspaces: [] }), + listProjects: vi.fn().mockResolvedValue({ projects: [], total_count: 0 }), + getActiveProject: vi.fn().mockResolvedValue({ project_id: '' }), + setActiveProject: vi.fn().mockResolvedValue(undefined), + connect: mockConnect, + createMeeting: mockCreateMeeting, + startTranscription: mockStartTranscription, +})); vi.mock('@/api', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - getAPI: vi.fn(() => ({ - listWorkspaces: vi.fn().mockResolvedValue({ workspaces: [] }), - listProjects: vi.fn().mockResolvedValue({ projects: [], total_count: 0 }), - getActiveProject: vi.fn().mockResolvedValue({ project_id: '' }), - setActiveProject: vi.fn().mockResolvedValue(undefined), - connect: mockConnect, - createMeeting: mockCreateMeeting, - startTranscription: mockStartTranscription, - })), + getAPI: mockGetAPI, isTauriEnvironment: () => mockIsTauriEnvironment(), }; }); +vi.mock('@/api/interface', () => ({ + getAPI: mockGetAPI, +})); // Mock toast const mockToast = vi.fn(); diff --git a/client/src/pages/Settings.tsx b/client/src/pages/Settings.tsx index 103f530..1f45459 100644 --- a/client/src/pages/Settings.tsx +++ b/client/src/pages/Settings.tsx @@ -3,7 +3,7 @@ import { motion } from 'framer-motion'; import { useCallback, useEffect, useState } from 'react'; import { useSearchParams } from 'react-router-dom'; -import { getAPI } from '@/api'; +import { getAPI } from '@/api/interface'; import { initializeTauriAPI, isTauriEnvironment } from '@/api/tauri-adapter'; import type { EffectiveServerUrl, Integration, ServerInfo } from '@/api/types'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; diff --git a/client/src/pages/Tasks.tsx b/client/src/pages/Tasks.tsx index f381ca5..f6fc429 100644 --- a/client/src/pages/Tasks.tsx +++ b/client/src/pages/Tasks.tsx @@ -4,7 +4,7 @@ import { AnimatePresence, motion } from 'framer-motion'; import { ArrowRight, Calendar, CheckCircle2, CheckSquare, Circle, Inbox } from 'lucide-react'; import { useEffect, useMemo, useState } from 'react'; import { Link } from 'react-router-dom'; -import { getAPI } from '@/api'; +import { getAPI } from '@/api/interface'; import type { Priority } from '@/api/types'; import type { ProjectScope } from '@/api/types/requests'; import { EmptyState } from '@/components/empty-state'; diff --git a/client/src/pages/meeting-detail/index.tsx b/client/src/pages/meeting-detail/index.tsx index b1d3f37..deb6da6 100644 --- a/client/src/pages/meeting-detail/index.tsx +++ b/client/src/pages/meeting-detail/index.tsx @@ -8,13 +8,14 @@ import { useRef } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { useVirtualizer } from '@tanstack/react-virtual'; -import { getAPI } from '@/api'; +import { getAPI } from '@/api/interface'; import { isTauriEnvironment } from '@/api/tauri-adapter'; import type { ExportFormat } from '@/api/types'; import { ProcessingStatus } from '@/components/processing-status'; import { SkeletonTranscript } from '@/components/ui/skeleton'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { useGuardedMutation } from '@/hooks/use-guarded-mutation'; +import { buildExportBlob, downloadBlob } from '@/lib/download-utils'; import { TRANSCRIPT_ESTIMATED_ROW_HEIGHT, @@ -86,28 +87,8 @@ export default function MeetingDetailPage() { return; } - let blobContent: BlobPart; - let mimeType: string; - if (format === 'pdf') { - const binaryString = atob(result.content); - const bytes = new Uint8Array(binaryString.length); - for (let i = 0; i < binaryString.length; i++) { - bytes[i] = binaryString.charCodeAt(i); - } - blobContent = bytes; - mimeType = 'application/pdf'; - } else { - blobContent = result.content; - mimeType = format === 'html' ? 'text/html' : 'text/markdown'; - } - - const blob = new Blob([blobContent], { type: mimeType }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `${meeting.title || 'Meeting'}.${extension}`; - a.click(); - URL.revokeObjectURL(url); + const blob = buildExportBlob(format, result); + downloadBlob(blob, `${meeting.title || 'Meeting'}.${extension}`); }, { title: 'Offline mode', diff --git a/client/src/pages/meeting-detail/use-meeting-detail.ts b/client/src/pages/meeting-detail/use-meeting-detail.ts index 88905d0..75f1547 100644 --- a/client/src/pages/meeting-detail/use-meeting-detail.ts +++ b/client/src/pages/meeting-detail/use-meeting-detail.ts @@ -4,11 +4,12 @@ import { useEffect, useMemo, useRef, useState } from 'react'; -import { getAPI } from '@/api'; +import { getAPI } from '@/api/interface'; import type { Meeting } from '@/api/types'; import { useConnectionState } from '@/contexts/connection-state'; import { useEntityExtraction } from '@/hooks/use-entity-extraction'; import { usePostProcessing } from '@/hooks/use-post-processing'; +import { fireAndForget } from '@/lib/async-utils'; import { addClientLog } from '@/lib/client-logs'; import { preferences } from '@/lib/preferences'; import { buildSpeakerNameMap } from '@/lib/speaker-utils'; @@ -29,9 +30,9 @@ export function useMeetingDetail({ meetingId }: UseMeetingDetailProps) { const autoStartLogRef = useRef>(new Set()); const failedStep = meeting?.processing_status - ? (['summary', 'entities', 'diarization'] as const).find( + ? ((['summary', 'entities', 'diarization'] as const).find( (step) => meeting.processing_status?.[step]?.status === 'failed' - ) ?? null + ) ?? null) : null; // Post-processing orchestration hook @@ -43,9 +44,12 @@ export function useMeetingDetail({ meetingId }: UseMeetingDetailProps) { showToasts: true, onComplete: (state) => { if (meetingId && state.overallStatus !== 'failed') { - void getAPI() - .getMeeting({ meeting_id: meetingId, include_segments: true, include_summary: true }) - .then(setMeeting); + fireAndForget( + getAPI() + .getMeeting({ meeting_id: meetingId, include_segments: true, include_summary: true }) + .then(setMeeting), + 'refresh-meeting-after-processing' + ); } }, }); @@ -171,7 +175,7 @@ export function useMeetingDetail({ meetingId }: UseMeetingDetailProps) { message: 'Post-processing auto-start triggered', metadata: { meeting_id: meetingId }, }); - void startProcessing(meetingId); + fireAndForget(startProcessing(meetingId), 'auto-start-processing'); }, [meeting, meetingId, shouldAutoStart, startProcessing]); const handleGenerateSummary = async () => { diff --git a/client/src/pages/meeting-detail/use-playback.ts b/client/src/pages/meeting-detail/use-playback.ts index 9c89ce6..e863509 100644 --- a/client/src/pages/meeting-detail/use-playback.ts +++ b/client/src/pages/meeting-detail/use-playback.ts @@ -4,16 +4,12 @@ import { useCallback, useEffect, useRef, useState } from 'react'; -import { getAPI } from '@/api'; +import { getAPI } from '@/api/interface'; import { isTauriEnvironment, TauriEvents } from '@/api/tauri-adapter'; import type { Meeting, PlaybackInfo } from '@/api/types'; import { useTauriEvent } from '@/lib/tauri-events'; -import { - HIGHLIGHT_THROTTLE_MS, - nowMs, - PLAYBACK_POSITION_THROTTLE_MS, -} from './constants'; +import { HIGHLIGHT_THROTTLE_MS, nowMs, PLAYBACK_POSITION_THROTTLE_MS } from './constants'; interface UsePlaybackProps { meeting: Meeting | null; diff --git a/client/src/pages/settings/AudioTab.tsx b/client/src/pages/settings/AudioTab.tsx index 310de13..689f6f8 100644 --- a/client/src/pages/settings/AudioTab.tsx +++ b/client/src/pages/settings/AudioTab.tsx @@ -1,7 +1,4 @@ -import { - AudioDevicesSection, - RecordingAppPolicySection, -} from '@/components/settings'; +import { AudioDevicesSection, RecordingAppPolicySection } from '@/components/settings'; import type { UseAudioDevicesReturn } from '@/hooks/use-audio-devices'; interface AudioTabProps { @@ -11,14 +8,30 @@ interface AudioTabProps { export function AudioTab({ audioDevices }: AudioTabProps) { const { - inputDevices, outputDevices, loopbackDevices, - selectedInputDevice, selectedOutputDevice, selectedSystemDevice, - dualCaptureEnabled, micGain, systemGain, + inputDevices, + outputDevices, + loopbackDevices, + selectedInputDevice, + selectedOutputDevice, + selectedSystemDevice, + dualCaptureEnabled, + micGain, + systemGain, systemAudioLevel, - isLoading, hasPermission, isTestingInput, isTestingOutput, inputLevel, - loadDevices, setInputDevice, setOutputDevice, - setSystemDevice, setDualCaptureEnabled, setMixLevels, - startInputTest, stopInputTest, testOutputDevice, + isLoading, + hasPermission, + isTestingInput, + isTestingOutput, + inputLevel, + loadDevices, + setInputDevice, + setOutputDevice, + setSystemDevice, + setDualCaptureEnabled, + setMixLevels, + startInputTest, + stopInputTest, + testOutputDevice, } = audioDevices; return ( diff --git a/client/src/pages/settings/DiagnosticsTab.tsx b/client/src/pages/settings/DiagnosticsTab.tsx index a1c1a7a..759cff7 100644 --- a/client/src/pages/settings/DiagnosticsTab.tsx +++ b/client/src/pages/settings/DiagnosticsTab.tsx @@ -1,13 +1,18 @@ import { useCallback } from 'react'; -import { getAPI } from '@/api'; +import { getAPI } from '@/api/interface'; import { isTauriEnvironment } from '@/api/tauri-adapter'; import type { ExportFormat, ExportResult, Meeting } from '@/api/types'; -import { ConnectionDiagnosticsPanel, DeveloperOptionsSection, QuickActionsSection } from '@/components/settings'; +import { + ConnectionDiagnosticsPanel, + DeveloperOptionsSection, + QuickActionsSection, +} from '@/components/settings'; import { toast } from '@/hooks/use-toast'; import { toastError } from '@/lib/error-reporting'; import { meetingCache } from '@/lib/cache/meeting-cache'; import { clearClientLogs } from '@/lib/client-logs'; import { clearSecureStorage } from '@/lib/crypto'; +import { buildExportBlob, downloadBlob } from '@/lib/download-utils'; import { preferences } from '@/lib/preferences'; import { resetPreferencesSyncMeta } from '@/lib/preferences-sync'; import { clearStoredProjectIds, clearStoredWorkspaceId } from '@/contexts/storage'; @@ -66,23 +71,6 @@ export function DiagnosticsTab({ }); }, []); - const buildExportBlob = useCallback( - (format: ExportFormat, result: ExportResult): Blob => { - if (format === 'pdf') { - const binaryString = atob(result.content); - const bytes = new Uint8Array(binaryString.length); - for (let i = 0; i < binaryString.length; i++) { - bytes[i] = binaryString.charCodeAt(i); - } - return new Blob([bytes], { type: 'application/pdf' }); - } - - const mimeType = format === 'html' ? 'text/html' : 'text/markdown'; - return new Blob([result.content], { type: mimeType }); - }, - [] - ); - const saveExportForMeeting = useCallback( async (meeting: Meeting, format: ExportFormat, result: ExportResult): Promise => { const extension = result.file_extension.startsWith('.') @@ -95,15 +83,10 @@ export function DiagnosticsTab({ } const blob = buildExportBlob(format, result); - const url = URL.createObjectURL(blob); - const link = document.createElement('a'); - link.href = url; - link.download = `${defaultName}.${extension}`; - link.click(); - URL.revokeObjectURL(url); + downloadBlob(blob, `${defaultName}.${extension}`); return true; }, - [buildExportBlob] + [] ); const handleExportAllMeetings = useCallback(async () => { diff --git a/client/src/pages/settings/IntegrationsTab.tsx b/client/src/pages/settings/IntegrationsTab.tsx index 11e41ae..7cc78f4 100644 --- a/client/src/pages/settings/IntegrationsTab.tsx +++ b/client/src/pages/settings/IntegrationsTab.tsx @@ -31,10 +31,7 @@ export function IntegrationsTab({ }: IntegrationsTabProps) { return (
- + - + @@ -60,10 +54,7 @@ export function IntegrationsTab({ triggerSync={triggerSync} /> - +
diff --git a/client/src/pages/settings/StatusTab.tsx b/client/src/pages/settings/StatusTab.tsx index 488d2f8..6e28889 100644 --- a/client/src/pages/settings/StatusTab.tsx +++ b/client/src/pages/settings/StatusTab.tsx @@ -50,10 +50,7 @@ export function StatusTab({ onRefresh={onRefresh} /> - + diff --git a/client/src/pages/settings/use-settings-state.ts b/client/src/pages/settings/use-settings-state.ts index bac4df9..81ed821 100644 --- a/client/src/pages/settings/use-settings-state.ts +++ b/client/src/pages/settings/use-settings-state.ts @@ -19,7 +19,9 @@ export interface AITemplateState { export function useAITemplateState(): AITemplateState { const [aiTone, setAiTone] = useState(preferences.get().ai_template.tone); const [aiFormat, setAiFormat] = useState(preferences.get().ai_template.format); - const [aiVerbosity, setAiVerbosity] = useState(preferences.get().ai_template.verbosity); + const [aiVerbosity, setAiVerbosity] = useState( + preferences.get().ai_template.verbosity + ); const handleToneChange = useCallback((tone: AITone) => { setAiTone(tone); @@ -36,7 +38,14 @@ export function useAITemplateState(): AITemplateState { preferences.setAITemplate('verbosity', verbosity); }, []); - return { aiTone, aiFormat, aiVerbosity, handleToneChange, handleFormatChange, handleVerbosityChange }; + return { + aiTone, + aiFormat, + aiVerbosity, + handleToneChange, + handleFormatChange, + handleVerbosityChange, + }; } export interface ExportState { @@ -64,7 +73,12 @@ export function useExportState(): ExportState { preferences.setDefaultExportLocation(location); }, []); - return { defaultExportFormat, defaultExportLocation, handleExportFormatChange, handleExportLocationChange }; + return { + defaultExportFormat, + defaultExportLocation, + handleExportFormatChange, + handleExportLocationChange, + }; } export interface DarkModeState { diff --git a/client/src/test/code-quality.test.ts b/client/src/test/code-quality.test.ts index 9bfaba6..b03e4a7 100644 --- a/client/src/test/code-quality.test.ts +++ b/client/src/test/code-quality.test.ts @@ -334,7 +334,10 @@ describe('Code Quality - Extraction Targets', () => { expect( violations.length, `Found ${violations.length} event emitter implementations (extract createEventEmitter()):\n` + - violations.slice(0, 8).map((file) => ` - ${file}`).join('\n') + violations + .slice(0, 8) + .map((file) => ` - ${file}`) + .join('\n') ).toBeLessThanOrEqual(1); }); @@ -364,7 +367,10 @@ describe('Code Quality - Extraction Targets', () => { expect( violations.length, `Found ${violations.length} hooks with duplicate async state patterns:\n` + - violations.slice(0, 8).map((file) => ` - ${file}`).join('\n') + violations + .slice(0, 8) + .map((file) => ` - ${file}`) + .join('\n') ).toBeLessThanOrEqual(1); }); @@ -403,8 +409,8 @@ describe('Code Quality - Extraction Targets', () => { .slice(0, 6) .map((line) => ` - ${line.slice(0, 120)}`) .join('\n') - // Threshold raised to 0.5 after extracting common utilities to oauth-utils.ts - // Remaining similarity is structural (both hooks need similar state shapes) + // Threshold raised to 0.5 after extracting common utilities to oauth-utils.ts + // Remaining similarity is structural (both hooks need similar state shapes) ).toBeLessThanOrEqual(0.5); }); @@ -427,7 +433,10 @@ describe('Code Quality - Extraction Targets', () => { expect( violations.length, `Found ${violations.length} polling hook implementations (extract use-polling):\n` + - violations.slice(0, 8).map((file) => ` - ${file}`).join('\n') + violations + .slice(0, 8) + .map((file) => ` - ${file}`) + .join('\n') ).toBeLessThanOrEqual(1); }); }); @@ -639,7 +648,7 @@ describe('Code Quality - Code Smells', () => { const ALLOWED_LONG_FILES = new Set([ 'api/interface.ts', 'api/mock-adapter.ts', - 'api/tauri-adapter.ts' + 'api/tauri-adapter.ts', ]); const longFiles: { file: string; lines: number }[] = []; @@ -732,16 +741,12 @@ describe('Code Quality - Async Patterns', () => { const matches = searchInFiles(files, nakedCatchPattern); // Filter out async-utils.ts which documents the rule - const violations = matches.filter( - (m) => !m.file.includes('async-utils.ts') - ); + const violations = matches.filter((m) => !m.file.includes('async-utils.ts')); expect( violations.length, `Found naked fire-and-forget catch handlers (use fireAndForget() from lib/async-utils):\n` + - violations - .map((m) => ` ${m.file}:${m.line}: ${m.content}`) - .join('\n') + violations.map((m) => ` ${m.file}:${m.line}: ${m.content}`).join('\n') ).toBe(0); }); diff --git a/client/src/types/window.d.ts b/client/src/types/window.d.ts index e2903e4..0df5f59 100644 --- a/client/src/types/window.d.ts +++ b/client/src/types/window.d.ts @@ -22,7 +22,10 @@ declare global { injectTestTone?: NoteFlowAPI['injectTestTone']; isE2EMode?: () => string | undefined; updatePreferences?: (updates: Partial) => void; - forceConnectionState?: (mode: 'connected' | 'disconnected' | 'cached' | 'mock', serverUrl?: string | null) => void; + forceConnectionState?: ( + mode: 'connected' | 'disconnected' | 'cached' | 'mock', + serverUrl?: string | null + ) => void; resetRecordingState?: () => Promise; }; /** Direct Tauri invoke - only exposed in dev/E2E mode for security */ diff --git a/client/vite.config.ts b/client/vite.config.ts index 14d5fae..b9d1b0c 100644 --- a/client/vite.config.ts +++ b/client/vite.config.ts @@ -31,9 +31,7 @@ export default defineConfig(({ mode }) => ({ host: 'localhost', }, }, - plugins: [noteflowAlias(), react(), mode === 'development' && componentTagger()].filter( - Boolean - ), + plugins: [noteflowAlias(), react(), mode === 'development' && componentTagger()].filter(Boolean), resolve: { alias: [ { find: /^@\//, replacement: `${srcDir}/` }, diff --git a/client/wdio.mac.conf.ts b/client/wdio.mac.conf.ts index 55b4032..fbfa4fa 100644 --- a/client/wdio.mac.conf.ts +++ b/client/wdio.mac.conf.ts @@ -28,7 +28,14 @@ function writeStderr(message: string): void { function getTauriAppBundlePath(): string { const projectRoot = path.resolve(__dirname, 'src-tauri'); - const releasePath = path.join(projectRoot, 'target', 'release', 'bundle', 'macos', 'NoteFlow.app'); + const releasePath = path.join( + projectRoot, + 'target', + 'release', + 'bundle', + 'macos', + 'NoteFlow.app' + ); const debugPath = path.join(projectRoot, 'target', 'debug', 'bundle', 'macos', 'NoteFlow.app'); if (fs.existsSync(releasePath)) { @@ -183,8 +190,7 @@ export const config: Options.Testrunner = { onPrepare: async () => { if (!fs.existsSync(APP_BUNDLE_PATH)) { throw new Error( - `Tauri app bundle not found at: ${APP_BUNDLE_PATH}\n` + - 'Build it with: npm run tauri:build' + `Tauri app bundle not found at: ${APP_BUNDLE_PATH}\n` + 'Build it with: npm run tauri:build' ); } fs.mkdirSync(SCREENSHOT_DIR, { recursive: true }); diff --git a/repomix-output.md b/repomix-output.md index a96fd3a..3139a20 100644 --- a/repomix-output.md +++ b/repomix-output.md @@ -4,7 +4,7 @@ The content has been processed where line numbers have been added, content has b # File Summary ## Purpose -This file contains a packed representation of the entire repository's contents. +This file contains a packed representation of a subset of the repository's contents that is considered the most important context. It is designed to be easily consumable by AI systems for analysis, code review, or other automated processes. @@ -14,7 +14,7 @@ The content is organized as follows: 2. Repository information 3. Directory structure 4. Repository files (if enabled) -4. Multiple file entries, each consisting of: +5. Multiple file entries, each consisting of: a. A header with the file path (## File: path/to/file) b. The full contents of the file in a code block @@ -29,13394 +29,25574 @@ The content is organized as follows: ## Notes - Some files may have been excluded based on .gitignore rules and Repomix's configuration - Binary files are not included in this packed representation. Please refer to the Repository Structure section for a complete list of file paths, including binary files -- Only files matching these patterns are included: client/src/lib +- Only files matching these patterns are included: client/src/components - Files matching these patterns are excluded: **/*_pb2.py, **/*_pb2_grpc.py, **/*.pb2.py, **/*.pb2_grpc.py, **/*.pyi, **/*.wav, **/*.nfaudio, **/*.m4a, **/*.mp3, **/*.mp4, **/*.mov, **/*.avi, **/*.mkv, **/*.flv, **/*.wmv, **/*.webm, **/*.m3u8, **/noteflow.rs, **/noteflow_pb2.py, src/noteflow_pb2.py, client/src-tauri/src/grpc/noteflow.rs, src/noteflow/grpc/proto/noteflow_pb2.py, src/noteflow/grpc/proto/noteflow_pb2_grpc.py, src/noteflow/grpc/proto/noteflow_pb2.pyi, **/persistence/migrations/**, **/node_modules/**, **/target/**, **/gen/**, **/__pycache__/**, **/*.pyc, **/.pytest_cache/**, **/.mypy_cache/**, **/.ruff_cache/**, **/dist/**, **/build/**, **/.vite/**, **/coverage/**, **/htmlcov/**, **/playwright-report/**, **/test-results/**, uv.lock, **/Cargo.lock, **/package-lock.json, **/bun.lockb, **/yarn.lock, **/*.lock, **/*.lockb, **/*.png, **/*.jpg, **/*.jpeg, **/*.gif, **/*.ico, **/*.svg, **/*.icns, **/*.webp, **/*.xml, **/icons/**, **/public/**, client/app-icon.png, **/*.md, .benchmarks/**, noteflow-api-spec.json, scratch.md, repomix-output.md, **/logs/**, **/status_line.json - Files matching patterns in .gitignore are excluded - Files matching default ignore patterns are excluded - Line numbers have been added to the beginning of each line - Content has been formatted for parsing in markdown style +- Long base64 data strings (e.g., data:image/png;base64,...) have been truncated to reduce token count - Files are sorted by Git change count (files with more changes are at the bottom) -## Additional Info - # Directory Structure ``` client/ src/ - lib/ - ai-providers/ - constants.ts - fetch-models.ts + components/ + analytics/ + analytics-card-title.tsx + analytics-utils.ts + log-entry-config.ts + log-entry.tsx + log-timeline.tsx + logs-tab.test.tsx + logs-tab.tsx + performance-tab.test.tsx + performance-tab.tsx + speech-analysis-tab.tsx + integration-config-panel/ + auth-config.tsx + calendar-config.tsx + email-config.tsx + index.tsx + oidc-config.tsx + pkm-config.tsx + shared.tsx + webhook-config.tsx + projects/ + ProjectList.tsx + ProjectMembersPanel.tsx + ProjectScopeFilter.tsx + ProjectSettingsPanel.tsx + ProjectSidebar.tsx + ProjectSwitcher.tsx + recording/ + audio-device-selector.test.tsx + audio-device-selector.tsx + audio-level-meter.test.tsx + audio-level-meter.tsx + buffering-indicator.test.tsx + buffering-indicator.tsx + confidence-indicator.test.tsx + confidence-indicator.tsx + idle-state.test.tsx + idle-state.tsx + index.test.ts index.ts - model-catalog-cache.ts - model-catalog-utils.ts - test-endpoint.ts - types.ts - cache/ - meeting-cache.test.ts - meeting-cache.ts - config/ - app-config.ts - config.test.ts - defaults.ts + listening-state.tsx + notes-panel.tsx + partial-text-display.tsx + recording-components.test.tsx + recording-header.test.tsx + recording-header.tsx + speaker-distribution.test.tsx + speaker-distribution.tsx + stat-card.test.tsx + stat-card.tsx + stats-content.tsx + stats-panel.tsx + transcript-segment-card.tsx + vad-indicator.test.tsx + vad-indicator.tsx + settings/ + advanced-local-ai-settings/ + _constants.ts + index.tsx + model-auth-section.tsx + resource-fit-panel.tsx + streaming-config-section.tsx + transcription-engine-section.tsx + integrations-section/ + custom-integration-dialog.tsx + helpers.ts + index.tsx + integration-item.tsx + types.ts + use-integration-handlers.ts + ai-config-hooks.ts + ai-config-models.ts + ai-config-section.tsx + audio-devices-section.tsx + cloud-ai-toggle.tsx + connection-diagnostics-panel.tsx + developer-options-section.tsx + export-ai-section.test.tsx + export-ai-section.tsx index.ts - provider-endpoints.ts - server.ts - preferences/ - api.ts - constants.ts - core.ts - index.ts - integrations.ts - storage.ts - tags.ts - tauri.ts - types.ts - validation-events.ts - ai-providers.ts - async-utils.ts - audio-device-ids.test.ts - audio-device-ids.ts - audio-device-persistence.integration.test.ts - client-log-events.integration.test.ts - client-log-events.test.ts - client-log-events.ts - client-logs.test.ts - client-logs.ts - crypto.test.ts - crypto.ts - cva.test.ts - cva.ts - debug.ts - default-integrations.ts - entity-store.test.ts - entity-store.ts - error-reporting.ts - event-emitter.ts - format.test.ts - format.ts - integration-utils.test.ts - integration-utils.ts - log-converters.test.ts - log-converters.ts - log-group-summarizer.test.ts - log-group-summarizer.ts - log-groups.test.ts - log-groups.ts - log-messages.test.ts - log-messages.ts - log-summarizer.test.ts - log-summarizer.ts - oauth-utils.ts - object-utils.test.ts - object-utils.ts - preferences-sync.test.ts - preferences-sync.ts - preferences-validation.test.ts - speaker-utils.test.ts - speaker-utils.ts - status-constants.ts - storage-keys.ts - storage-utils.ts - styles.ts - tauri-events.test.tsx - tauri-events.ts - time.ts - timing-constants.ts - utils.test.ts - utils.ts + integrations-section.tsx + medium-label.tsx + ollama-status-card.tsx + provider-config-card.tsx + quick-actions-section.tsx + recording-app-policy-section.tsx + server-connection-section.tsx + summarization-settings-panel.tsx + summarization-template-creator.tsx + summarization-templates-card.tsx + summarization-templates-list.tsx + summarization-templates-manager.tsx + template-content-label.tsx + ui/ + sidebar/ + constants.ts + context.tsx + group.tsx + index.tsx + layout.tsx + menu.tsx + primitives.tsx + accordion.tsx + alert-dialog.tsx + alert.tsx + aspect-ratio.tsx + avatar.tsx + badge.tsx + breadcrumb.tsx + button.tsx + calendar.tsx + card.tsx + carousel.tsx + chart.tsx + checkbox.tsx + collapsible.tsx + command.tsx + confirmation-dialog.tsx + context-menu.tsx + dialog.tsx + drawer.tsx + dropdown-menu.test.tsx + dropdown-menu.tsx + form.tsx + hover-card.tsx + icon-circle.tsx + inline-label.tsx + input-otp.tsx + input.tsx + label.tsx + loading-button.tsx + menubar.tsx + navigation-menu.tsx + pagination.tsx + popover.tsx + progress.tsx + radio-group.tsx + resizable.test.tsx + resizable.tsx + scroll-area.tsx + search-icon.tsx + select.tsx + separator.tsx + sheet.tsx + skeleton.tsx + slider.tsx + sonner.tsx + status-badge.tsx + switch.tsx + table.tsx + tabs.tsx + textarea.tsx + toast.tsx + toaster.tsx + toggle-group.tsx + toggle.tsx + tooltip.tsx + ui-components.test.tsx + use-toast.ts + annotation-type-badge.tsx + api-mode-indicator.test.tsx + api-mode-indicator.tsx + app-layout.tsx + app-sidebar.tsx + calendar-connection-panel.tsx + calendar-events-panel.tsx + confirmation-dialog.tsx + connection-status.tsx + dev-profiler.tsx + empty-state.tsx + entity-highlight.test.tsx + entity-highlight.tsx + entity-management-panel.test.tsx + entity-management-panel.tsx + error-boundary.tsx + integration-config-panel.tsx + meeting-card.tsx + meeting-state-badge.tsx + NavLink.tsx + offline-banner.test.tsx + offline-banner.tsx + preferences-sync-bridge.tsx + preferences-sync-status.test.tsx + preferences-sync-status.tsx + priority-badge.tsx + processing-status.test.tsx + processing-status.tsx + secure-storage-recovery-dialog.tsx + server-switch-confirmation-dialog.tsx + simulation-confirmation-dialog.tsx + speaker-badge.test.tsx + speaker-badge.tsx + stats-card.tsx + sync-control-panel.tsx + sync-history-log.tsx + sync-status-indicator.tsx + tauri-event-listener.tsx + timestamped-notes-editor.test.tsx + timestamped-notes-editor.tsx + top-bar.tsx + upcoming-meetings.tsx + webhook-settings-panel.tsx + workspace-switcher.test.tsx + workspace-switcher.tsx ``` # Files -## File: client/src/lib/async-utils.ts +## File: client/src/components/analytics/analytics-card-title.tsx ````typescript - 1: /** - 2: * Async utilities for observable fire-and-forget and streaming backpressure. - 3: * - 4: * Rules: - 5: * - NEVER use `promise.catch(() => {})` directly in the API layer - 6: * - Use `fireAndForget()` for truly optional, non-critical operations - 7: * - Use `AsyncQueue` for ordered, backpressure-aware streaming - 8: */ - 9: - 10: import { addClientLog } from './client-logs'; - 11: - 12: /** - 13: * Observed fire-and-forget wrapper. - 14: * - 15: * Use this ONLY when ALL of these are true: - 16: * 1. Non-critical: failure does not corrupt user state or lose core data - 17: * 2. Idempotent/retryable: safe to run multiple times - 18: * 3. Bounded: concurrency and memory growth are bounded - 19: * - 20: * @param promise - The promise to fire and forget - 21: * @param label - Descriptive label for logging (e.g., 'event-bridge-init') - 22: * @param options - Optional configuration - 23: */ - 24: export function fireAndForget( - 25: promise: Promise, - 26: label: string, - 27: options?: { - 28: /** Log level for failures. Defaults to 'warning'. */ - 29: level?: 'debug' | 'warning' | 'error'; - 30: /** Additional metadata for logging. */ - 31: metadata?: Record; - 32: } - 33: ): void { - 34: const level = options?.level ?? 'warning'; - 35: promise.catch((err: unknown) => { - 36: const message = err instanceof Error ? err.message : String(err); - 37: addClientLog({ - 38: level, - 39: source: 'app', - 40: message: `[fire-and-forget] ${label} failed`, - 41: details: message, - 42: metadata: { label, ...options?.metadata }, - 43: }); - 44: }); - 45: } - 46: - 47: /** - 48: * Result of an enqueued operation. - 49: */ - 50: export interface QueuedResult { - 51: /** Whether the operation succeeded. */ - 52: success: boolean; - 53: /** The result value if successful. */ - 54: value?: T; - 55: /** The error if failed. */ - 56: error?: Error; - 57: } - 58: - 59: /** - 60: * Async queue for ordered, backpressure-aware operations. - 61: * - 62: * Guarantees: - 63: * - Operations execute in order (FIFO) - 64: * - Only one operation runs at a time (serialized) - 65: * - Errors are captured and reported, not swallowed - 66: * - Queue depth is bounded to prevent memory growth - 67: * - 68: * Use for streaming operations where order matters and backpressure is needed. - 69: */ - 70: export class AsyncQueue { - 71: private tail: Promise = Promise.resolve(); - 72: private depth = 0; - 73: private readonly maxDepth: number; - 74: private readonly label: string; - 75: private readonly onOverflow?: () => void; - 76: private readonly onError?: (error: Error, depth: number) => void; - 77: - 78: /** - 79: * Create an async queue. - 80: * - 81: * @param options - Queue configuration - 82: */ - 83: constructor(options: { - 84: /** Descriptive label for logging. */ - 85: label: string; - 86: /** Maximum queue depth before rejecting. Defaults to 100. */ - 87: maxDepth?: number; - 88: /** Callback when queue overflows (depth exceeds maxDepth). */ - 89: onOverflow?: () => void; - 90: /** Callback when an operation fails. */ - 91: onError?: (error: Error, depth: number) => void; - 92: }) { - 93: this.label = options.label; - 94: this.maxDepth = options.maxDepth ?? 100; - 95: this.onOverflow = options.onOverflow; - 96: this.onError = options.onError; - 97: } - 98: - 99: /** -100: * Current queue depth. -101: */ -102: get currentDepth(): number { -103: return this.depth; -104: } -105: -106: /** -107: * Whether the queue is at capacity. -108: */ -109: get isAtCapacity(): boolean { -110: return this.depth >= this.maxDepth; -111: } -112: -113: /** -114: * Enqueue an operation. -115: * -116: * @param fn - Async function to execute -117: * @returns true if enqueued, false if queue is at capacity -118: */ -119: enqueue(fn: () => Promise): boolean { -120: if (this.depth >= this.maxDepth) { -121: this.onOverflow?.(); -122: addClientLog({ -123: level: 'warning', -124: source: 'app', -125: message: `[async-queue] ${this.label} overflow - operation rejected`, -126: metadata: { label: this.label, depth: String(this.depth), maxDepth: String(this.maxDepth) }, -127: }); -128: return false; -129: } -130: -131: this.depth++; -132: this.tail = this.tail.then( -133: async () => { -134: try { -135: await fn(); -136: } catch (err) { -137: const error = err instanceof Error ? err : new Error(String(err)); -138: this.onError?.(error, this.depth); -139: addClientLog({ -140: level: 'error', -141: source: 'app', -142: message: `[async-queue] ${this.label} operation failed`, -143: details: error.message, -144: metadata: { label: this.label, depth: String(this.depth) }, -145: }); -146: } finally { -147: this.depth--; -148: } -149: }, -150: // Also handle rejections in the chain itself -151: async () => { -152: this.depth--; -153: } -154: ); -155: return true; -156: } -157: -158: /** -159: * Wait for all pending operations to complete. -160: */ -161: async drain(): Promise { -162: await this.tail; -163: } -164: -165: /** -166: * Clear the queue without waiting for pending operations. -167: * Note: Operations already in flight will still complete. -168: */ -169: clear(): void { -170: this.depth = 0; -171: this.tail = Promise.resolve(); -172: } -173: } -174: -175: /** -176: * Streaming queue specialized for audio chunk transmission. -177: * -178: * Extends AsyncQueue with: -179: * - Consecutive failure tracking -180: * - Error threshold notifications -181: * - Success counter reset -182: */ -183: export class StreamingQueue extends AsyncQueue { -184: private consecutiveFailures = 0; -185: private readonly failureThreshold: number; -186: private readonly onThresholdReached?: (failures: number) => void; -187: private hasNotifiedThreshold = false; -188: -189: constructor(options: { -190: label: string; -191: maxDepth?: number; -192: /** Number of consecutive failures before triggering notification. Defaults to 3. */ -193: failureThreshold?: number; -194: /** Callback when failure threshold is reached. */ -195: onThresholdReached?: (failures: number) => void; -196: onOverflow?: () => void; -197: }) { -198: super({ -199: label: options.label, -200: maxDepth: options.maxDepth, -201: onOverflow: options.onOverflow, -202: onError: (error, depth) => this.handleError(error, depth), -203: }); -204: this.failureThreshold = options.failureThreshold ?? 3; -205: this.onThresholdReached = options.onThresholdReached; -206: } -207: -208: private handleError(_error: Error, _depth: number): void { -209: this.consecutiveFailures++; -210: if ( -211: this.consecutiveFailures >= this.failureThreshold && -212: !this.hasNotifiedThreshold -213: ) { -214: this.hasNotifiedThreshold = true; -215: this.onThresholdReached?.(this.consecutiveFailures); -216: } -217: } -218: -219: /** -220: * Enqueue an operation and reset failure counter on success. -221: */ -222: enqueueWithSuccessReset(fn: () => Promise): boolean { -223: return this.enqueue(async () => { -224: await fn(); -225: // Reset on success -226: this.consecutiveFailures = 0; -227: this.hasNotifiedThreshold = false; -228: }); -229: } -230: -231: /** -232: * Current consecutive failure count. -233: */ -234: get failures(): number { -235: return this.consecutiveFailures; -236: } -237: -238: /** -239: * Reset failure tracking state. -240: */ -241: resetFailures(): void { -242: this.consecutiveFailures = 0; -243: this.hasNotifiedThreshold = false; -244: } -245: } -```` - -## File: client/src/lib/ai-providers/constants.ts -````typescript - 1: /** - 2: * AI Provider constants and configuration. - 3: */ - 4: - 5: import { PROVIDER_ENDPOINTS } from '@/lib/config'; - 6: - 7: import type { FetchModelsResult, ProviderOption } from './types'; - 8: - 9: /** AI provider options for summarization. */ -10: export const AI_PROVIDERS: ProviderOption[] = [ -11: { value: 'openai', label: 'OpenAI', defaultUrl: PROVIDER_ENDPOINTS.openai }, -12: { value: 'anthropic', label: 'Anthropic', defaultUrl: PROVIDER_ENDPOINTS.anthropic }, -13: { value: 'google', label: 'Google AI', defaultUrl: PROVIDER_ENDPOINTS.google }, -14: { value: 'azure', label: 'Azure OpenAI', defaultUrl: PROVIDER_ENDPOINTS.azure }, -15: { value: 'ollama', label: 'Ollama (Local)', defaultUrl: PROVIDER_ENDPOINTS.ollama }, -16: { value: 'custom', label: 'Custom', defaultUrl: '' }, -17: ]; -18: -19: /** Transcription provider options. */ -20: export const TRANSCRIPTION_PROVIDERS: ProviderOption[] = [ -21: { value: 'deepgram', label: 'Deepgram', defaultUrl: PROVIDER_ENDPOINTS.deepgram }, -22: { value: 'elevenlabs', label: 'ElevenLabs', defaultUrl: PROVIDER_ENDPOINTS.elevenlabs }, -23: { value: 'whisper', label: 'OpenAI Whisper', defaultUrl: PROVIDER_ENDPOINTS.openai }, -24: { value: 'azure', label: 'Azure Speech', defaultUrl: PROVIDER_ENDPOINTS.azureSpeech }, -25: { value: 'custom', label: 'Custom', defaultUrl: '' }, -26: ]; -27: -28: // Provider API versions -29: -30: export const ANTHROPIC_API_VERSION = '2023-06-01'; -31: export const AZURE_OPENAI_API_VERSION = '2023-03-15-preview'; -32: -33: // Result helpers to reduce repetition -34: -35: /** Create an error result. */ -36: export const errorResult = (error: string): FetchModelsResult => ({ -37: success: false, -38: models: [], -39: error, -40: }); -41: -42: /** Create a success result. */ -43: export const successResult = ( -44: models: FetchModelsResult['models'], -45: error?: string -46: ): FetchModelsResult => ({ -47: success: true, -48: models, -49: error, -50: }); -51: -52: /** Create a CORS warning result with cached models. */ -53: export const corsWarning = ( -54: models: ReadonlyArray -55: ): FetchModelsResult => -56: successResult([...models], 'Could not verify API key (CORS). Using cached model list.'); -```` - -## File: client/src/lib/ai-providers/fetch-models.ts -````typescript - 1: /** - 2: * Model fetching functions for AI providers. - 3: */ - 4: - 5: import { extractErrorMessage, getErrorMessage, isRecord } from '@/api/helpers'; - 6: import type { AIProviderType, ModelCatalogEntry, TranscriptionProviderType } from '@/api/types'; - 7: - 8: import { - 9: ANTHROPIC_API_VERSION, - 10: AZURE_OPENAI_API_VERSION, - 11: errorResult, - 12: successResult, - 13: } from './constants'; - 14: import { - 15: getCachedModelCatalog, - 16: isModelCatalogFresh, - 17: setCachedModelCatalog, - 18: } from './model-catalog-cache'; - 19: import { - 20: dedupeAndSortModels, - 21: extractModelEntries, - 22: filterGoogleModel, - 23: filterOpenAIModel, - 24: type ModelCatalogType, - 25: } from './model-catalog-utils'; - 26: import type { FetchModelsResult } from './types'; - 27: - 28: type ConfigType = ModelCatalogType; - 29: - 30: type FetchModelsOptions = { - 31: forceRefresh?: boolean; - 32: }; - 33: - 34: const AZURE_SPEECH_API_VERSIONS = ['v3.2', 'v3.1']; - 35: - 36: /** Fetch models from OpenAI-compatible API. */ - 37: async function fetchOpenAIModels( - 38: baseUrl: string, - 39: apiKey: string, - 40: type: ConfigType - 41: ): Promise { - 42: try { - 43: const response = await fetch(`${baseUrl}/models`, { - 44: headers: { - 45: Authorization: `Bearer ${apiKey}`, - 46: }, - 47: }); - 48: - 49: if (!response.ok) { - 50: const errorPayload: unknown = await response.json().catch(() => null); - 51: return errorResult(getErrorMessage(errorPayload) || `HTTP ${response.status}`); - 52: } - 53: - 54: const data: unknown = await response.json(); - 55: const items = isRecord(data) && Array.isArray(data.data) ? data.data : []; - 56: const models = extractModelEntries(items, ['id']) - 57: .filter((model) => filterOpenAIModel(model.id, type)); - 58: - 59: return successResult(dedupeAndSortModels(models)); - 60: } catch (error: unknown) { - 61: return errorResult(extractErrorMessage(error, 'Failed to fetch models')); - 62: } - 63: } - 64: - 65: /** Fetch Anthropic models. */ - 66: async function fetchAnthropicModels(baseUrl: string, apiKey: string): Promise { - 67: try { - 68: const response = await fetch(`${baseUrl}/models`, { - 69: headers: { - 70: 'x-api-key': apiKey, - 71: 'anthropic-version': ANTHROPIC_API_VERSION, - 72: }, - 73: }); - 74: - 75: if (!response.ok) { - 76: return errorResult(`HTTP ${response.status}`); - 77: } - 78: - 79: const data: unknown = await response.json(); - 80: const items = isRecord(data) && Array.isArray(data.data) ? data.data : []; - 81: const models = extractModelEntries(items, ['id', 'name']); - 82: return successResult(dedupeAndSortModels(models)); - 83: } catch (error: unknown) { - 84: return errorResult(extractErrorMessage(error, 'Failed to fetch models')); - 85: } - 86: } - 87: - 88: /** Fetch models from Google AI API. */ - 89: async function fetchGoogleModels( - 90: baseUrl: string, - 91: apiKey: string, - 92: type: ConfigType - 93: ): Promise { - 94: try { - 95: const response = await fetch(`${baseUrl}/models?key=${apiKey}`); - 96: if (!response.ok) { - 97: return errorResult(`HTTP ${response.status}`); - 98: } - 99: const data: unknown = await response.json(); -100: const items = isRecord(data) && Array.isArray(data.models) ? data.models : []; -101: const models = extractModelEntries(items, ['name'], (name) => name.replace(/^models\//, '')) -102: .filter((model) => filterGoogleModel(model.id, type)); -103: return successResult(dedupeAndSortModels(models)); -104: } catch (error: unknown) { -105: return errorResult(extractErrorMessage(error, 'Failed to fetch models')); -106: } -107: } -108: -109: /** Fetch models from local Ollama instance. */ -110: async function fetchOllamaModels(baseUrl: string): Promise { -111: try { -112: const response = await fetch(`${baseUrl}/tags`); -113: if (!response.ok) { -114: return errorResult(`HTTP ${response.status}`); -115: } -116: const data: unknown = await response.json(); -117: const items = isRecord(data) && Array.isArray(data.models) ? data.models : []; -118: const models = extractModelEntries(items, ['name']); -119: return successResult(dedupeAndSortModels(models)); -120: } catch (error: unknown) { -121: return errorResult(extractErrorMessage(error, 'Could not connect to Ollama')); -122: } -123: } -124: -125: function extractDeepgramModelEntries(data: unknown): ModelCatalogEntry[] { -126: if (!isRecord(data)) { -127: return []; -128: } -129: const directArray = Array.isArray(data.models) ? data.models : []; -130: if (directArray.length > 0) { -131: return extractModelEntries(directArray, ['name', 'id', 'model_id']); -132: } -133: const sttModels = Array.isArray(data.stt) ? data.stt : []; -134: if (sttModels.length > 0) { -135: return extractModelEntries(sttModels, ['name', 'id', 'model_id']); -136: } -137: if (isRecord(data.models) && Array.isArray(data.models.stt)) { -138: return extractModelEntries(data.models.stt, ['name', 'id', 'model_id']); -139: } -140: return []; -141: } -142: -143: /** Fetch Deepgram models. */ -144: async function fetchDeepgramModels(baseUrl: string, apiKey: string): Promise { -145: const base = baseUrl.replace(/\/v1\/?$/, ''); -146: try { -147: const projectsResponse = await fetch(`${base}/v1/projects`, { -148: headers: { Authorization: `Token ${apiKey}` }, -149: }); -150: if (projectsResponse.ok) { -151: const projectsData: unknown = await projectsResponse.json(); -152: const projects = isRecord(projectsData) && Array.isArray(projectsData.projects) -153: ? projectsData.projects -154: : []; -155: const projectId = projects -156: .map((project) => (isRecord(project) ? project.project_id ?? project.id : null)) -157: .find((id): id is string => typeof id === 'string' && id.length > 0); -158: if (projectId) { -159: const modelsResponse = await fetch(`${base}/v1/projects/${projectId}/models`, { -160: headers: { Authorization: `Token ${apiKey}` }, -161: }); -162: if (modelsResponse.ok) { -163: const modelsData: unknown = await modelsResponse.json(); -164: const models = extractDeepgramModelEntries(modelsData); -165: return successResult(dedupeAndSortModels(models)); -166: } -167: } -168: } -169: } catch { -170: // Ignore and fall back to public models endpoint. -171: } -172: -173: try { -174: const response = await fetch(`${base}/v1/models`, { -175: headers: { Authorization: `Token ${apiKey}` }, -176: }); -177: if (!response.ok) { -178: return errorResult(`HTTP ${response.status}`); -179: } -180: const data: unknown = await response.json(); -181: const models = extractDeepgramModelEntries(data); -182: return successResult(dedupeAndSortModels(models)); -183: } catch (error: unknown) { -184: return errorResult(extractErrorMessage(error, 'Failed to fetch Deepgram models')); -185: } -186: } -187: -188: /** Fetch models from ElevenLabs API. */ -189: async function fetchElevenLabsModels( -190: baseUrl: string, -191: apiKey: string -192: ): Promise { -193: try { -194: const response = await fetch(`${baseUrl}/models`, { headers: { 'xi-api-key': apiKey } }); -195: if (!response.ok) { -196: return errorResult(`HTTP ${response.status}`); -197: } -198: const data: unknown = await response.json(); -199: const items = Array.isArray(data) ? data : []; -200: const models: ModelCatalogEntry[] = []; -201: for (const item of items) { -202: if (!isRecord(item) || item.can_do_text_to_speech === false) { -203: continue; -204: } -205: models.push(...extractModelEntries([item], ['model_id', 'id', 'name'])); -206: } -207: return successResult(dedupeAndSortModels(models)); -208: } catch (error: unknown) { -209: return errorResult(extractErrorMessage(error, 'Failed to fetch models')); -210: } -211: } -212: -213: /** Fetch Azure OpenAI deployment list. */ -214: async function fetchAzureOpenAIModels( -215: baseUrl: string, -216: apiKey: string -217: ): Promise { -218: try { -219: const response = await fetch( -220: `${baseUrl.replace(/\/+$/, '')}/openai/deployments?api-version=${AZURE_OPENAI_API_VERSION}`, -221: { -222: headers: { 'api-key': apiKey }, -223: } -224: ); -225: if (!response.ok) { -226: return errorResult(`HTTP ${response.status}`); -227: } -228: const data: unknown = await response.json(); -229: const items = isRecord(data) && Array.isArray(data.data) -230: ? data.data -231: : isRecord(data) && Array.isArray(data.value) -232: ? data.value -233: : []; -234: const models = extractModelEntries(items, ['id', 'name', 'deployment_name']); -235: return successResult(dedupeAndSortModels(models)); -236: } catch (error: unknown) { -237: return errorResult(extractErrorMessage(error, 'Failed to fetch deployments')); -238: } -239: } -240: -241: /** Fetch Azure Speech-to-text models. */ -242: async function fetchAzureSpeechModels( -243: baseUrl: string, -244: apiKey: string -245: ): Promise { -246: const trimmed = baseUrl.replace(/\/+$/, ''); -247: for (const version of AZURE_SPEECH_API_VERSIONS) { -248: try { -249: const response = await fetch(`${trimmed}/speechtotext/${version}/models/base`, { -250: headers: { 'Ocp-Apim-Subscription-Key': apiKey }, -251: }); -252: if (!response.ok) { -253: continue; -254: } -255: const data: unknown = await response.json(); -256: const items = isRecord(data) && Array.isArray(data.values) -257: ? data.values -258: : isRecord(data) && Array.isArray(data.models) -259: ? data.models -260: : []; -261: const models = extractModelEntries(items, ['shortName', 'name', 'id']); -262: return successResult(dedupeAndSortModels(models)); -263: } catch { -264: // Try the next API version. -265: } -266: } -267: return errorResult('Azure Speech endpoint not reachable'); -268: } -269: -270: async function fetchModelsFromProvider( -271: provider: AIProviderType | TranscriptionProviderType, -272: baseUrl: string, -273: apiKey: string, -274: type: ConfigType -275: ): Promise { -276: switch (provider) { -277: case 'openai': -278: case 'whisper': -279: case 'custom': -280: return fetchOpenAIModels(baseUrl, apiKey, type); -281: -282: case 'anthropic': -283: return fetchAnthropicModels(baseUrl, apiKey); -284: -285: case 'google': -286: return fetchGoogleModels(baseUrl, apiKey, type); -287: -288: case 'ollama': -289: return fetchOllamaModels(baseUrl); -290: -291: case 'azure': -292: return type === 'transcription' -293: ? fetchAzureSpeechModels(baseUrl, apiKey) -294: : fetchAzureOpenAIModels(baseUrl, apiKey); -295: -296: case 'deepgram': -297: return fetchDeepgramModels(baseUrl, apiKey); -298: -299: case 'elevenlabs': -300: return fetchElevenLabsModels(baseUrl, apiKey); -301: -302: default: -303: return errorResult('Unknown provider'); -304: } -305: } -306: -307: /** Fetch available models from the specified AI provider (with caching). */ -308: export async function fetchModels( -309: provider: AIProviderType | TranscriptionProviderType, -310: baseUrl: string, -311: apiKey: string, -312: type: ConfigType, -313: options: FetchModelsOptions = {} -314: ): Promise { -315: const normalizedBaseUrl = baseUrl.trim().replace(/\/+$/, ''); -316: if (!normalizedBaseUrl) { -317: return errorResult('Base URL is required'); -318: } -319: const requiresApiKey = provider !== 'ollama' && provider !== 'custom'; -320: if (requiresApiKey && !apiKey) { -321: return errorResult('API key is required'); -322: } -323: -324: const cached = getCachedModelCatalog(provider, normalizedBaseUrl, type); -325: const hasFreshCache = cached ? isModelCatalogFresh(cached.updated_at) : false; -326: if (!options.forceRefresh && cached && hasFreshCache) { -327: return { -328: success: true, -329: models: cached.models, -330: source: 'cache', -331: updatedAt: cached.updated_at, -332: }; -333: } -334: -335: const result = await fetchModelsFromProvider(provider, normalizedBaseUrl, apiKey, type); -336: if (result.success) { -337: const updatedAt = Date.now(); -338: const models = dedupeAndSortModels(result.models); -339: setCachedModelCatalog(provider, normalizedBaseUrl, type, models, updatedAt); -340: return { -341: ...result, -342: models, -343: source: 'network', -344: updatedAt, -345: stale: false, -346: }; -347: } -348: -349: if (cached) { -350: return { -351: success: true, -352: models: cached.models, -353: error: result.error, -354: source: 'cache', -355: updatedAt: cached.updated_at, -356: stale: true, -357: }; -358: } -359: -360: return { -361: ...result, -362: source: 'network', -363: }; -364: } -```` - -## File: client/src/lib/ai-providers/index.ts -````typescript - 1: /** - 2: * AI Provider utilities package. - 3: * - 4: * Provides functions for fetching models and testing endpoints - 5: * across various AI providers (OpenAI, Anthropic, Google, etc.). - 6: */ - 7: - 8: // Types - 9: export type { FetchModelsResult, ProviderOption, TestEndpointResult } from './types'; -10: -11: // Constants -12: export { AI_PROVIDERS, TRANSCRIPTION_PROVIDERS } from './constants'; -13: -14: // Functions -15: export { fetchModels } from './fetch-models'; -16: export { testEndpoint } from './test-endpoint'; -```` - -## File: client/src/lib/ai-providers/model-catalog-cache.ts -````typescript - 1: /** - 2: * Local cache for AI model catalogs. - 3: */ - 4: - 5: import { isRecord } from '@/api/helpers'; - 6: import type { ModelCatalogEntry } from '@/api/types'; - 7: import { addClientLog } from '@/lib/client-logs'; - 8: import { MODEL_CATALOG_CACHE_KEY } from '@/lib/storage-keys'; - 9: - 10: const CACHE_VERSION = 1; - 11: const MAX_CACHE_ENTRIES = 50; - 12: const CACHE_TTL_MS = 24 * 60 * 60 * 1000; - 13: - 14: interface ModelCatalogCacheEntry { - 15: models: ModelCatalogEntry[]; - 16: updated_at: number; - 17: } - 18: - 19: interface ModelCatalogCache { - 20: version: number; - 21: entries: Record; - 22: } - 23: - 24: function normalizeBaseUrl(baseUrl: string): string { - 25: const trimmed = baseUrl.trim().replace(/\/+$/, ''); - 26: return trimmed.toLowerCase(); - 27: } - 28: - 29: export function buildModelCatalogKey( - 30: provider: string, - 31: baseUrl: string, - 32: configType: 'transcription' | 'summary' | 'embedding' - 33: ): string { - 34: return [provider, normalizeBaseUrl(baseUrl), configType].join('::'); - 35: } - 36: - 37: function coerceModelEntry(value: unknown): ModelCatalogEntry | null { - 38: if (typeof value === 'string') { - 39: const id = value.trim(); - 40: return id ? { id } : null; - 41: } - 42: if (isRecord(value) && typeof value.id === 'string') { - 43: const id = value.id.trim(); - 44: if (!id) { - 45: return null; - 46: } - 47: const cost = typeof value.cost === 'string' ? value.cost : undefined; - 48: return cost ? { id, cost } : { id }; - 49: } - 50: return null; - 51: } - 52: - 53: function coerceCacheEntry(value: unknown): ModelCatalogCacheEntry | null { - 54: if (!isRecord(value)) { - 55: return null; - 56: } - 57: const updatedAt = value.updated_at; - 58: const modelsRaw = value.models; - 59: if (typeof updatedAt !== 'number' || !Array.isArray(modelsRaw)) { - 60: return null; - 61: } - 62: const models = modelsRaw - 63: .map((entry) => coerceModelEntry(entry)) - 64: .filter((entry): entry is ModelCatalogEntry => entry !== null); - 65: return { models, updated_at: updatedAt }; - 66: } - 67: - 68: function loadCache(): ModelCatalogCache { - 69: if (typeof window === 'undefined') { - 70: return { version: CACHE_VERSION, entries: {} }; - 71: } - 72: try { - 73: const stored = localStorage.getItem(MODEL_CATALOG_CACHE_KEY); - 74: if (!stored) { - 75: return { version: CACHE_VERSION, entries: {} }; - 76: } - 77: const decoded: unknown = JSON.parse(stored); - 78: if (!isRecord(decoded) || decoded.version !== CACHE_VERSION || !isRecord(decoded.entries)) { - 79: return { version: CACHE_VERSION, entries: {} }; - 80: } - 81: const entries: Record = {}; - 82: for (const [key, value] of Object.entries(decoded.entries)) { - 83: const entry = coerceCacheEntry(value); - 84: if (entry) { - 85: entries[key] = entry; - 86: } - 87: } - 88: return { version: CACHE_VERSION, entries }; - 89: } catch (error) { - 90: addClientLog({ - 91: level: 'warning', - 92: source: 'app', - 93: message: 'Failed to read model catalog cache', - 94: details: error instanceof Error ? error.message : String(error), - 95: metadata: { context: 'ai_model_cache_load' }, - 96: }); - 97: return { version: CACHE_VERSION, entries: {} }; - 98: } - 99: } -100: -101: function saveCache(cache: ModelCatalogCache): void { -102: if (typeof window === 'undefined') { -103: return; -104: } -105: try { -106: localStorage.setItem(MODEL_CATALOG_CACHE_KEY, JSON.stringify(cache)); -107: } catch (error) { -108: addClientLog({ -109: level: 'warning', -110: source: 'app', -111: message: 'Failed to write model catalog cache', -112: details: error instanceof Error ? error.message : String(error), -113: metadata: { context: 'ai_model_cache_save' }, -114: }); -115: } -116: } -117: -118: export function isModelCatalogFresh(updatedAt: number): boolean { -119: return Date.now() - updatedAt <= CACHE_TTL_MS; -120: } -121: -122: export function getCachedModelCatalog( -123: provider: string, -124: baseUrl: string, -125: configType: 'transcription' | 'summary' | 'embedding' -126: ): ModelCatalogCacheEntry | null { -127: const cache = loadCache(); -128: const key = buildModelCatalogKey(provider, baseUrl, configType); -129: return cache.entries[key] ?? null; -130: } -131: -132: export function setCachedModelCatalog( -133: provider: string, -134: baseUrl: string, -135: configType: 'transcription' | 'summary' | 'embedding', -136: models: ModelCatalogEntry[], -137: updatedAt: number = Date.now() -138: ): void { -139: const cache = loadCache(); -140: const key = buildModelCatalogKey(provider, baseUrl, configType); -141: cache.entries[key] = { models, updated_at: updatedAt }; -142: -143: const keys = Object.keys(cache.entries); -144: if (keys.length > MAX_CACHE_ENTRIES) { -145: const sortedKeys = keys.sort( -146: (a, b) => cache.entries[a].updated_at - cache.entries[b].updated_at -147: ); -148: const extra = sortedKeys.length - MAX_CACHE_ENTRIES; -149: for (const entryKey of sortedKeys.slice(0, extra)) { -150: delete cache.entries[entryKey]; -151: } -152: } -153: -154: saveCache(cache); -155: } -156: -157: export function clearModelCatalogCache(): void { -158: if (typeof window === 'undefined') { -159: return; -160: } -161: try { -162: localStorage.removeItem(MODEL_CATALOG_CACHE_KEY); -163: } catch (error) { -164: addClientLog({ -165: level: 'warning', -166: source: 'app', -167: message: 'Failed to clear model catalog cache', -168: details: error instanceof Error ? error.message : String(error), -169: metadata: { context: 'ai_model_cache_clear' }, -170: }); -171: } -172: } -```` - -## File: client/src/lib/ai-providers/test-endpoint.ts -````typescript - 1: /** - 2: * Endpoint testing functions for AI providers. - 3: */ - 4: - 5: import { HttpStatus } from '@/api/constants'; - 6: import { extractErrorMessage, getErrorMessage } from '@/api/helpers'; - 7: import type { AIProviderType, TranscriptionProviderType } from '@/api/types'; - 8: - 9: import { ANTHROPIC_API_VERSION } from './constants'; - 10: import type { TestEndpointResult } from './types'; - 11: - 12: /** Test OpenAI endpoint connectivity. */ - 13: async function testOpenAIEndpoint( - 14: baseUrl: string, - 15: apiKey: string, - 16: model: string, - 17: type: 'transcription' | 'summary' | 'embedding', - 18: startTime: number - 19: ): Promise { - 20: if (type === 'embedding') { - 21: const response = await fetch(`${baseUrl}/embeddings`, { - 22: method: 'POST', - 23: headers: { - 24: Authorization: `Bearer ${apiKey}`, - 25: 'Content-Type': 'application/json', - 26: }, - 27: body: JSON.stringify({ - 28: model: model, - 29: input: 'test', - 30: }), - 31: }); - 32: - 33: if (!response.ok) { - 34: const errorPayload: unknown = await response.json().catch(() => null); - 35: return { - 36: success: false, - 37: message: getErrorMessage(errorPayload) || `HTTP ${response.status}`, - 38: }; - 39: } - 40: - 41: return { - 42: success: true, - 43: message: 'Embedding endpoint is working', - 44: latency: Date.now() - startTime, - 45: }; - 46: } else if (type === 'summary') { - 47: const response = await fetch(`${baseUrl}/chat/completions`, { - 48: method: 'POST', - 49: headers: { - 50: Authorization: `Bearer ${apiKey}`, - 51: 'Content-Type': 'application/json', - 52: }, - 53: body: JSON.stringify({ - 54: model: model, - 55: messages: [{ role: 'user', content: 'Say "test successful" in exactly 2 words.' }], - 56: max_tokens: 10, - 57: }), - 58: }); - 59: - 60: if (!response.ok) { - 61: const errorPayload: unknown = await response.json().catch(() => null); - 62: return { - 63: success: false, - 64: message: getErrorMessage(errorPayload) || `HTTP ${response.status}`, - 65: }; - 66: } - 67: - 68: return { - 69: success: true, - 70: message: 'Chat completion endpoint is working', - 71: latency: Date.now() - startTime, - 72: }; - 73: } - 74: - 75: return { success: false, message: 'Unsupported test for this provider' }; - 76: } - 77: - 78: /** Test Anthropic endpoint connectivity. */ - 79: async function testAnthropicEndpoint( - 80: baseUrl: string, - 81: apiKey: string, - 82: model: string, - 83: startTime: number - 84: ): Promise { - 85: const response = await fetch(`${baseUrl}/messages`, { - 86: method: 'POST', - 87: headers: { - 88: 'x-api-key': apiKey, - 89: 'anthropic-version': ANTHROPIC_API_VERSION, - 90: 'Content-Type': 'application/json', - 91: }, - 92: body: JSON.stringify({ - 93: model: model, - 94: max_tokens: 10, - 95: messages: [{ role: 'user', content: 'Say "ok"' }], - 96: }), - 97: }); - 98: - 99: // Rate limited but key is valid -100: if (response.ok || response.status === HttpStatus.TOO_MANY_REQUESTS) { -101: return { -102: success: true, -103: message: -104: response.status === HttpStatus.TOO_MANY_REQUESTS -105: ? 'API key valid (rate limited)' -106: : 'Connection successful', -107: latency: Date.now() - startTime, -108: }; -109: } -110: -111: return { -112: success: false, -113: message: `HTTP ${response.status}`, -114: }; -115: } -116: -117: /** Test Google AI endpoint connectivity. */ -118: async function testGoogleEndpoint( -119: baseUrl: string, -120: apiKey: string, -121: model: string, -122: startTime: number -123: ): Promise { -124: const response = await fetch(`${baseUrl}/models/${model}:generateContent?key=${apiKey}`, { -125: method: 'POST', -126: headers: { -127: 'Content-Type': 'application/json', -128: }, -129: body: JSON.stringify({ -130: contents: [{ parts: [{ text: 'Say ok' }] }], -131: }), -132: }); -133: -134: if (!response.ok) { -135: const errorPayload: unknown = await response.json().catch(() => null); -136: return { -137: success: false, -138: message: getErrorMessage(errorPayload) || `HTTP ${response.status}`, -139: }; -140: } -141: -142: return { -143: success: true, -144: message: 'Google AI endpoint is working', -145: latency: Date.now() - startTime, -146: }; -147: } -148: -149: /** Test Ollama endpoint connectivity. */ -150: async function testOllamaEndpoint( -151: baseUrl: string, -152: model: string, -153: startTime: number -154: ): Promise { -155: const response = await fetch(`${baseUrl}/generate`, { -156: method: 'POST', -157: headers: { -158: 'Content-Type': 'application/json', -159: }, -160: body: JSON.stringify({ -161: model: model, -162: prompt: 'Say ok', -163: stream: false, -164: }), -165: }); -166: -167: if (!response.ok) { -168: return { -169: success: false, -170: message: `HTTP ${response.status} - Is Ollama running?`, -171: }; -172: } -173: -174: return { -175: success: true, -176: message: 'Ollama is responding', -177: latency: Date.now() - startTime, -178: }; -179: } -180: -181: /** Test Deepgram endpoint connectivity. */ -182: async function testDeepgramEndpoint( -183: baseUrl: string, -184: apiKey: string, -185: startTime: number -186: ): Promise { -187: const response = await fetch(`${baseUrl.replace('/v1', '')}/v1/projects`, { -188: headers: { -189: Authorization: `Token ${apiKey}`, -190: }, -191: }); -192: -193: if (response.ok) { -194: return { -195: success: true, -196: message: 'Deepgram API key is valid', -197: latency: Date.now() - startTime, -198: }; -199: } -200: -201: return { -202: success: false, -203: message: 'Invalid API key or endpoint', -204: }; -205: } -206: -207: /** Test ElevenLabs endpoint connectivity. */ -208: async function testElevenLabsEndpoint( -209: baseUrl: string, -210: apiKey: string, -211: startTime: number -212: ): Promise { -213: const response = await fetch(`${baseUrl}/user`, { -214: headers: { -215: 'xi-api-key': apiKey, -216: }, -217: }); -218: -219: if (response.ok) { -220: return { -221: success: true, -222: message: 'ElevenLabs API key is valid', -223: latency: Date.now() - startTime, -224: }; -225: } -226: -227: return { -228: success: false, -229: message: 'Invalid API key', -230: }; -231: } -232: -233: /** Test custom/unknown endpoint connectivity. */ -234: async function testCustomEndpoint( -235: baseUrl: string, -236: apiKey: string, -237: startTime: number -238: ): Promise { -239: const response = await fetch(`${baseUrl}/models`, { -240: headers: { -241: Authorization: `Bearer ${apiKey}`, -242: }, -243: }); -244: -245: if (response.ok) { -246: return { -247: success: true, -248: message: 'Endpoint is responding', -249: latency: Date.now() - startTime, -250: }; -251: } -252: -253: return { -254: success: false, -255: message: `HTTP ${response.status}`, -256: }; -257: } -258: -259: /** Test endpoint connectivity for the specified AI provider. */ -260: export async function testEndpoint( -261: provider: AIProviderType | TranscriptionProviderType, -262: baseUrl: string, -263: apiKey: string, -264: model: string, -265: type: 'transcription' | 'summary' | 'embedding' -266: ): Promise { -267: const startTime = Date.now(); -268: -269: try { -270: switch (provider) { -271: case 'openai': -272: case 'whisper': -273: return await testOpenAIEndpoint(baseUrl, apiKey, model, type, startTime); -274: -275: case 'anthropic': -276: return await testAnthropicEndpoint(baseUrl, apiKey, model, startTime); -277: -278: case 'google': -279: return await testGoogleEndpoint(baseUrl, apiKey, model, startTime); -280: -281: case 'ollama': -282: return await testOllamaEndpoint(baseUrl, model, startTime); -283: -284: case 'deepgram': -285: return await testDeepgramEndpoint(baseUrl, apiKey, startTime); -286: -287: case 'elevenlabs': -288: return await testElevenLabsEndpoint(baseUrl, apiKey, startTime); -289: -290: case 'custom': -291: return type === 'transcription' -292: ? await testCustomEndpoint(baseUrl, apiKey, startTime) -293: : await testOpenAIEndpoint(baseUrl, apiKey, model, type, startTime); -294: -295: default: -296: return await testCustomEndpoint(baseUrl, apiKey, startTime); -297: } -298: } catch (error) { -299: // CORS errors are common when testing from browser -300: const errorMessage = extractErrorMessage(error, 'Unknown error'); -301: const isCorsError = -302: error instanceof TypeError || -303: errorMessage.includes('fetch') || -304: errorMessage.includes('Failed to fetch') || -305: errorMessage.includes('NetworkError') || -306: errorMessage.includes('CORS'); -307: -308: if (isCorsError) { -309: return { -310: success: true, -311: message: -312: 'Cannot verify from browser (CORS). Configuration saved - ' + -313: 'will work when used by the app.', -314: latency: undefined, -315: }; -316: } -317: return { -318: success: false, -319: message: errorMessage, -320: }; -321: } -322: } -```` - -## File: client/src/lib/ai-providers/types.ts -````typescript - 1: /** - 2: * AI Provider type definitions. - 3: */ - 4: - 5: import type { ModelCatalogEntry } from '@/api/types'; - 6: - 7: /** Provider configuration option. */ - 8: export interface ProviderOption { - 9: value: string; -10: label: string; -11: defaultUrl: string; -12: } -13: -14: export type ModelCatalogSource = 'cache' | 'network'; -15: -16: /** Result from fetching models. */ -17: export interface FetchModelsResult { -18: success: boolean; -19: models: ModelCatalogEntry[]; -20: error?: string; -21: source?: ModelCatalogSource; -22: updatedAt?: number; -23: stale?: boolean; -24: } -25: -26: /** Result from testing an endpoint. */ -27: export interface TestEndpointResult { -28: success: boolean; -29: message: string; -30: latency?: number; -31: } -```` - -## File: client/src/lib/cache/meeting-cache.test.ts -````typescript - 1: import { describe, expect, it, afterEach, vi } from 'vitest'; - 2: import { meetingCache, STALENESS_THRESHOLD_MS, type CacheEvent } from '@/lib/cache/meeting-cache'; - 3: import type { Meeting } from '@/api/types'; - 4: - 5: afterEach(() => { - 6: meetingCache.clear(); - 7: }); - 8: - 9: const sampleMeeting = (id: string, createdAt: number): Meeting => ({ - 10: id, - 11: title: `Meeting ${id}`, - 12: state: 'completed', - 13: created_at: createdAt, - 14: duration_seconds: 120, - 15: segments: [], - 16: metadata: {}, - 17: }); - 18: - 19: describe('meetingCache', () => { - 20: it('stores and retrieves meetings', () => { - 21: const meeting = sampleMeeting('m1', 100); - 22: meetingCache.cacheMeeting(meeting); - 23: - 24: const stored = meetingCache.getMeeting('m1'); - 25: expect(stored?.id).toBe('m1'); - 26: }); - 27: - 28: it('lists meetings in newest-first order', () => { - 29: meetingCache.cacheMeeting(sampleMeeting('m1', 100)); - 30: meetingCache.cacheMeeting(sampleMeeting('m2', 200)); - 31: - 32: const meetings = meetingCache.listMeetings(); - 33: expect(meetings[0]?.id).toBe('m2'); - 34: expect(meetings[1]?.id).toBe('m1'); - 35: }); - 36: - 37: it('handles invalid stored cache data', () => { - 38: localStorage.setItem('noteflow_meeting_cache_v1', '{not json'); - 39: const meetings = meetingCache.listMeetings(); - 40: expect(meetings).toHaveLength(0); - 41: }); - 42: - 43: it('ignores cache when version mismatches', () => { - 44: localStorage.setItem( - 45: 'noteflow_meeting_cache_v1', - 46: JSON.stringify({ version: 999, updated_at: 0, meetings: {}, meeting_ids: [] }) - 47: ); - 48: const meetings = meetingCache.listMeetings(); - 49: expect(meetings).toHaveLength(0); - 50: }); - 51: - 52: it('merges incoming meeting data without losing existing segments', () => { - 53: const meeting = { - 54: ...sampleMeeting('m1', 100), - 55: segments: [{ segment_id: 1, text: 'Hello', start_time: 0, end_time: 1, words: [] }], - 56: summary: { executive_summary: 'Summary' } as unknown as Meeting['summary'], - 57: }; - 58: meetingCache.cacheMeeting(meeting); - 59: - 60: const incoming = { ...sampleMeeting('m1', 200), segments: [] }; - 61: meetingCache.cacheMeeting(incoming); - 62: - 63: const stored = meetingCache.getMeeting('m1'); - 64: expect(stored?.segments?.length).toBe(1); - 65: expect(stored?.summary).toBeDefined(); - 66: }); - 67: - 68: it('removes meetings and clears cache', () => { - 69: meetingCache.cacheMeeting(sampleMeeting('m1', 100)); - 70: meetingCache.removeMeeting('m1'); - 71: expect(meetingCache.getMeeting('m1')).toBeNull(); - 72: - 73: meetingCache.cacheMeeting(sampleMeeting('m2', 200)); - 74: meetingCache.clear(); - 75: expect(meetingCache.listMeetings()).toHaveLength(0); - 76: }); - 77: }); - 78: - 79: // Sprint GAP-002: State Synchronization tests - 80: describe('meetingCache TTL and invalidation', () => { - 81: it('tracks cache age for meetings', () => { - 82: const meeting = sampleMeeting('m1', 100); - 83: meetingCache.cacheMeeting(meeting); - 84: - 85: const age = meetingCache.getCacheAge('m1'); - 86: expect(age).not.toBeNull(); - 87: expect(age).toBeGreaterThanOrEqual(0); - 88: expect(age).toBeLessThan(100); // Should be very recent - 89: }); - 90: - 91: it('returns null cache age for non-existent meetings', () => { - 92: const age = meetingCache.getCacheAge('non-existent'); - 93: expect(age).toBeNull(); - 94: }); - 95: - 96: it('detects stale meetings after threshold', () => { - 97: const meeting = sampleMeeting('m1', 100); - 98: meetingCache.cacheMeeting(meeting); - 99: -100: // Immediately after caching, should not be stale -101: expect(meetingCache.isStale('m1')).toBe(false); -102: -103: // Mock time passage -104: const now = Date.now(); -105: vi.spyOn(Date, 'now').mockReturnValue(now + STALENESS_THRESHOLD_MS + 1); -106: -107: expect(meetingCache.isStale('m1')).toBe(true); -108: -109: vi.restoreAllMocks(); -110: }); -111: -112: it('reports non-cached meetings as stale', () => { -113: expect(meetingCache.isStale('non-existent')).toBe(true); -114: }); -115: -116: it('invalidates all cached meetings', () => { -117: meetingCache.cacheMeeting(sampleMeeting('m1', 100)); -118: meetingCache.cacheMeeting(sampleMeeting('m2', 200)); -119: -120: // Meetings exist but are now stale -121: meetingCache.invalidateAll(); -122: -123: expect(meetingCache.isStale('m1')).toBe(true); -124: expect(meetingCache.isStale('m2')).toBe(true); -125: -126: // Data is still accessible -127: expect(meetingCache.getMeeting('m1')).not.toBeNull(); -128: }); -129: -130: it('invalidates specific meeting', () => { -131: meetingCache.cacheMeeting(sampleMeeting('m1', 100)); -132: meetingCache.cacheMeeting(sampleMeeting('m2', 200)); -133: -134: meetingCache.invalidate('m1'); -135: -136: expect(meetingCache.isStale('m1')).toBe(true); -137: expect(meetingCache.isStale('m2')).toBe(false); -138: }); -139: -140: it('returns stale meeting IDs', () => { -141: const now = Date.now(); -142: meetingCache.cacheMeeting(sampleMeeting('m1', 100)); -143: -144: // Advance time past threshold -145: vi.spyOn(Date, 'now').mockReturnValue(now + STALENESS_THRESHOLD_MS + 1); -146: meetingCache.cacheMeeting(sampleMeeting('m2', 200)); -147: -148: const staleIds = meetingCache.getStaleIds(); -149: expect(staleIds).toContain('m1'); -150: expect(staleIds).not.toContain('m2'); -151: -152: vi.restoreAllMocks(); -153: }); -154: }); -155: -156: describe('meetingCache server state versioning', () => { -157: it('stores and retrieves server state version', () => { -158: expect(meetingCache.getServerStateVersion()).toBeUndefined(); -159: -160: meetingCache.updateServerStateVersion(1); -161: expect(meetingCache.getServerStateVersion()).toBe(1); -162: }); -163: -164: it('invalidates cache on version mismatch', () => { -165: meetingCache.cacheMeeting(sampleMeeting('m1', 100)); -166: meetingCache.updateServerStateVersion(1); -167: -168: // Same version - no invalidation -169: const invalidated1 = meetingCache.updateServerStateVersion(1); -170: expect(invalidated1).toBe(false); -171: expect(meetingCache.isStale('m1')).toBe(false); -172: -173: // Different version - invalidation -174: const invalidated2 = meetingCache.updateServerStateVersion(2); -175: expect(invalidated2).toBe(true); -176: expect(meetingCache.isStale('m1')).toBe(true); -177: }); -178: }); -179: -180: describe('meetingCache event subscription', () => { -181: it('emits events on cache operations', () => { -182: const events: CacheEvent[] = []; -183: const unsubscribe = meetingCache.subscribe((event) => events.push(event)); -184: -185: meetingCache.cacheMeeting(sampleMeeting('m1', 100)); -186: meetingCache.invalidateAll(); -187: meetingCache.clear(); -188: -189: expect(events.some((e) => e.type === 'refreshed')).toBe(true); -190: expect(events.some((e) => e.type === 'invalidated')).toBe(true); -191: -192: unsubscribe(); -193: }); -194: -195: it('unsubscribes correctly', () => { -196: const events: CacheEvent[] = []; -197: const unsubscribe = meetingCache.subscribe((event) => events.push(event)); -198: -199: meetingCache.cacheMeeting(sampleMeeting('m1', 100)); -200: expect(events.length).toBeGreaterThan(0); -201: -202: const countBefore = events.length; -203: unsubscribe(); -204: meetingCache.cacheMeeting(sampleMeeting('m2', 200)); -205: -206: expect(events.length).toBe(countBefore); -207: }); -208: }); -209: -210: describe('meetingCache statistics', () => { -211: it('provides accurate cache statistics', () => { -212: meetingCache.cacheMeeting(sampleMeeting('m1', 100)); -213: meetingCache.cacheMeeting(sampleMeeting('m2', 200)); -214: meetingCache.updateServerStateVersion(5); -215: -216: const stats = meetingCache.getStats(); -217: expect(stats.totalMeetings).toBe(2); -218: expect(stats.staleCount).toBe(0); -219: expect(stats.oldestCacheMs).toBeGreaterThanOrEqual(0); -220: expect(stats.serverStateVersion).toBe(5); -221: }); -222: }); -```` - -## File: client/src/lib/cache/meeting-cache.ts -````typescript - 1: // Meeting cache for offline read-only mode with TTL-based invalidation - 2: // (Sprint GAP-002: State Synchronization) - 3: - 4: import type { Meeting } from '@/api/types'; - 5: import { addClientLog } from '@/lib/client-logs'; - 6: import { MEETING_CACHE_KEY } from '@/lib/storage-keys'; - 7: const CACHE_VERSION = 1; - 8: - 9: /** TTL for cached meetings in milliseconds (30 seconds for active meeting refresh). */ - 10: export const MEETING_CACHE_TTL_MS = 30_000; - 11: - 12: /** Staleness threshold: meetings older than this are considered potentially stale. */ - 13: export const STALENESS_THRESHOLD_MS = 30_000; - 14: - 15: interface MeetingCacheData { - 16: version: number; - 17: updated_at: number; - 18: meetings: Record; - 19: meeting_ids: string[]; - 20: /** Track when each meeting was cached for TTL (Sprint GAP-002). */ - 21: cached_times: Record; - 22: /** Server state version for cache invalidation (Sprint GAP-002). */ - 23: server_state_version?: number; - 24: } - 25: - 26: /** Event types for cache lifecycle notifications. */ - 27: export type CacheEventType = 'invalidated' | 'refreshed' | 'stale'; - 28: - 29: /** Cache event payload. */ - 30: export interface CacheEvent { - 31: type: CacheEventType; - 32: meetingId?: string; - 33: reason?: string; - 34: } - 35: - 36: /** Cache event listener type. */ - 37: export type CacheEventListener = (event: CacheEvent) => void; - 38: - 39: /** Listeners for cache events. */ - 40: const cacheListeners = new Set(); - 41: - 42: /** Emit cache event to all listeners. */ - 43: const emitCacheEvent = (event: CacheEvent): void => { - 44: for (const listener of cacheListeners) { - 45: try { - 46: listener(event); - 47: } catch (error) { - 48: addClientLog({ - 49: level: 'warning', - 50: source: 'sync', - 51: message: 'Cache event listener error', - 52: details: error instanceof Error ? error.message : String(error), - 53: metadata: { event_type: event.type, meeting_id: event.meetingId ?? '' }, - 54: }); - 55: } - 56: } - 57: }; - 58: - 59: const emptyCache = (): MeetingCacheData => ({ - 60: version: CACHE_VERSION, - 61: updated_at: Date.now(), - 62: meetings: {}, - 63: meeting_ids: [], - 64: cached_times: {}, - 65: }); - 66: - 67: const loadCache = (): MeetingCacheData => { - 68: if (typeof window === 'undefined') { - 69: return emptyCache(); - 70: } - 71: try { - 72: const stored = localStorage.getItem(MEETING_CACHE_KEY); - 73: if (!stored) { - 74: return emptyCache(); - 75: } - 76: const storedCache = JSON.parse(stored) as MeetingCacheData; - 77: if (storedCache?.version !== CACHE_VERSION) { - 78: return emptyCache(); - 79: } - 80: return { - 81: ...storedCache, - 82: meetings: storedCache.meetings ?? {}, - 83: meeting_ids: Array.isArray(storedCache.meeting_ids) ? storedCache.meeting_ids : [], - 84: cached_times: storedCache.cached_times ?? {}, - 85: }; - 86: } catch { - 87: return emptyCache(); - 88: } - 89: }; - 90: - 91: const saveCache = (cache: MeetingCacheData): void => { - 92: if (typeof window === 'undefined') { - 93: return; - 94: } - 95: try { - 96: localStorage.setItem(MEETING_CACHE_KEY, JSON.stringify(cache)); - 97: } catch (error) { - 98: addClientLog({ - 99: level: 'warning', -100: source: 'sync', -101: message: 'Meeting cache write failed', -102: details: error instanceof Error ? error.message : String(error), -103: metadata: { context: 'meeting_cache_persist' }, -104: }); -105: } -106: }; -107: -108: const mergeMeeting = (existing: Meeting | undefined, incoming: Meeting): Meeting => { -109: if (!existing) { -110: return incoming; -111: } -112: return { -113: ...existing, -114: ...incoming, -115: segments: incoming.segments?.length ? incoming.segments : existing.segments, -116: summary: incoming.summary ?? existing.summary, -117: }; -118: }; -119: -120: const sortMeetingIds = (meetings: Record): string[] => -121: Object.values(meetings) -122: .sort((a, b) => b.created_at - a.created_at) -123: .map((m) => m.id); -124: -125: export const meetingCache = { -126: /** -127: * Cache multiple meetings with timestamp tracking. -128: */ -129: cacheMeetings(meetings: Meeting[]): void { -130: const cache = loadCache(); -131: const now = Date.now(); -132: for (const meeting of meetings) { -133: cache.meetings[meeting.id] = mergeMeeting(cache.meetings[meeting.id], meeting); -134: cache.cached_times[meeting.id] = now; -135: } -136: cache.meeting_ids = sortMeetingIds(cache.meetings); -137: cache.updated_at = now; -138: saveCache(cache); -139: emitCacheEvent({ type: 'refreshed', reason: 'batch_cache' }); -140: }, -141: -142: /** -143: * Cache a single meeting with timestamp tracking. -144: */ -145: cacheMeeting(meeting: Meeting): void { -146: const cache = loadCache(); -147: const now = Date.now(); -148: cache.meetings[meeting.id] = mergeMeeting(cache.meetings[meeting.id], meeting); -149: cache.cached_times[meeting.id] = now; -150: cache.meeting_ids = sortMeetingIds(cache.meetings); -151: cache.updated_at = now; -152: saveCache(cache); -153: emitCacheEvent({ type: 'refreshed', meetingId: meeting.id }); -154: }, -155: -156: /** -157: * Remove a meeting from the cache. -158: */ -159: removeMeeting(meetingId: string): void { -160: const cache = loadCache(); -161: delete cache.meetings[meetingId]; -162: delete cache.cached_times[meetingId]; -163: cache.meeting_ids = cache.meeting_ids.filter((id) => id !== meetingId); -164: cache.updated_at = Date.now(); -165: saveCache(cache); -166: }, -167: -168: /** -169: * List all cached meetings (not filtered by staleness). -170: */ -171: listMeetings(): Meeting[] { -172: const cache = loadCache(); -173: return cache.meeting_ids.map((id) => cache.meetings[id]).filter(Boolean); -174: }, -175: -176: /** -177: * Get a cached meeting by ID. -178: */ -179: getMeeting(meetingId: string): Meeting | null { -180: const cache = loadCache(); -181: return cache.meetings[meetingId] ?? null; -182: }, -183: -184: /** -185: * Check if a cached meeting is stale (older than STALENESS_THRESHOLD_MS). -186: * (Sprint GAP-002: State Synchronization) -187: */ -188: isStale(meetingId: string): boolean { -189: const cache = loadCache(); -190: const cachedAt = cache.cached_times[meetingId]; -191: if (!cachedAt) { -192: return true; // No cache time means it's stale -193: } -194: return Date.now() - cachedAt > STALENESS_THRESHOLD_MS; -195: }, -196: -197: /** -198: * Get the cache age for a meeting in milliseconds. -199: * Returns null if meeting is not cached. -200: * (Sprint GAP-002: State Synchronization) -201: */ -202: getCacheAge(meetingId: string): number | null { -203: const cache = loadCache(); -204: const cachedAt = cache.cached_times[meetingId]; -205: if (!cachedAt) { -206: return null; -207: } -208: return Date.now() - cachedAt; -209: }, -210: -211: /** -212: * Get all stale meeting IDs (older than threshold). -213: * (Sprint GAP-002: State Synchronization) -214: */ -215: getStaleIds(): string[] { -216: const cache = loadCache(); -217: const now = Date.now(); -218: return cache.meeting_ids.filter((id) => { -219: const cachedAt = cache.cached_times[id]; -220: return !cachedAt || now - cachedAt > STALENESS_THRESHOLD_MS; -221: }); -222: }, -223: -224: /** -225: * Invalidate all cached meetings (for reconnection scenarios). -226: * Does not delete data, but marks everything as stale. -227: * (Sprint GAP-002: State Synchronization) -228: */ -229: invalidateAll(): void { -230: const cache = loadCache(); -231: // Clear all cached times to mark everything as stale -232: cache.cached_times = {}; -233: cache.updated_at = Date.now(); -234: saveCache(cache); -235: emitCacheEvent({ type: 'invalidated', reason: 'invalidate_all' }); -236: }, -237: -238: /** -239: * Invalidate a specific meeting's cache. -240: * (Sprint GAP-002: State Synchronization) -241: */ -242: invalidate(meetingId: string): void { -243: const cache = loadCache(); -244: delete cache.cached_times[meetingId]; -245: cache.updated_at = Date.now(); -246: saveCache(cache); -247: emitCacheEvent({ type: 'invalidated', meetingId, reason: 'single_invalidation' }); -248: }, -249: -250: /** -251: * Update server state version for cache versioning. -252: * If version differs from cached, invalidates all data. -253: * (Sprint GAP-002: State Synchronization) -254: */ -255: updateServerStateVersion(version: number): boolean { -256: const cache = loadCache(); -257: const previousVersion = cache.server_state_version; -258: -259: if (previousVersion !== undefined && previousVersion !== version) { -260: // Version mismatch - invalidate all cached data -261: cache.cached_times = {}; -262: cache.server_state_version = version; -263: cache.updated_at = Date.now(); -264: saveCache(cache); -265: emitCacheEvent({ type: 'invalidated', reason: 'version_mismatch' }); -266: return true; // Indicates cache was invalidated -267: } -268: -269: // Update version without invalidation -270: cache.server_state_version = version; -271: saveCache(cache); -272: return false; -273: }, -274: -275: /** -276: * Get the last known server state version. -277: */ -278: getServerStateVersion(): number | undefined { -279: const cache = loadCache(); -280: return cache.server_state_version; -281: }, -282: -283: /** -284: * Clear all cached data. -285: */ -286: clear(): void { -287: saveCache(emptyCache()); -288: emitCacheEvent({ type: 'invalidated', reason: 'clear' }); -289: }, -290: -291: /** -292: * Subscribe to cache events (invalidation, refresh, stale detection). -293: * Returns unsubscribe function. -294: * (Sprint GAP-002: State Synchronization) -295: */ -296: subscribe(listener: CacheEventListener): () => void { -297: cacheListeners.add(listener); -298: return () => cacheListeners.delete(listener); -299: }, -300: -301: /** -302: * Get cache statistics for debugging/monitoring. -303: */ -304: getStats(): { -305: totalMeetings: number; -306: staleCount: number; -307: oldestCacheMs: number | null; -308: serverStateVersion: number | undefined; -309: } { -310: const cache = loadCache(); -311: const staleIds = this.getStaleIds(); -312: const now = Date.now(); -313: -314: let oldestCacheMs: number | null = null; -315: for (const id of cache.meeting_ids) { -316: const cachedAt = cache.cached_times[id]; -317: if (cachedAt) { -318: const age = now - cachedAt; -319: if (oldestCacheMs === null || age > oldestCacheMs) { -320: oldestCacheMs = age; -321: } -322: } -323: } -324: -325: return { -326: totalMeetings: cache.meeting_ids.length, -327: staleCount: staleIds.length, -328: oldestCacheMs, -329: serverStateVersion: cache.server_state_version, -330: }; -331: }, -332: }; -```` - -## File: client/src/lib/config/app-config.ts -````typescript - 1: /** - 2: * Centralized application configuration. - 3: * - 4: * All timing constants, thresholds, and configurable values should be defined here - 5: * to avoid magic numbers scattered throughout the codebase. - 6: */ - 7: - 8: import { Timing } from '@/api/constants'; - 9: import { MINUTES_PER_DAY } from '@/lib/time'; - 10: import { INITIAL_SYNC_DELAY_MS } from '@/lib/timing-constants'; - 11: - 12: /** - 13: * Polling and interval configuration (in milliseconds). - 14: */ - 15: export const PollingConfig = { - 16: /** Diarization job polling interval (initial) */ - 17: DIARIZATION_INITIAL_MS: Timing.TWO_SECONDS_MS, - 18: /** Diarization job polling interval (maximum) */ - 19: DIARIZATION_MAX_MS: Timing.TEN_SECONDS_MS, - 20: /** Diarization initial retry delay */ - 21: DIARIZATION_RETRY_DELAY_MS: Timing.ONE_SECOND_MS, - 22: /** Maximum duration for diarization polling (aligned with server timeout) */ - 23: DIARIZATION_MAX_DURATION_MS: Timing.FIVE_MINUTES_MS, - 24: /** Meeting reminders check interval */ - 25: REMINDERS_CHECK_MS: Timing.THIRTY_SECONDS_MS, - 26: /** Integration sync status check interval */ - 27: SYNC_STATUS_CHECK_MS: INITIAL_SYNC_DELAY_MS, - 28: /** Audio device test delay */ - 29: AUDIO_DEVICE_TEST_DELAY_MS: 500, - 30: } as const; - 31: - 32: /** - 33: * UI feedback delays (in milliseconds). - 34: */ - 35: export const UiDelays = { - 36: /** Delay after successful test endpoint */ - 37: TEST_SUCCESS_FEEDBACK_MS: 1500, - 38: /** Short delay for button feedback */ - 39: BUTTON_FEEDBACK_MS: 500, - 40: /** Toast notification duration */ - 41: TOAST_DURATION_MS: 3000, - 42: /** Debounce delay for search input */ - 43: SEARCH_DEBOUNCE_MS: 300, - 44: } as const; - 45: - 46: /** - 47: * Audio configuration. - 48: */ - 49: export const AudioConfig = { - 50: /** FFT size for audio analyser */ - 51: FFT_SIZE: 2 ** 8, - 52: /** Test tone frequency (Hz) - A4 note */ - 53: TEST_TONE_FREQUENCY: 440, - 54: /** Recording timer update interval */ - 55: TIMER_INTERVAL_MS: 1000, - 56: } as const; - 57: - 58: /** - 59: * Meeting reminder options (in minutes). - 60: */ - 61: export const ReminderOptions = { - 62: /** Available reminder time options (minutes before meeting) */ - 63: MINUTES: [30, 15, 10, 5] as const, - 64: /** Default reminder times */ - 65: DEFAULTS: [15, 5] as const, - 66: } as const; - 67: - 68: /** - 69: * Sync interval defaults (in minutes). - 70: */ - 71: export const SyncIntervals = { - 72: /** Default calendar sync interval */ - 73: CALENDAR_DEFAULT_MINUTES: 15, - 74: /** Default PKM sync interval */ - 75: PKM_DEFAULT_MINUTES: 30, - 76: /** Minimum sync interval */ - 77: MINIMUM_MINUTES: 5, - 78: /** Maximum sync interval */ - 79: MAXIMUM_MINUTES: MINUTES_PER_DAY, - 80: } as const; - 81: - 82: /** - 83: * Performance thresholds for analytics. - 84: */ - 85: export const PerformanceThresholds = { - 86: /** Latency thresholds [good, warning] in ms */ - 87: LATENCY_MS: [100, 200] as const, - 88: /** FPS baseline */ - 89: FPS_BASELINE: 60, - 90: /** Health score thresholds [good, warning] */ - 91: HEALTH_SCORE: [70, 40] as const, - 92: } as const; - 93: - 94: /** - 95: * Pagination defaults. - 96: */ - 97: export const PaginationDefaults = { - 98: /** Default items per page for meetings list */ - 99: MEETINGS_PER_PAGE: 100, -100: /** Maximum items per page */ -101: MAX_PER_PAGE: 1000, -102: /** Items per page for upcoming meetings widget */ -103: UPCOMING_MEETINGS_PER_PAGE: 3, -104: } as const; -105: -106: /** -107: * Crypto/security configuration. -108: * These are security-relevant and should only be changed with care. -109: */ -110: export const CryptoConfig = { -111: /** PBKDF2 iterations for key derivation */ -112: PBKDF2_ITERATIONS: 100000, -113: /** Hash algorithm for PBKDF2 */ -114: HASH_ALGORITHM: 'SHA-256', -115: /** AES key length (bits) */ -116: AES_KEY_LENGTH: 32 * 8, -117: /** Salt length (bytes) */ -118: SALT_LENGTH: 16, -119: /** IV length for AES-GCM (bytes) */ -120: IV_LENGTH: 12, -121: } as const; -122: -123: /** -124: * Responsive breakpoints (in pixels). -125: */ -126: export const Breakpoints = { -127: /** Mobile breakpoint */ -128: MOBILE: 768, -129: /** Tablet breakpoint */ -130: TABLET: 1024, -131: /** Desktop breakpoint */ -132: DESKTOP: 1280, -133: } as const; -134: -135: /** -136: * Integration defaults. -137: */ -138: export const IntegrationDefaults = { -139: /** Default SMTP port */ -140: SMTP_PORT: 587, -141: /** Default SMTP TLS */ -142: SMTP_TLS: true, -143: } as const; -144: -145: /** -146: * Development mode configuration. -147: * (Sprint GAP-007: Simulation Mode Clarity) -148: * -149: * Controls visibility of developer-only features like simulation mode. -150: * In production builds, VITE_DEV_MODE should be 'false' to hide simulation toggle. -151: */ -152: export const DevModeConfig = { -153: /** -154: * Check if development mode is enabled. -155: * Uses VITE_DEV_MODE environment variable, defaults to true in development. -156: */ -157: isDevMode(): boolean { -158: // Default to true if not set (development), false if explicitly set to 'false' -159: const envValue = import.meta.env.VITE_DEV_MODE; -160: if (envValue === undefined || envValue === '') { -161: // Not set - default based on Vite mode -162: return import.meta.env.DEV; -163: } -164: return envValue !== 'false'; -165: }, -166: -167: /** -168: * Check if simulation mode toggle should be visible. -169: * Only visible in dev mode builds. -170: */ -171: isSimulationEnabled(): boolean { -172: return this.isDevMode(); -173: }, -174: } as const; -```` - -## File: client/src/lib/config/config.test.ts -````typescript - 1: import { describe, expect, it } from 'vitest'; - 2: import { - 3: DEFAULT_EXPORT_LOCATION, - 4: DEFAULT_TRANSCRIPTION_CONFIG, - 5: PROVIDER_ENDPOINTS, - 6: getProviderEndpoint, - 7: getDefaultServerUrl, - 8: buildServerUrl, - 9: CryptoConfig, -10: } from '@/lib/config'; -11: -12: describe('config exports', () => { -13: it('exposes provider endpoints and supports template substitutions', () => { -14: expect(PROVIDER_ENDPOINTS.openai).toContain('openai'); -15: expect(getProviderEndpoint('azure', { resource: 'demo' })).toBe( -16: 'https://demo.openai.azure.com' -17: ); -18: }); -19: -20: it('returns provider endpoints without substitutions', () => { -21: expect(getProviderEndpoint('openai')).toBe(PROVIDER_ENDPOINTS.openai); -22: }); -23: -24: it('exposes default server helpers', () => { -25: expect(getDefaultServerUrl()).toBe('127.0.0.1:50051'); -26: expect(buildServerUrl('example.com', '1234')).toBe('http://example.com:1234'); -27: }); -28: -29: it('exports default preferences values', () => { -30: expect(DEFAULT_TRANSCRIPTION_CONFIG.provider).toBe('deepgram'); -31: expect(DEFAULT_TRANSCRIPTION_CONFIG.base_url).toBe(PROVIDER_ENDPOINTS.deepgram); -32: expect(DEFAULT_EXPORT_LOCATION).toContain('NoteFlow'); -33: expect(CryptoConfig.AES_KEY_LENGTH).toBe(256); -34: }); -35: }); -```` - -## File: client/src/lib/config/defaults.ts -````typescript - 1: /** - 2: * Default configuration values for user preferences. - 3: */ - 4: - 5: import type { AIProviderConfig, TranscriptionProviderConfig } from '@/api/types'; - 6: import { PROVIDER_ENDPOINTS } from './provider-endpoints'; - 7: - 8: export const DEFAULT_TRANSCRIPTION_CONFIG: TranscriptionProviderConfig = { - 9: provider: 'deepgram', -10: base_url: PROVIDER_ENDPOINTS.deepgram, -11: api_key: '', -12: selected_model: 'nova-2', -13: available_models: [{ id: 'nova-2' }], -14: models_last_updated: null, -15: models_source: null, -16: last_tested: null, -17: test_status: 'untested', -18: }; -19: -20: export const DEFAULT_SUMMARY_CONFIG: AIProviderConfig = { -21: provider: 'openai', -22: base_url: PROVIDER_ENDPOINTS.openai, -23: api_key: '', -24: selected_model: 'gpt-4o', -25: available_models: [{ id: 'gpt-4o' }], -26: models_last_updated: null, -27: models_source: null, -28: last_tested: null, -29: test_status: 'untested', -30: }; -31: -32: export const DEFAULT_EMBEDDING_CONFIG: AIProviderConfig = { -33: provider: 'openai', -34: base_url: PROVIDER_ENDPOINTS.openai, -35: api_key: '', -36: selected_model: 'text-embedding-3-small', -37: available_models: [{ id: 'text-embedding-3-small' }], -38: models_last_updated: null, -39: models_source: null, -40: last_tested: null, -41: test_status: 'untested', -42: }; -43: -44: export const DEFAULT_AUDIO_DEVICES = { -45: input_device_id: '', -46: output_device_id: '', -47: input_device_name: '', -48: output_device_name: '', -49: system_device_id: '', -50: dual_capture_enabled: false, -51: mic_gain: 1, -52: system_gain: 1, -53: }; -54: -55: export const DEFAULT_RECORDING_APP_POLICY = { -56: allowlist: [], -57: denylist: [], -58: }; -59: -60: export const DEFAULT_AI_TEMPLATE = { -61: tone: 'professional' as const, -62: format: 'bullet_points' as const, -63: verbosity: 'balanced' as const, -64: custom_instructions: '', -65: }; -66: -67: export const DEFAULT_SYNC_PREFERENCES = { -68: auto_sync: false, -69: sync_interval_minutes: 30, -70: sync_on_meeting_end: true, -71: notify_on_sync: true, -72: notify_on_conflict: true, -73: notify_on_error: true, -74: }; -75: -76: export const DEFAULT_EXPORT_LOCATION = '~/Documents/NoteFlow'; -```` - -## File: client/src/lib/config/index.ts -````typescript -1: /** -2: * Centralized configuration module. -3: * Import all config values from here. -4: */ -5: -6: export * from './app-config'; -7: export * from './defaults'; -8: export * from './provider-endpoints'; -9: export * from './server'; -```` - -## File: client/src/lib/config/provider-endpoints.ts -````typescript - 1: /** - 2: * Centralized provider endpoint configuration. - 3: * All API endpoint URLs should be imported from here. - 4: * - 5: * Environment variables (via Vite): - 6: * VITE_OLLAMA_ENDPOINT: Override default Ollama server URL - 7: */ - 8: - 9: /** Get Ollama endpoint from environment or use default */ -10: function getOllamaEndpoint(): string { -11: // Vite environment variables are available at build time -12: const endpoint = import.meta.env?.VITE_OLLAMA_ENDPOINT as string | undefined; -13: return endpoint ?? 'http://localhost:11434/api'; -14: } -15: -16: /** AI Provider API endpoints */ -17: export const PROVIDER_ENDPOINTS = { -18: openai: 'https://api.openai.com/v1', -19: anthropic: 'https://api.anthropic.com/v1', -20: google: 'https://generativelanguage.googleapis.com/v1', -21: azure: 'https://{resource}.openai.azure.com', -22: ollama: getOllamaEndpoint(), -23: deepgram: 'https://api.deepgram.com/v1', -24: elevenlabs: 'https://api.elevenlabs.io/v1', -25: azureSpeech: 'https://{region}.api.cognitive.microsoft.com', -26: } as const; -27: -28: export type ProviderEndpointKey = keyof typeof PROVIDER_ENDPOINTS; -29: -30: /** External documentation and resource links */ -31: export const EXTERNAL_LINKS = { -32: huggingfaceTokens: 'https://huggingface.co/settings/tokens', -33: ollamaWebsite: 'https://ollama.com', -34: } as const; -35: -36: /** Get endpoint URL for a provider, with optional template substitution */ -37: export function getProviderEndpoint( -38: provider: ProviderEndpointKey, -39: substitutions?: Record -40: ): string { -41: let url = PROVIDER_ENDPOINTS[provider]; -42: if (substitutions) { -43: for (const [key, value] of Object.entries(substitutions)) { -44: url = url.replace(`{${key}}`, value); -45: } -46: } -47: return url; -48: } -```` - -## File: client/src/lib/config/server.ts -````typescript - 1: /** - 2: * Server connection defaults. - 3: */ - 4: - 5: import { FIVE_SECONDS_MS } from '@/lib/timing-constants'; - 6: - 7: export const SERVER_DEFAULTS = { - 8: PROTOCOL: 'http', - 9: HOST: '127.0.0.1', -10: PORT: 50051, -11: TIMEOUT_MS: FIVE_SECONDS_MS, -12: } as const; -13: -14: export function getDefaultServerUrl(): string { -15: return `${SERVER_DEFAULTS.HOST}:${SERVER_DEFAULTS.PORT}`; -16: } -17: -18: export function buildServerUrl(host: string, port: string): string { -19: return `${SERVER_DEFAULTS.PROTOCOL}://${host}:${port}`; -20: } -```` - -## File: client/src/lib/preferences/constants.ts -````typescript - 1: /** - 2: * Constants and default values for user preferences. - 3: */ - 4: - 5: import { generateId } from '@/api/mock-data'; - 6: import type { AIFormat, AITone, AIVerbosity, UserPreferences } from '@/api/types'; - 7: import { - 8: DEFAULT_AI_TEMPLATE, - 9: DEFAULT_AUDIO_DEVICES, -10: DEFAULT_EMBEDDING_CONFIG, -11: DEFAULT_EXPORT_LOCATION, -12: DEFAULT_RECORDING_APP_POLICY, -13: DEFAULT_SUMMARY_CONFIG, -14: DEFAULT_TRANSCRIPTION_CONFIG, -15: SERVER_DEFAULTS, -16: } from '@/lib/config'; -17: import { DEFAULT_INTEGRATIONS } from '@/lib/default-integrations'; -18: -19: export const AI_TONE_VALUES: readonly AITone[] = [ -20: 'professional', -21: 'casual', -22: 'technical', -23: 'friendly', -24: ]; -25: -26: export const AI_FORMAT_VALUES: readonly AIFormat[] = [ -27: 'bullet_points', -28: 'narrative', -29: 'structured', -30: 'concise', -31: ]; -32: -33: export const AI_VERBOSITY_VALUES: readonly AIVerbosity[] = [ -34: 'minimal', -35: 'balanced', -36: 'detailed', -37: 'comprehensive', -38: ]; -39: -40: export const defaultPreferences: UserPreferences = { -41: server_host: SERVER_DEFAULTS.HOST, -42: server_port: String(SERVER_DEFAULTS.PORT), -43: server_address_customized: false, -44: server_address_customized_at: null, -45: preferences_updated_at: undefined, -46: simulate_transcription: false, -47: skip_simulation_confirmation: false, -48: default_export_format: 'markdown', -49: default_export_location: DEFAULT_EXPORT_LOCATION, -50: completed_tasks: [], -51: speaker_names: [], -52: tags: [ -53: { id: generateId(), name: 'Important', color: 'primary', meeting_ids: [] }, -54: { id: generateId(), name: 'Follow-up', color: 'warning', meeting_ids: [] }, -55: { id: generateId(), name: 'Personal', color: 'info', meeting_ids: [] }, -56: ], -57: ai_config: { -58: transcription: DEFAULT_TRANSCRIPTION_CONFIG, -59: summary: DEFAULT_SUMMARY_CONFIG, -60: embedding: DEFAULT_EMBEDDING_CONFIG, -61: }, -62: audio_devices: DEFAULT_AUDIO_DEVICES, -63: recording_app_policy: DEFAULT_RECORDING_APP_POLICY, -64: ai_template: DEFAULT_AI_TEMPLATE, -65: integrations: DEFAULT_INTEGRATIONS, -66: sync_notifications: { -67: enabled: true, -68: notify_on_success: false, -69: notify_on_error: true, -70: notify_via_toast: true, -71: notify_via_email: false, -72: quiet_hours_enabled: false, -73: }, -74: sync_scheduler_paused: false, -75: sync_history: [], -76: meetings_project_scope: 'active', -77: meetings_project_ids: [], -78: tasks_project_scope: 'active', -79: tasks_project_ids: [], -80: }; -```` - -## File: client/src/lib/preferences/index.ts -````typescript - 1: /** - 2: * User preferences store with localStorage persistence. - 3: * (Sprint GAP-002: State Synchronization - Integration validation events) - 4: * - 5: * This module is organized as a package for maintainability: - 6: * - types.ts: Type definitions - 7: * - constants.ts: Default values and enums - 8: * - core.ts: Core helper functions - 9: * - storage.ts: localStorage operations -10: * - tauri.ts: Tauri-specific persistence -11: * - integrations.ts: Integration helpers -12: * - validation-events.ts: Integration validation event system -13: * - api.ts: Main preferences API -14: */ -15: -16: // Re-export main API and hydration utilities -17: export { isHydrated, preferences, waitForHydration } from './api'; -18: -19: // Re-export types -20: export type { -21: AIConfigType, -22: AudioDeviceType, -23: ConfigForType, -24: LocalPreferencesCache, -25: ServerAddressOverride, -26: UpdateAIConfigOptions, -27: } from './types'; -28: -29: // Re-export validation events -30: export { -31: emitValidationEvent, -32: subscribeToValidationEvents, -33: } from './validation-events'; -34: export type { -35: IntegrationValidationEvent, -36: IntegrationValidationEventType, -37: IntegrationValidationListener, -38: } from './validation-events'; -39: -40: // Re-export cache utilities -41: export { -42: getTauriExportLocationCache, -43: setTauriExportLocationCache, -44: _resetPreferencesForTesting, -45: } from './storage'; -46: -47: // Re-export constants for external use -48: export { defaultPreferences } from './constants'; -```` - -## File: client/src/lib/preferences/integrations.ts -````typescript - 1: /** - 2: * Integration-specific helpers for preferences management. - 3: */ - 4: - 5: import type { Integration } from '@/api/types'; - 6: import { DEFAULT_INTEGRATIONS } from '@/lib/default-integrations'; - 7: - 8: /** - 9: * Ensure DEFAULT_INTEGRATIONS are always present in the integrations array. -10: * Preserves config from incoming integrations but ensures defaults always exist. -11: * -12: * Merge strategy: -13: * 1. For each default integration, find matching incoming by name -14: * 2. If found, merge (default as base, incoming overwrites, fallback to default configs) -15: * 3. If not found, use default as-is -16: * 4. Append any custom integrations (not in defaults) -17: */ -18: export function mergeIntegrationsWithDefaults(incoming: Integration[]): Integration[] { -19: const incomingByName = new Map(incoming.map((i) => [i.name, i])); -20: const defaultNames = new Set(DEFAULT_INTEGRATIONS.map((d) => d.name)); -21: -22: // Merge defaults with incoming -23: const merged = DEFAULT_INTEGRATIONS.map((defaultInt) => { -24: const existing = incomingByName.get(defaultInt.name); -25: if (existing) { -26: return { -27: ...defaultInt, -28: ...existing, -29: oauth_config: existing.oauth_config || defaultInt.oauth_config, -30: email_config: existing.email_config || defaultInt.email_config, -31: calendar_config: existing.calendar_config || defaultInt.calendar_config, -32: pkm_config: existing.pkm_config || defaultInt.pkm_config, -33: webhook_config: existing.webhook_config || defaultInt.webhook_config, -34: oidc_config: existing.oidc_config || defaultInt.oidc_config, -35: }; -36: } -37: return { ...defaultInt }; -38: }); -39: -40: // Add custom integrations (not in defaults) -41: const custom = incoming.filter((i) => !defaultNames.has(i.name)); -42: -43: return [...merged, ...custom]; -44: } -45: -46: /** -47: * Reset integration state for server switch while preserving configs. -48: * - Clears integration_id (server-assigned) -49: * - Sets status to 'disconnected' -50: * - Clears error_message and last_sync -51: * - Preserves all config objects (OAuth, calendar, etc.) -52: */ -53: export function resetIntegrationsForServerSwitch(integrations: Integration[]): Integration[] { -54: const defaultNames = new Set(DEFAULT_INTEGRATIONS.map((d) => d.name)); -55: -56: // Reset default integrations with preserved configs -57: const resetDefaults = DEFAULT_INTEGRATIONS.map((defaultInt) => { -58: const existing = integrations.find((i) => i.name === defaultInt.name); -59: if (existing) { -60: return { -61: ...defaultInt, -62: ...existing, -63: integration_id: undefined, -64: status: 'disconnected' as const, -65: error_message: undefined, -66: last_sync: undefined, -67: }; -68: } -69: return { ...defaultInt }; -70: }); -71: -72: // Reset custom integrations (preserve their configs too) -73: const resetCustom = integrations -74: .filter((i) => !defaultNames.has(i.name)) -75: .map((custom) => ({ -76: ...custom, -77: integration_id: undefined, -78: status: 'disconnected' as const, -79: error_message: undefined, -80: last_sync: undefined, -81: })); -82: -83: return [...resetDefaults, ...resetCustom]; -84: } -```` - -## File: client/src/lib/preferences/storage.ts -````typescript - 1: /** - 2: * Storage and persistence helpers for preferences. - 3: */ - 4: - 5: import { isRecord } from '@/api/helpers'; - 6: import type { Integration, UserPreferences } from '@/api/types'; - 7: import { addClientLog } from '@/lib/client-logs'; - 8: import { - 9: DEFAULT_EMBEDDING_CONFIG, - 10: DEFAULT_SUMMARY_CONFIG, - 11: DEFAULT_TRANSCRIPTION_CONFIG, - 12: } from '@/lib/config'; - 13: import { - 14: PREFERENCES_KEY, - 15: PREFERENCES_LOCAL_CACHE_KEY, - 16: SERVER_ADDRESS_OVERRIDE_KEY, - 17: SERVER_ADDRESS_VALUE_KEY, - 18: MODEL_CATALOG_CACHE_KEY, - 19: } from '@/lib/storage-keys'; - 20: - 21: import { defaultPreferences } from './constants'; - 22: import { clonePreferences, isTauriRuntime, normalizePreferences, sanitizeAiTemplate } from './core'; - 23: import type { LocalPreferencesCache, ServerAddressOverride } from './types'; - 24: - 25: /** In-memory preferences cache */ - 26: let inMemoryPrefs: UserPreferences | null = null; - 27: - 28: /** Listeners for preferences changes */ - 29: const listeners = new Set<(prefs: UserPreferences) => void>(); - 30: - 31: /** - 32: * Reset in-memory preferences cache. FOR TESTING ONLY. - 33: */ - 34: export function _resetPreferencesForTesting(): void { - 35: inMemoryPrefs = null; - 36: } - 37: - 38: /** - 39: * Load server address override from localStorage. - 40: */ - 41: export function loadServerAddressOverride(): ServerAddressOverride | null { - 42: if (typeof window === 'undefined') { - 43: return null; - 44: } - 45: try { - 46: if (localStorage.getItem(SERVER_ADDRESS_OVERRIDE_KEY) !== 'true') { - 47: return null; - 48: } - 49: const raw = localStorage.getItem(SERVER_ADDRESS_VALUE_KEY); - 50: if (!raw) { - 51: return null; - 52: } - 53: const storedOverride: unknown = JSON.parse(raw); - 54: if ( - 55: !isRecord(storedOverride) || - 56: typeof storedOverride.host !== 'string' || - 57: typeof storedOverride.port !== 'string' || - 58: typeof storedOverride.updated_at !== 'number' - 59: ) { - 60: return null; - 61: } - 62: if (!storedOverride.host.trim() || !storedOverride.port.trim()) { - 63: return null; - 64: } - 65: return { - 66: host: storedOverride.host, - 67: port: storedOverride.port, - 68: updated_at: storedOverride.updated_at, - 69: }; - 70: } catch { - 71: return null; - 72: } - 73: } - 74: - 75: /** - 76: * Set or clear server address override in localStorage. - 77: */ - 78: export function setServerAddressOverride(enabled: boolean, host: string, port: string): void { - 79: try { - 80: if (enabled) { - 81: localStorage.setItem(SERVER_ADDRESS_OVERRIDE_KEY, 'true'); - 82: localStorage.setItem( - 83: SERVER_ADDRESS_VALUE_KEY, - 84: JSON.stringify({ host, port, updated_at: Date.now() }) - 85: ); - 86: } else { - 87: localStorage.removeItem(SERVER_ADDRESS_OVERRIDE_KEY); - 88: localStorage.removeItem(SERVER_ADDRESS_VALUE_KEY); - 89: } - 90: } catch (error) { - 91: addClientLog({ - 92: level: 'warning', - 93: source: 'app', - 94: message: 'Server address override storage failed', - 95: details: error instanceof Error ? error.message : String(error), - 96: metadata: { context: 'preferences_server_address_override' }, - 97: }); - 98: } - 99: } -100: -101: /** -102: * Apply local overrides (server address) to preferences. -103: */ -104: export function applyLocalOverrides(next: UserPreferences): UserPreferences { -105: if (typeof window === 'undefined') { -106: return next; -107: } -108: const override = loadServerAddressOverride(); -109: if (!override) { -110: return next; -111: } -112: return { -113: ...next, -114: server_host: override.host, -115: server_port: override.port, -116: server_address_customized: true, -117: server_address_customized_at: override.updated_at, -118: }; -119: } -120: -121: /** -122: * Load preferences from localStorage with hydration and defaults. -123: */ -124: export function loadPreferences(): UserPreferences { -125: if (inMemoryPrefs) { -126: return clonePreferences(inMemoryPrefs); -127: } -128: try { -129: const stored = localStorage.getItem(PREFERENCES_KEY); -130: if (stored) { -131: const storedRaw: unknown = JSON.parse(stored); -132: if (!isRecord(storedRaw)) { -133: return clonePreferences(defaultPreferences); -134: } -135: const storedPrefs = storedRaw as Partial; -136: -137: // Merge integrations with defaults to ensure config objects exist -138: const mergedIntegrations = defaultPreferences.integrations.map((defaultInt) => { -139: const storedIntegrations = Array.isArray(storedPrefs.integrations) -140: ? storedPrefs.integrations -141: : []; -142: const storedInt = storedIntegrations.find((i: Integration) => i.name === defaultInt.name); -143: if (storedInt) { -144: return { -145: ...defaultInt, -146: ...storedInt, -147: oauth_config: storedInt.oauth_config || defaultInt.oauth_config, -148: email_config: storedInt.email_config || defaultInt.email_config, -149: calendar_config: storedInt.calendar_config || defaultInt.calendar_config, -150: pkm_config: storedInt.pkm_config || defaultInt.pkm_config, -151: webhook_config: storedInt.webhook_config || defaultInt.webhook_config, -152: }; -153: } -154: return defaultInt; -155: }); -156: -157: // Add any custom integrations that aren't in defaults -158: const customIntegrations = ( -159: Array.isArray(storedPrefs.integrations) ? storedPrefs.integrations : [] -160: ).filter((i: Integration) => !defaultPreferences.integrations.some((d) => d.name === i.name)); -161: -162: const recordingPolicy = -163: storedPrefs.recording_app_policy && -164: typeof storedPrefs.recording_app_policy === 'object' && -165: !Array.isArray(storedPrefs.recording_app_policy) -166: ? { -167: allowlist: Array.isArray(storedPrefs.recording_app_policy.allowlist) -168: ? storedPrefs.recording_app_policy.allowlist -169: : [], -170: denylist: Array.isArray(storedPrefs.recording_app_policy.denylist) -171: ? storedPrefs.recording_app_policy.denylist -172: : [], -173: } -174: : defaultPreferences.recording_app_policy; -175: -176: const inferredCustomized = -177: typeof storedPrefs.server_address_customized === 'boolean' -178: ? storedPrefs.server_address_customized -179: : Boolean( -180: storedPrefs.server_host && -181: storedPrefs.server_port && -182: (storedPrefs.server_host !== defaultPreferences.server_host || -183: storedPrefs.server_port !== defaultPreferences.server_port) -184: ); -185: -186: const sanitizedCustomizedAt = -187: typeof storedPrefs.server_address_customized_at === 'number' -188: ? storedPrefs.server_address_customized_at -189: : null; -190: -191: const audioDevices = isRecord(storedPrefs.audio_devices) -192: ? { ...defaultPreferences.audio_devices, ...storedPrefs.audio_devices } -193: : { ...defaultPreferences.audio_devices }; -194: -195: const hydrated: UserPreferences = { -196: ...defaultPreferences, -197: ...storedPrefs, -198: server_address_customized: inferredCustomized, -199: server_address_customized_at: sanitizedCustomizedAt, -200: integrations: [...mergedIntegrations, ...customIntegrations], -201: recording_app_policy: recordingPolicy, -202: ai_template: sanitizeAiTemplate(storedPrefs.ai_template), -203: ai_config: { -204: transcription: { -205: ...DEFAULT_TRANSCRIPTION_CONFIG, -206: ...(storedPrefs.ai_config?.transcription ?? {}), -207: }, -208: summary: { ...DEFAULT_SUMMARY_CONFIG, ...(storedPrefs.ai_config?.summary ?? {}) }, -209: embedding: { ...DEFAULT_EMBEDDING_CONFIG, ...(storedPrefs.ai_config?.embedding ?? {}) }, -210: }, -211: audio_devices: audioDevices, -212: }; -213: const override = loadServerAddressOverride(); -214: const hydratedWithOverride = override -215: ? { -216: ...hydrated, -217: server_host: override.host, -218: server_port: override.port, -219: server_address_customized: true, -220: server_address_customized_at: override.updated_at, -221: } -222: : hydrated; -223: const normalized = normalizePreferences(hydratedWithOverride); -224: inMemoryPrefs = normalized; -225: return clonePreferences(normalized); -226: } -227: } catch { -228: // Invalid stored preferences - fall through to default -229: } -230: const fallback = clonePreferences(defaultPreferences); -231: const override = loadServerAddressOverride(); -232: const fallbackWithOverride = override -233: ? { -234: ...fallback, -235: server_host: override.host, -236: server_port: override.port, -237: server_address_customized: true, -238: server_address_customized_at: override.updated_at, -239: } -240: : fallback; -241: const normalizedFallback = normalizePreferences(fallbackWithOverride); -242: inMemoryPrefs = clonePreferences(normalizedFallback); -243: return clonePreferences(normalizedFallback); -244: } -245: -246: /** -247: * Get the set of preferences change listeners. -248: */ -249: export function getListeners(): Set<(prefs: UserPreferences) => void> { -250: return listeners; -251: } -252: -253: /** -254: * Set in-memory preferences cache directly. -255: */ -256: export function setInMemoryPrefs(prefs: UserPreferences): void { -257: inMemoryPrefs = prefs; -258: } -259: -260: /** -261: * Save preferences to localStorage and notify listeners. -262: * Sets preferences_updated_at timestamp for conflict detection. -263: */ -264: export function savePreferences( -265: prefs: UserPreferences, -266: persistToTauri: (prefs: UserPreferences) => Promise -267: ): void { -268: // Set timestamp for conflict detection - tracks when user last modified preferences -269: const timestamped: UserPreferences = { -270: ...prefs, -271: preferences_updated_at: Date.now(), -272: }; -273: const normalized = normalizePreferences(timestamped); -274: inMemoryPrefs = clonePreferences(normalized); -275: try { -276: localStorage.setItem(PREFERENCES_KEY, JSON.stringify(normalized)); -277: } catch (error) { -278: addClientLog({ -279: level: 'warning', -280: source: 'app', -281: message: 'Storage save failed - continuing with in-memory cache', -282: details: error instanceof Error ? error.message : String(error), -283: metadata: { context: 'preferences_storage_save' }, -284: }); -285: } -286: if (isTauriRuntime()) { -287: void persistToTauri(normalized); -288: } -289: for (const listener of listeners) { -290: listener(normalized); -291: } -292: } -293: -294: /** -295: * Load local cache from localStorage. -296: */ -297: export function loadLocalCache(): LocalPreferencesCache { -298: if (typeof window === 'undefined') { -299: return {}; -300: } -301: try { -302: const stored = localStorage.getItem(PREFERENCES_LOCAL_CACHE_KEY); -303: if (!stored) { -304: return {}; -305: } -306: const cachedPreferences = JSON.parse(stored) as LocalPreferencesCache; -307: return cachedPreferences ?? {}; -308: } catch { -309: return {}; -310: } -311: } -312: -313: /** -314: * Save local cache to localStorage. -315: */ -316: export function saveLocalCache(cache: LocalPreferencesCache): void { -317: if (typeof window === 'undefined') { -318: return; -319: } -320: localStorage.setItem(PREFERENCES_LOCAL_CACHE_KEY, JSON.stringify(cache)); -321: } -322: -323: /** -324: * Get Tauri export location from local cache. -325: */ -326: export function getTauriExportLocationCache(): string | null { -327: const cache = loadLocalCache(); -328: return cache.tauri_export_location ?? null; -329: } -330: -331: /** -332: * Set Tauri export location in local cache. -333: */ -334: export function setTauriExportLocationCache(path: string): void { -335: const cache = loadLocalCache(); -336: cache.tauri_export_location = path; -337: saveLocalCache(cache); -338: } -339: -340: /** -341: * Clear all local caches. -342: */ -343: export function clearLocalCaches(): void { -344: try { -345: localStorage.removeItem(PREFERENCES_LOCAL_CACHE_KEY); -346: localStorage.removeItem(SERVER_ADDRESS_OVERRIDE_KEY); -347: localStorage.removeItem(SERVER_ADDRESS_VALUE_KEY); -348: localStorage.removeItem(MODEL_CATALOG_CACHE_KEY); -349: } catch (error) { -350: addClientLog({ -351: level: 'warning', -352: source: 'system', -353: message: 'Failed to clear local preferences cache', -354: details: error instanceof Error ? error.message : String(error), -355: }); -356: } -357: } -```` - -## File: client/src/lib/preferences/tags.ts -````typescript - 1: /** - 2: * Tag-related preference operations. - 3: */ - 4: - 5: import { generateId } from '@/api/mock-data'; - 6: import type { Tag, UserPreferences } from '@/api/types'; - 7: - 8: import { loadPreferences, savePreferences } from './storage'; - 9: import { persistPreferencesToTauri } from './tauri'; -10: -11: type PreferencesUpdater = (updater: (prefs: UserPreferences) => void) => void; -12: -13: export function createTagOperations(withPreferences: PreferencesUpdater) { -14: return { -15: getTags(): Tag[] { -16: return loadPreferences().tags; -17: }, -18: -19: addTag(name: string, color: string): Tag { -20: const prefs = loadPreferences(); -21: const tag: Tag = { id: generateId(), name, color, meeting_ids: [] }; -22: prefs.tags.push(tag); -23: savePreferences(prefs, persistPreferencesToTauri); -24: return tag; -25: }, -26: -27: deleteTag(tagId: string): void { -28: withPreferences((prefs) => { -29: prefs.tags = prefs.tags.filter((t) => t.id !== tagId); -30: }); -31: }, -32: -33: addMeetingToTag(tagId: string, meetingId: string): void { -34: withPreferences((prefs) => { -35: const tag = prefs.tags.find((t) => t.id === tagId); -36: if (tag && !tag.meeting_ids.includes(meetingId)) { -37: tag.meeting_ids.push(meetingId); -38: } -39: }); -40: }, -41: -42: removeMeetingFromTag(tagId: string, meetingId: string): void { -43: withPreferences((prefs) => { -44: const tag = prefs.tags.find((t) => t.id === tagId); -45: if (tag) { -46: tag.meeting_ids = tag.meeting_ids.filter((id) => id !== meetingId); -47: } -48: }); -49: }, -50: -51: getMeetingTags(meetingId: string): Tag[] { -52: return loadPreferences().tags.filter((t) => t.meeting_ids.includes(meetingId)); -53: }, -54: }; -55: } -```` - -## File: client/src/lib/preferences/types.ts -````typescript - 1: /** - 2: * Type definitions for user preferences. - 3: */ - 4: - 5: import type { AIProviderConfig, TranscriptionProviderConfig } from '@/api/types'; - 6: - 7: export type AIConfigType = 'transcription' | 'summary' | 'embedding'; - 8: export type AudioDeviceType = 'input' | 'output'; - 9: -10: export type ConfigForType = T extends 'transcription' -11: ? TranscriptionProviderConfig -12: : AIProviderConfig; -13: -14: export interface UpdateAIConfigOptions { -15: resetTestStatus?: boolean; -16: } -17: -18: export interface LocalPreferencesCache { -19: tauri_export_location?: string; -20: } -21: -22: export type ServerAddressOverride = { -23: host: string; -24: port: string; -25: updated_at: number; -26: }; -```` - -## File: client/src/lib/preferences/validation-events.ts -````typescript - 1: /** - 2: * Integration validation events for UI notification. - 3: * (Sprint GAP-002: State Synchronization) - 4: */ + 1: import type { ComponentPropsWithoutRef } from 'react'; + 2: import { CardTitle } from '@/components/ui/card'; + 3: import { flexLayout } from '@/lib/styles'; + 4: import { cn } from '@/lib/utils'; 5: - 6: import { addClientLog } from '@/lib/client-logs'; + 6: type AnalyticsCardTitleProps = ComponentPropsWithoutRef; 7: - 8: /** - 9: * Event types for integration validation notifications. -10: */ -11: export type IntegrationValidationEventType = -12: | 'validation_failed' -13: | 'integrations_removed' -14: | 'validation_complete'; -15: -16: /** -17: * Integration validation event payload. -18: */ -19: export interface IntegrationValidationEvent { -20: type: IntegrationValidationEventType; -21: /** Error message if validation failed */ -22: error?: string; -23: /** IDs of integrations that were removed as stale */ -24: removedIntegrationIds?: string[]; -25: /** Names of integrations that were removed */ -26: removedIntegrationNames?: string[]; -27: /** Timestamp of the event */ -28: timestamp: number; -29: } -30: -31: /** -32: * Listener type for integration validation events. -33: */ -34: export type IntegrationValidationListener = (event: IntegrationValidationEvent) => void; -35: -36: /** Listeners for integration validation events. */ -37: const validationListeners = new Set(); -38: -39: /** -40: * Emit an integration validation event to all listeners. -41: */ -42: export function emitValidationEvent(event: IntegrationValidationEvent): void { -43: for (const listener of validationListeners) { -44: try { -45: listener(event); -46: } catch (error) { -47: addClientLog({ -48: level: 'warning', -49: source: 'app', -50: message: 'Validation event listener error', -51: details: error instanceof Error ? error.message : String(error), -52: metadata: { context: 'preferences_validation_listener' }, -53: }); -54: } -55: } -56: } -57: -58: /** -59: * Subscribe to integration validation events. -60: * Returns an unsubscribe function. -61: * (Sprint GAP-002: State Synchronization) -62: */ -63: export function subscribeToValidationEvents(listener: IntegrationValidationListener): () => void { -64: validationListeners.add(listener); -65: return () => validationListeners.delete(listener); -66: } + 8: export function AnalyticsCardTitle({ className, ...props }: AnalyticsCardTitleProps) { + 9: return ; +10: } ```` -## File: client/src/lib/ai-providers.ts +## File: client/src/components/analytics/analytics-utils.ts ````typescript - 1: /** - 2: * AI Provider utilities. - 3: * - 4: * This file re-exports from the ai-providers package for backward compatibility. - 5: * The implementation has been split into modular files in ./ai-providers/ - 6: */ - 7: - 8: export { - 9: // Types -10: type FetchModelsResult, -11: type ProviderOption, -12: type TestEndpointResult, -13: // Constants -14: AI_PROVIDERS, -15: TRANSCRIPTION_PROVIDERS, -16: // Functions -17: fetchModels, -18: testEndpoint, -19: } from './ai-providers/index'; + 1: export const SPEAKER_COLORS = [ + 2: 'hsl(var(--chart-1))', + 3: 'hsl(var(--chart-2))', + 4: 'hsl(var(--chart-3))', + 5: 'hsl(var(--chart-4))', + 6: 'hsl(var(--chart-5))', + 7: ]; + 8: + 9: export const SPEAKER_COLOR_CLASSES = [ +10: 'bg-[hsl(var(--chart-1))]', +11: 'bg-[hsl(var(--chart-2))]', +12: 'bg-[hsl(var(--chart-3))]', +13: 'bg-[hsl(var(--chart-4))]', +14: 'bg-[hsl(var(--chart-5))]', +15: ]; +16: +17: export function speakerLabel(entry: unknown): string { +18: if (!entry || typeof entry !== 'object') { +19: return ''; +20: } +21: const record = entry as Record; +22: const speakerId = typeof record.speakerId === 'string' ? record.speakerId : null; +23: const percentage = typeof record.percentage === 'number' ? record.percentage : null; +24: if (!speakerId || percentage === null) { +25: return ''; +26: } +27: return `${speakerId}: ${percentage.toFixed(1)}%`; +28: } +29: +30: export function wordCountTickLabel(value: unknown): string { +31: const numeric = typeof value === 'number' ? value : Number(value); +32: if (!Number.isFinite(numeric)) { +33: return ''; +34: } +35: return numeric >= 1000 ? `${(numeric / 1000).toFixed(1)}k` : `${numeric}`; +36: } ```` -## File: client/src/lib/audio-device-ids.test.ts +## File: client/src/components/analytics/log-entry-config.ts +````typescript + 1: import { AlertCircle, AlertTriangle, Bug, Info, type LucideIcon } from 'lucide-react'; + 2: import type { LogLevel } from '@/api/types'; + 3: + 4: export interface LevelConfig { + 5: icon: LucideIcon; + 6: color: string; + 7: bgColor: string; + 8: } + 9: +10: export const levelConfig: Record = { +11: info: { icon: Info, color: 'text-blue-500', bgColor: 'bg-blue-500/10' }, +12: warning: { icon: AlertTriangle, color: 'text-amber-500', bgColor: 'bg-amber-500/10' }, +13: error: { icon: AlertCircle, color: 'text-red-500', bgColor: 'bg-red-500/10' }, +14: debug: { icon: Bug, color: 'text-purple-500', bgColor: 'bg-purple-500/10' }, +15: }; +```` + +## File: client/src/components/analytics/log-entry.tsx ````typescript 1: /** - 2: * Rigorous unit tests for audio device ID resolution logic. - 3: * - 4: * These tests verify the actual resolution algorithm behavior, not mock interactions. - 5: * Critical for catching bugs where device ID format changes between sessions cause - 6: * the wrong device to be selected. - 7: */ - 8: import { describe, expect, it } from 'vitest'; - 9: import { - 10: resolveAudioDeviceId, - 11: type AudioDeviceMatchCandidate, - 12: } from './audio-device-ids'; - 13: - 14: describe('resolveAudioDeviceId', () => { - 15: describe('exact match scenarios', () => { - 16: it('returns exact match when device ID is unchanged', () => { - 17: const devices: AudioDeviceMatchCandidate[] = [ - 18: { deviceId: 'output:Wave Link Aux 2', label: 'Wave Link Aux 2' }, - 19: { deviceId: 'output:Wave Link Aux 3', label: 'Wave Link Aux 3' }, - 20: ]; - 21: - 22: const result = resolveAudioDeviceId(devices, 'output:Wave Link Aux 2', 'output', ''); - 23: - 24: expect(result).toBe('output:Wave Link Aux 2'); - 25: }); - 26: - 27: it('returns exact match for indexed device ID', () => { - 28: const devices: AudioDeviceMatchCandidate[] = [ - 29: { deviceId: 'output:0:Wave Link Aux', label: 'Wave Link Aux' }, - 30: { deviceId: 'output:1:Wave Link Aux', label: 'Wave Link Aux (2)' }, - 31: ]; + 2: * Log entry component for displaying individual or grouped log entries. + 3: */ + 4: + 5: import { format } from 'date-fns'; + 6: import { ChevronDown } from 'lucide-react'; + 7: import type { LogLevel, LogSource } from '@/api/types'; + 8: import { Badge } from '@/components/ui/badge'; + 9: import { Button } from '@/components/ui/button'; + 10: import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; + 11: import { formatRelativeTimeMs } from '@/lib/format'; + 12: import { toFriendlyMessage } from '@/lib/log-messages'; + 13: import type { SummarizedLog } from '@/lib/log-summarizer'; + 14: import { cn } from '@/lib/utils'; + 15: import { levelConfig } from './log-entry-config'; + 16: + 17: type LogOrigin = 'client' | 'server'; + 18: type ViewMode = 'friendly' | 'technical'; + 19: + 20: export interface LogEntryData { + 21: id: string; + 22: timestamp: number; + 23: level: LogLevel; + 24: source: LogSource; + 25: message: string; + 26: details?: string; + 27: metadata?: Record; + 28: traceId?: string; + 29: spanId?: string; + 30: origin: LogOrigin; + 31: } 32: - 33: const result = resolveAudioDeviceId(devices, 'output:1:Wave Link Aux', 'output', ''); - 34: - 35: expect(result).toBe('output:1:Wave Link Aux'); - 36: }); - 37: }); - 38: - 39: describe('device ID format migration (index added/removed)', () => { - 40: it('resolves non-indexed ID to indexed when device becomes duplicate', () => { - 41: // Session 1: User selected "Wave Link Aux" (only one existed, no index) - 42: const storedId = 'output:Wave Link Aux'; - 43: - 44: // Session 2: Another "Wave Link Aux" was added, now indices are used - 45: const devices: AudioDeviceMatchCandidate[] = [ - 46: { deviceId: 'output:0:Wave Link Aux', label: 'Wave Link Aux' }, - 47: { deviceId: 'output:1:Wave Link Aux', label: 'Wave Link Aux (2)' }, - 48: ]; - 49: - 50: const result = resolveAudioDeviceId(devices, storedId, 'output', ''); - 51: - 52: // Should find by name match - but which one? - 53: // Current behavior: returns first match (index 0) - 54: expect(result).toBe('output:0:Wave Link Aux'); - 55: }); - 56: - 57: it('resolves indexed ID to non-indexed when device becomes unique', () => { - 58: // Session 1: User selected "Wave Link Aux" at index 1 (was duplicate) - 59: const storedId = 'output:1:Wave Link Aux'; - 60: - 61: // Session 2: Only one "Wave Link Aux" exists now (no index needed) - 62: const devices: AudioDeviceMatchCandidate[] = [ - 63: { deviceId: 'output:Wave Link Aux', label: 'Wave Link Aux' }, - 64: { deviceId: 'output:Speakers', label: 'Speakers' }, - 65: ]; - 66: - 67: const result = resolveAudioDeviceId(devices, storedId, 'output', ''); - 68: - 69: // Index 1 doesn't exist, but name matches - should resolve - 70: expect(result).toBe('output:Wave Link Aux'); - 71: }); - 72: }); - 73: - 74: describe('normalized name matching', () => { - 75: it('matches when parenthetical suffix differs', () => { - 76: const devices: AudioDeviceMatchCandidate[] = [ - 77: { deviceId: 'output:Speakers (Realtek Audio)', label: 'Speakers (Realtek Audio)' }, - 78: ]; - 79: - 80: // Stored without parenthetical - 81: const result = resolveAudioDeviceId(devices, 'output:Speakers', 'output', ''); - 82: - 83: expect(result).toBe('output:Speakers (Realtek Audio)'); - 84: }); - 85: - 86: it('matches case-insensitively', () => { - 87: const devices: AudioDeviceMatchCandidate[] = [ - 88: { deviceId: 'output:WAVE LINK AUX', label: 'WAVE LINK AUX' }, - 89: ]; - 90: - 91: const result = resolveAudioDeviceId(devices, 'output:Wave Link Aux', 'output', ''); - 92: - 93: expect(result).toBe('output:WAVE LINK AUX'); - 94: }); - 95: - 96: it('normalizes whitespace differences', () => { - 97: const devices: AudioDeviceMatchCandidate[] = [ - 98: { deviceId: 'output:Wave Link Aux', label: 'Wave Link Aux' }, - 99: ]; -100: -101: const result = resolveAudioDeviceId(devices, 'output:Wave Link Aux', 'output', ''); -102: -103: expect(result).toBe('output:Wave Link Aux'); -104: }); -105: }); -106: -107: describe('multiple match scenarios (THE BUG CASE)', () => { -108: /** -109: * This is the critical bug scenario: -110: * - User has multiple devices with similar names (Wave Link Aux 2, Wave Link Aux 3, etc.) -111: * - Device IDs change format between sessions -112: * - Resolution finds multiple normalized matches -113: * - Current behavior: picks FIRST match, which may be WRONG -114: */ -115: it('picks first match when multiple devices have same normalized name', () => { -116: const devices: AudioDeviceMatchCandidate[] = [ -117: { deviceId: 'output:0:Wave Link', label: 'Wave Link' }, -118: { deviceId: 'output:1:Wave Link', label: 'Wave Link (2)' }, -119: { deviceId: 'output:2:Wave Link', label: 'Wave Link (3)' }, -120: ]; -121: -122: // User had selected "Wave Link (3)" but ID format changed -123: const storedId = 'output:Wave Link (3)'; -124: -125: const result = resolveAudioDeviceId(devices, storedId, 'output', ''); -126: -127: // BUG: Returns first match, not the one user selected -128: // All three normalize to "wave link" so first wins -129: expect(result).toBe('output:0:Wave Link'); -130: }); -131: -132: it('prefers default device when multiple normalized matches exist', () => { -133: const devices: AudioDeviceMatchCandidate[] = [ -134: { deviceId: 'output:0:Wave Link', label: 'Wave Link', isDefault: false }, -135: { deviceId: 'output:1:Wave Link', label: 'Wave Link (2)', isDefault: true }, -136: { deviceId: 'output:2:Wave Link', label: 'Wave Link (3)', isDefault: false }, -137: ]; -138: -139: const storedId = 'output:Wave Link (3)'; -140: -141: const result = resolveAudioDeviceId(devices, storedId, 'output', ''); -142: -143: // Should pick the default when multiple matches -144: expect(result).toBe('output:1:Wave Link'); -145: }); -146: -147: it('returns first match when no default among multiple matches', () => { -148: const devices: AudioDeviceMatchCandidate[] = [ -149: { deviceId: 'output:0:Wave Link', label: 'Wave Link' }, -150: { deviceId: 'output:1:Wave Link', label: 'Wave Link (2)' }, -151: ]; -152: -153: const storedId = 'output:Wave Link (2)'; -154: -155: const result = resolveAudioDeviceId(devices, storedId, 'output', ''); -156: -157: // No default, returns first - THIS IS THE BUG -158: expect(result).toBe('output:0:Wave Link'); -159: }); -160: }); -161: -162: describe('kind mismatch handling', () => { -163: it('returns null when stored kind does not match requested kind', () => { -164: const devices: AudioDeviceMatchCandidate[] = [ -165: { deviceId: 'output:Speakers', label: 'Speakers' }, -166: ]; -167: -168: // Trying to resolve an input device ID as output -169: const result = resolveAudioDeviceId(devices, 'input:Microphone', 'output', ''); -170: -171: expect(result).toBeNull(); -172: }); -173: -174: it('resolves when stored ID has no kind prefix', () => { -175: const devices: AudioDeviceMatchCandidate[] = [ -176: { deviceId: 'output:Speakers', label: 'Speakers' }, -177: ]; -178: -179: // Legacy ID without kind prefix -180: const result = resolveAudioDeviceId(devices, 'Speakers', 'output', ''); -181: -182: expect(result).toBe('output:Speakers'); -183: }); -184: }); -185: -186: describe('no match scenarios', () => { -187: it('returns null when device no longer exists', () => { -188: const devices: AudioDeviceMatchCandidate[] = [ -189: { deviceId: 'output:Speakers', label: 'Speakers' }, -190: ]; -191: -192: const result = resolveAudioDeviceId(devices, 'output:Headphones', 'output', ''); -193: -194: expect(result).toBeNull(); -195: }); -196: -197: it('returns null for empty device list', () => { -198: const result = resolveAudioDeviceId([], 'output:Speakers', 'output', ''); -199: -200: expect(result).toBeNull(); -201: }); -202: }); -203: -204: describe('storedDeviceName parameter (Sprint: Audio Device Persistence Fix)', () => { -205: /** -206: * When a stored device name is provided, resolution prioritizes label matching. -207: * This is more reliable than ID-based matching because device labels (e.g., "Wave Link Aux 3") -208: * are user-visible and less likely to change format than device IDs. -209: */ -210: it('uses stored device name for exact label matching', () => { -211: const devices: AudioDeviceMatchCandidate[] = [ -212: { deviceId: 'output:0:Wave Link Aux', label: 'Wave Link Aux 1' }, -213: { deviceId: 'output:1:Wave Link Aux', label: 'Wave Link Aux 2' }, -214: { deviceId: 'output:2:Wave Link Aux', label: 'Wave Link Aux 3' }, -215: ]; -216: -217: // Stored ID has old format, but we have the stored label -218: const storedId = 'output:Wave Link Aux 3'; -219: const storedName = 'Wave Link Aux 3'; -220: -221: const result = resolveAudioDeviceId(devices, storedId, 'output', storedName); -222: -223: // Should match by label, finding the correct device -224: expect(result).toBe('output:2:Wave Link Aux'); -225: }); -226: -227: it('uses normalized label matching when exact match fails', () => { -228: const devices: AudioDeviceMatchCandidate[] = [ -229: { deviceId: 'output:0:Wave Link Aux', label: 'wave link aux 1' }, -230: { deviceId: 'output:1:Wave Link Aux', label: 'wave link aux 2' }, -231: { deviceId: 'output:2:Wave Link Aux', label: 'wave link aux 3' }, -232: ]; -233: -234: // Stored name has different case -235: const storedId = 'output:Wave Link Aux 3'; -236: const storedName = 'Wave Link Aux 3'; -237: -238: const result = resolveAudioDeviceId(devices, storedId, 'output', storedName); -239: -240: // Should find via normalized matching -241: expect(result).toBe('output:2:Wave Link Aux'); -242: }); -243: -244: it('prefers default device when multiple normalized label matches exist', () => { -245: const devices: AudioDeviceMatchCandidate[] = [ -246: { deviceId: 'output:0:Wave Link Aux', label: 'Wave Link Aux', isDefault: false }, -247: { deviceId: 'output:1:Wave Link Aux', label: 'Wave Link Aux', isDefault: true }, -248: { deviceId: 'output:2:Wave Link Aux', label: 'Wave Link Aux', isDefault: false }, -249: ]; -250: -251: const storedId = 'output:Wave Link Aux'; -252: const storedName = 'Wave Link Aux'; -253: -254: const result = resolveAudioDeviceId(devices, storedId, 'output', storedName); -255: -256: // Should prefer the default among multiple matches -257: expect(result).toBe('output:1:Wave Link Aux'); -258: }); -259: -260: it('falls back to ID-based resolution when stored name does not match', () => { -261: const devices: AudioDeviceMatchCandidate[] = [ -262: { deviceId: 'output:Speakers', label: 'Speakers' }, -263: { deviceId: 'output:Headphones', label: 'Headphones' }, -264: ]; -265: -266: // Stored name doesn't match any current label -267: const storedId = 'output:Speakers'; -268: const storedName = 'Old Device Name'; -269: -270: const result = resolveAudioDeviceId(devices, storedId, 'output', storedName); -271: -272: // Should fall back to ID-based matching -273: expect(result).toBe('output:Speakers'); -274: }); -275: -276: it('solves the Wave Link Aux bug when stored name is provided', () => { -277: // This is the critical fix: when ID format changes but we have the stored label -278: const indexedDevices: AudioDeviceMatchCandidate[] = [ -279: { deviceId: 'output:0:Wave Link Aux', label: 'Wave Link Aux 1' }, -280: { deviceId: 'output:1:Wave Link Aux', label: 'Wave Link Aux 2' }, -281: { deviceId: 'output:2:Wave Link Aux', label: 'Wave Link Aux 3' }, -282: { deviceId: 'output:3:Wave Link Aux', label: 'Wave Link Aux 4' }, -283: ]; -284: -285: // User selected "Wave Link Aux 3" - stored both ID and name -286: const storedId = 'output:Wave Link Aux 3'; -287: const storedName = 'Wave Link Aux 3'; -288: -289: const result = resolveAudioDeviceId(indexedDevices, storedId, 'output', storedName); -290: -291: // With stored name, should correctly find index 2 (Wave Link Aux 3) -292: expect(result).toBe('output:2:Wave Link Aux'); -293: }); -294: -295: it('returns null when stored name matches no labels and ID matches nothing', () => { -296: const devices: AudioDeviceMatchCandidate[] = [ -297: { deviceId: 'output:Speakers', label: 'Speakers' }, -298: ]; -299: -300: const storedId = 'output:Headphones'; -301: const storedName = 'Headphones'; -302: -303: const result = resolveAudioDeviceId(devices, storedId, 'output', storedName); -304: -305: // Neither name nor ID matches - device is gone -306: expect(result).toBeNull(); -307: }); -308: }); -309: -310: describe('real-world Wave Link scenario', () => { -311: /** -312: * This test simulates the exact bug scenario reported: -313: * User selects "Wave Link Aux 3" but after restart, app selects "Wave Link Aux 2" -314: */ -315: it('demonstrates the Wave Link Aux resolution bug', () => { -316: // These are typical Wave Link virtual audio devices -317: const waveLinkeDevices: AudioDeviceMatchCandidate[] = [ -318: { deviceId: 'output:Wave Link MicrophoneFX', label: 'Wave Link MicrophoneFX' }, -319: { deviceId: 'output:Wave Link Stream', label: 'Wave Link Stream' }, -320: { deviceId: 'output:Wave Link Aux 1', label: 'Wave Link Aux 1' }, -321: { deviceId: 'output:Wave Link Aux 2', label: 'Wave Link Aux 2' }, -322: { deviceId: 'output:Wave Link Aux 3', label: 'Wave Link Aux 3' }, -323: { deviceId: 'output:Wave Link Aux 4', label: 'Wave Link Aux 4' }, -324: ]; -325: -326: // User explicitly selected Aux 3 -327: const userSelection = 'output:Wave Link Aux 3'; -328: -329: // If stored ID matches exactly, resolution works -330: const exactResult = resolveAudioDeviceId(waveLinkeDevices, userSelection, 'output', ''); -331: expect(exactResult).toBe('output:Wave Link Aux 3'); -332: -333: // But if format changes (e.g., indices added), resolution may fail -334: const indexedDevices: AudioDeviceMatchCandidate[] = [ -335: { deviceId: 'output:0:Wave Link Aux', label: 'Wave Link Aux 1' }, -336: { deviceId: 'output:1:Wave Link Aux', label: 'Wave Link Aux 2' }, -337: { deviceId: 'output:2:Wave Link Aux', label: 'Wave Link Aux 3' }, -338: { deviceId: 'output:3:Wave Link Aux', label: 'Wave Link Aux 4' }, -339: ]; -340: -341: // Now stored ID doesn't match any exactly -342: // "Wave Link Aux 3" normalizes to "wave link aux 3" -343: // But indexed names are "Wave Link Aux" which normalizes to "wave link aux" -344: // No match! Returns null. -345: const indexedResult = resolveAudioDeviceId(indexedDevices, userSelection, 'output', ''); -346: expect(indexedResult).toBeNull(); -347: }); -348: }); -349: }); -```` - -## File: client/src/lib/audio-device-ids.ts -````typescript - 1: export type AudioDeviceMatchKind = 'input' | 'output'; - 2: - 3: export interface AudioDeviceMatchCandidate { - 4: deviceId: string; - 5: label: string; - 6: isDefault?: boolean; - 7: } - 8: - 9: type DecodedDeviceId = { - 10: name: string; - 11: kind?: AudioDeviceMatchKind; - 12: index?: number; - 13: }; - 14: - 15: const PARENTHETICAL_SUFFIX = /\s*\([^)]*\)\s*$/; - 16: const WHITESPACE_SEQUENCE = /\s+/g; - 17: - 18: function decodeAudioDeviceId(deviceId: string): DecodedDeviceId { - 19: const kindMatch = deviceId.match(/^(input|output):(.*)$/); - 20: if (!kindMatch) { - 21: return { name: deviceId }; - 22: } - 23: const kind = kindMatch[1] as AudioDeviceMatchKind; - 24: const rest = kindMatch[2]; - 25: const [first, ...remainder] = rest.split(':'); - 26: if (remainder.length > 0 && /^\d+$/.test(first)) { - 27: return { name: remainder.join(':'), kind, index: Number(first) }; - 28: } - 29: return { name: rest, kind }; - 30: } - 31: - 32: function normalizeDeviceName(name: string): string { - 33: return name - 34: .replace(PARENTHETICAL_SUFFIX, '') - 35: .replace(WHITESPACE_SEQUENCE, ' ') - 36: .trim() - 37: .toLowerCase(); - 38: } - 39: - 40: /** - 41: * Resolve a stored device ID to a current device ID. - 42: * - 43: * When device ID format changes between sessions (e.g., "output:Wave Link Aux 3" becomes - 44: * "output:2:Wave Link Aux"), this function attempts to find the correct device. - 45: * - 46: * Resolution strategy (in priority order): - 47: * 1. Exact device ID match with index - 48: * 2. Exact label match against stored name - 49: * 3. Normalized label match against stored name - 50: * 4. Exact name match from decoded device ID - 51: * 5. Normalized name match from decoded device ID - 52: * 6. Among normalized matches, prefer default device - 53: * - 54: * @param devices - Available devices to match against - 55: * @param storedDeviceId - The stored device ID to resolve - 56: * @param kind - 'input' or 'output' - 57: * @param storedDeviceName - Stored device label for matching (empty string if not set) - 58: */ - 59: export function resolveAudioDeviceId( - 60: devices: AudioDeviceMatchCandidate[], - 61: storedDeviceId: string, - 62: kind: AudioDeviceMatchKind, - 63: storedDeviceName: string - 64: ): string | null { - 65: const decodedStored = decodeAudioDeviceId(storedDeviceId); - 66: if (decodedStored.kind && decodedStored.kind !== kind) { - 67: return null; - 68: } - 69: - 70: const decodedDevices = devices.map((device) => ({ - 71: device, - 72: decoded: decodeAudioDeviceId(device.deviceId), - 73: })); - 74: - 75: // 1. Try exact device ID match with index (if stored ID had an index) - 76: if (decodedStored.index !== undefined) { - 77: const exact = decodedDevices.find( - 78: ({ decoded }) => - 79: decoded.kind === kind && - 80: decoded.index === decodedStored.index && - 81: decoded.name === decodedStored.name - 82: ); - 83: if (exact) { - 84: return exact.device.deviceId; - 85: } - 86: } - 87: - 88: // 2. If we have a stored device name, try matching against device labels - 89: // This is more reliable because labels (e.g., "Wave Link Aux 3") are user-visible - 90: // and less likely to change than device ID format - 91: if (storedDeviceName) { - 92: // 2a. Exact label match(es) - 93: const exactLabelMatches = devices.filter( - 94: (device) => device.label === storedDeviceName - 95: ); - 96: if (exactLabelMatches.length === 1) { - 97: return exactLabelMatches[0].deviceId; - 98: } - 99: if (exactLabelMatches.length > 1) { -100: // Multiple exact matches - prefer default, then first -101: const defaultMatch = exactLabelMatches.find((d) => d.isDefault === true); -102: if (defaultMatch) { -103: return defaultMatch.deviceId; -104: } -105: return exactLabelMatches[0].deviceId; -106: } -107: -108: // 2b. Normalized label match -109: const normalizedStoredName = normalizeDeviceName(storedDeviceName); -110: const labelMatches = devices.filter( -111: (device) => normalizeDeviceName(device.label) === normalizedStoredName -112: ); -113: if (labelMatches.length === 1) { -114: return labelMatches[0].deviceId; -115: } -116: if (labelMatches.length > 1) { -117: // Multiple matches - prefer default, then first -118: const defaultMatch = labelMatches.find((d) => d.isDefault === true); -119: if (defaultMatch) { -120: return defaultMatch.deviceId; -121: } -122: return labelMatches[0].deviceId; -123: } -124: } -125: -126: // 3. Fall back to matching by decoded name from device ID -127: const nameMatches = decodedDevices.filter( -128: ({ decoded }) => decoded.name === decodedStored.name -129: ); -130: if (nameMatches.length === 1) { -131: return nameMatches[0].device.deviceId; -132: } -133: -134: // 4. Try normalized name match from device ID -135: const normalizedStored = normalizeDeviceName(decodedStored.name); -136: const normalizedMatches = decodedDevices.filter( -137: ({ decoded }) => normalizeDeviceName(decoded.name) === normalizedStored -138: ); -139: if (normalizedMatches.length >= 1) { -140: if (normalizedMatches.length === 1) { -141: return normalizedMatches[0].device.deviceId; -142: } -143: const defaultMatches = normalizedMatches.filter( -144: ({ device }) => device.isDefault === true -145: ); -146: if (defaultMatches.length === 1) { -147: return defaultMatches[0].device.deviceId; -148: } -149: return normalizedMatches[0].device.deviceId; -150: } -151: -152: return null; -153: } -```` - -## File: client/src/lib/audio-device-persistence.integration.test.ts -````typescript - 1: /** - 2: * Integration tests for audio device persistence across sessions. - 3: * - 4: * These tests use real localStorage (no mocking) to verify that user device - 5: * selections survive application restarts, even when device ID formats change. - 6: * - 7: * Critical bug scenario: - 8: * 1. User selects "Wave Link Aux 3" - 9: * 2. App stores "output:Wave Link Aux 3" in localStorage - 10: * 3. App restarts, device IDs now have indices: "output:2:Wave Link Aux" - 11: * 4. Resolution finds a match for current session - 12: * 5. BUG (OLD): Resolution persisted "output:0:Wave Link Aux" → wrong device! - 13: * 6. FIX (NEW): Resolution only updates session state, localStorage unchanged - 14: */ - 15: import { afterEach, beforeEach, describe, expect, it } from 'vitest'; - 16: import { PREFERENCES_KEY } from './storage-keys'; - 17: import { resolveAudioDeviceId, type AudioDeviceMatchCandidate } from './audio-device-ids'; - 18: - 19: // Real preferences storage operations (not mocked) - 20: function getStoredAudioDevices(): { input_device_id: string; output_device_id: string } { - 21: const raw = localStorage.getItem(PREFERENCES_KEY); - 22: if (!raw) { - 23: return { input_device_id: '', output_device_id: '' }; - 24: } - 25: const prefs = JSON.parse(raw) as { audio_devices?: { input_device_id?: string; output_device_id?: string } }; - 26: return { - 27: input_device_id: prefs.audio_devices?.input_device_id ?? '', - 28: output_device_id: prefs.audio_devices?.output_device_id ?? '', - 29: }; - 30: } - 31: - 32: function setStoredAudioDevice(type: 'input' | 'output', deviceId: string): void { - 33: const raw = localStorage.getItem(PREFERENCES_KEY); - 34: const prefs = raw ? (JSON.parse(raw) as Record) : {}; - 35: if (!prefs.audio_devices) { - 36: prefs.audio_devices = {}; - 37: } - 38: const audioDevices = prefs.audio_devices as Record; - 39: const key = type === 'input' ? 'input_device_id' : 'output_device_id'; - 40: audioDevices[key] = deviceId; - 41: localStorage.setItem(PREFERENCES_KEY, JSON.stringify(prefs)); - 42: } - 43: - 44: function clearPreferences(): void { - 45: localStorage.removeItem(PREFERENCES_KEY); + 33: const sourceColors: Record = { + 34: app: 'bg-chart-1/20 text-chart-1', + 35: api: 'bg-chart-2/20 text-chart-2', + 36: sync: 'bg-chart-3/20 text-chart-3', + 37: auth: 'bg-chart-4/20 text-chart-4', + 38: system: 'bg-chart-5/20 text-chart-5', + 39: }; + 40: + 41: export interface LogEntryProps { + 42: summarized: SummarizedLog; + 43: viewMode: ViewMode; + 44: isExpanded: boolean; + 45: onToggleExpanded: () => void; 46: } 47: - 48: describe('Audio Device Persistence Integration', () => { - 49: beforeEach(() => { - 50: clearPreferences(); - 51: }); - 52: - 53: afterEach(() => { - 54: clearPreferences(); - 55: }); - 56: - 57: describe('localStorage persistence', () => { - 58: it('stores and retrieves device selection from localStorage', () => { - 59: setStoredAudioDevice('output', 'output:Wave Link Aux 3'); - 60: - 61: const stored = getStoredAudioDevices(); - 62: - 63: expect(stored.output_device_id).toBe('output:Wave Link Aux 3'); - 64: }); + 48: export function LogEntry({ summarized, viewMode, isExpanded, onToggleExpanded }: LogEntryProps) { + 49: const {log} = summarized; + 50: const config = levelConfig[log.level]; + 51: const Icon = config.icon; + 52: const hasDetails = log.details || log.metadata || log.traceId || log.spanId; + 53: + 54: // Get display message based on view mode + 55: const displayMessage = + 56: viewMode === 'friendly' + 57: ? toFriendlyMessage(log.message, (log.metadata as Record) ?? {}) + 58: : log.message; + 59: + 60: // Get display timestamp based on view mode + 61: const displayTimestamp = + 62: viewMode === 'friendly' + 63: ? formatRelativeTimeMs(log.timestamp) + 64: : format(new Date(log.timestamp), 'HH:mm:ss.SSS'); 65: - 66: it('persists across simulated restarts', () => { - 67: // Session 1: User selects device - 68: setStoredAudioDevice('output', 'output:Wave Link Aux 3'); - 69: - 70: // "Restart" - read from storage - 71: const afterRestart = getStoredAudioDevices(); - 72: - 73: expect(afterRestart.output_device_id).toBe('output:Wave Link Aux 3'); - 74: }); - 75: }); - 76: - 77: describe('device resolution without persistence (the fix)', () => { - 78: /** - 79: * This test verifies the FIX behavior: - 80: * - Resolution should find a device for the current session - 81: * - But should NOT overwrite the user's stored selection - 82: */ - 83: it('preserves user selection in localStorage after resolution', () => { - 84: // Session 1: User explicitly selects "Wave Link Aux 3" - 85: const userSelection = 'output:Wave Link Aux 3'; - 86: setStoredAudioDevice('output', userSelection); - 87: - 88: // Session 2: Device format changed, resolution needed - 89: const session2Devices: AudioDeviceMatchCandidate[] = [ - 90: { deviceId: 'output:0:Wave Link Aux', label: 'Wave Link Aux 1' }, - 91: { deviceId: 'output:1:Wave Link Aux', label: 'Wave Link Aux 2' }, - 92: { deviceId: 'output:2:Wave Link Aux', label: 'Wave Link Aux 3' }, - 93: ]; - 94: - 95: const storedId = getStoredAudioDevices().output_device_id; - 96: const resolvedId = resolveAudioDeviceId(session2Devices, storedId, 'output'); - 97: - 98: // Resolution may find a match (or not, depending on algorithm) - 99: // But localStorage should STILL have the original selection -100: const afterResolution = getStoredAudioDevices(); -101: expect(afterResolution.output_device_id).toBe(userSelection); -102: -103: // The resolved ID is for SESSION USE ONLY -104: // In this case, resolution returns null because names don't match -105: // "Wave Link Aux 3" !== "Wave Link Aux" -106: expect(resolvedId).toBeNull(); -107: }); -108: -109: it('allows re-resolution in future sessions', () => { -110: // Session 1: User selects device -111: const userSelection = 'output:Wave Link Aux 3'; -112: setStoredAudioDevice('output', userSelection); -113: -114: // Session 2: Devices available with exact match -115: const session2Devices: AudioDeviceMatchCandidate[] = [ -116: { deviceId: 'output:Wave Link Aux 2', label: 'Wave Link Aux 2' }, -117: { deviceId: 'output:Wave Link Aux 3', label: 'Wave Link Aux 3' }, -118: ]; -119: -120: const resolved2 = resolveAudioDeviceId( -121: session2Devices, -122: getStoredAudioDevices().output_device_id, -123: 'output' -124: ); -125: expect(resolved2).toBe('output:Wave Link Aux 3'); // Exact match -126: -127: // Session 3: Different device list -128: const session3Devices: AudioDeviceMatchCandidate[] = [ -129: { deviceId: 'output:Wave Link Aux 1', label: 'Wave Link Aux 1' }, -130: { deviceId: 'output:Wave Link Aux 3', label: 'Wave Link Aux 3' }, -131: ]; -132: -133: // Original selection still in localStorage -134: const resolved3 = resolveAudioDeviceId( -135: session3Devices, -136: getStoredAudioDevices().output_device_id, -137: 'output' -138: ); -139: expect(resolved3).toBe('output:Wave Link Aux 3'); // Still works + 66: return ( + 67: + 68:
+ 75:
+ 76:
+ 77: + 78:
+ 79:
+ 80:
+ 81: + 87: {displayTimestamp} + 88: + 89: {viewMode === 'technical' && ( + 90: <> + 91: + 92: {log.source} + 93: + 94: + 95: {log.origin} + 96: + 97: + 98: )} + 99: {summarized.isGroup && summarized.count > 1 && ( +100: +101: {summarized.count}x +102: +103: )} +104:
+105:

{displayMessage}

+106: {viewMode === 'friendly' && summarized.isGroup && summarized.count > 1 && ( +107:

{summarized.count} similar events

+108: )} +109:
+110: {(hasDetails || viewMode === 'friendly') && ( +111: +112: +117: +118: )} +119:
+120: +121: +122: +128: +129:
+130:
+131: ); +132: } +133: +134: interface LogEntryDetailsProps { +135: log: LogEntryData; +136: summarized: SummarizedLog; +137: viewMode: ViewMode; +138: sourceColors: Record; +139: } 140: -141: // Session 4: User's device removed -142: const session4Devices: AudioDeviceMatchCandidate[] = [ -143: { deviceId: 'output:Wave Link Aux 1', label: 'Wave Link Aux 1' }, -144: { deviceId: 'output:Wave Link Aux 2', label: 'Wave Link Aux 2' }, -145: ]; -146: -147: const resolved4 = resolveAudioDeviceId( -148: session4Devices, -149: getStoredAudioDevices().output_device_id, -150: 'output' -151: ); -152: expect(resolved4).toBeNull(); // Device gone -153: -154: // But localStorage STILL has original - if device returns, it works again -155: expect(getStoredAudioDevices().output_device_id).toBe(userSelection); -156: }); -157: }); -158: -159: describe('the Wave Link bug scenario', () => { -160: /** -161: * Real-world scenario that caused the bug: -162: * - Elgato Wave Link creates virtual audio devices -163: * - Multiple "Wave Link Aux" devices exist -164: * - Device enumeration order/format can change -165: */ -166: it('does not clobber user selection with first device', () => { -167: // User has Wave Link installed with multiple Aux devices -168: const waveLinkeDevices: AudioDeviceMatchCandidate[] = [ -169: { deviceId: 'output:Wave Link MicrophoneFX', label: 'Wave Link MicrophoneFX' }, -170: { deviceId: 'output:Wave Link Stream', label: 'Wave Link Stream' }, -171: { deviceId: 'output:Wave Link Aux 1', label: 'Wave Link Aux 1' }, -172: { deviceId: 'output:Wave Link Aux 2', label: 'Wave Link Aux 2' }, -173: { deviceId: 'output:Wave Link Aux 3', label: 'Wave Link Aux 3' }, -174: { deviceId: 'output:Wave Link Aux 4', label: 'Wave Link Aux 4' }, -175: ]; -176: -177: // User explicitly selects Aux 3 (NOT Aux 2) -178: const userSelection = 'output:Wave Link Aux 3'; -179: setStoredAudioDevice('output', userSelection); -180: -181: // Simulate loadDevices flow: -182: // 1. Read stored ID -183: const storedId = getStoredAudioDevices().output_device_id; -184: expect(storedId).toBe(userSelection); -185: -186: // 2. Check if stored ID exists in current device list -187: const exactMatch = waveLinkeDevices.some((d) => d.deviceId === storedId); -188: expect(exactMatch).toBe(true); // Device exists -189: -190: // 3. If exact match, use it directly (no resolution needed) -191: // This is the happy path - no format change -192: -193: // Now simulate the problematic scenario: -194: // Device format changed (indices added due to duplicates detected) -195: const changedFormatDevices: AudioDeviceMatchCandidate[] = [ -196: { deviceId: 'output:Wave Link MicrophoneFX', label: 'Wave Link MicrophoneFX' }, -197: { deviceId: 'output:Wave Link Stream', label: 'Wave Link Stream' }, -198: { deviceId: 'output:0:Wave Link Aux', label: 'Wave Link Aux 1' }, -199: { deviceId: 'output:1:Wave Link Aux', label: 'Wave Link Aux 2' }, // This would be selected by old bug! -200: { deviceId: 'output:2:Wave Link Aux', label: 'Wave Link Aux 3' }, // User wanted this -201: { deviceId: 'output:3:Wave Link Aux', label: 'Wave Link Aux 4' }, -202: ]; -203: -204: // Check if stored ID exists (it doesn't - format changed) -205: const stillExists = changedFormatDevices.some((d) => d.deviceId === storedId); -206: expect(stillExists).toBe(false); -207: -208: // Resolution kicks in -209: const resolvedId = resolveAudioDeviceId(changedFormatDevices, storedId, 'output'); -210: -211: // Resolution can't find exact match (names differ: "Wave Link Aux 3" vs "Wave Link Aux") -212: // This is actually good - it prevents wrong selection -213: expect(resolvedId).toBeNull(); -214: -215: // CRITICAL: localStorage should NOT be modified -216: // The OLD bug would have persisted "output:0:Wave Link Aux" here -217: expect(getStoredAudioDevices().output_device_id).toBe(userSelection); -218: }); -219: -220: it('demonstrates why NOT persisting resolved IDs is correct', () => { -221: // Scenario: User selects a device, format changes, then changes back -222: const userSelection = 'output:Wave Link Aux 3'; -223: setStoredAudioDevice('output', userSelection); -224: -225: // Session 2: Format changed -226: const session2Devices: AudioDeviceMatchCandidate[] = [ -227: { deviceId: 'output:0:Wave Link Aux', label: 'Wave Link Aux 1' }, -228: { deviceId: 'output:1:Wave Link Aux', label: 'Wave Link Aux 2' }, -229: ]; -230: -231: // User's device not available, resolution returns null -232: const resolved2 = resolveAudioDeviceId( -233: session2Devices, -234: getStoredAudioDevices().output_device_id, -235: 'output' -236: ); -237: expect(resolved2).toBeNull(); -238: -239: // OLD BUG: Would have persisted "output:0:Wave Link Aux" (WRONG!) -240: // FIX: Don't persist, keep original -241: -242: // Session 3: User's device is back! -243: const session3Devices: AudioDeviceMatchCandidate[] = [ -244: { deviceId: 'output:Wave Link Aux 1', label: 'Wave Link Aux 1' }, -245: { deviceId: 'output:Wave Link Aux 2', label: 'Wave Link Aux 2' }, -246: { deviceId: 'output:Wave Link Aux 3', label: 'Wave Link Aux 3' }, // It's back! -247: ]; -248: -249: // Because we preserved the original selection, it now works! -250: const resolved3 = resolveAudioDeviceId( -251: session3Devices, -252: getStoredAudioDevices().output_device_id, -253: 'output' -254: ); -255: expect(resolved3).toBe('output:Wave Link Aux 3'); // Correct device! -256: }); -257: }); -258: -259: describe('input and output independence', () => { -260: it('handles input and output devices separately', () => { -261: setStoredAudioDevice('input', 'input:Blue Yeti'); -262: setStoredAudioDevice('output', 'output:Wave Link Aux 3'); -263: -264: const stored = getStoredAudioDevices(); -265: -266: expect(stored.input_device_id).toBe('input:Blue Yeti'); -267: expect(stored.output_device_id).toBe('output:Wave Link Aux 3'); -268: }); -269: -270: it('resolves input and output independently', () => { -271: setStoredAudioDevice('input', 'input:Blue Yeti'); -272: setStoredAudioDevice('output', 'output:Wave Link Aux 3'); -273: -274: const inputDevices: AudioDeviceMatchCandidate[] = [ -275: { deviceId: 'input:Blue Yeti', label: 'Blue Yeti' }, -276: ]; -277: -278: const outputDevices: AudioDeviceMatchCandidate[] = [ -279: { deviceId: 'output:Wave Link Aux 2', label: 'Wave Link Aux 2' }, -280: // Aux 3 is missing! -281: ]; -282: -283: const stored = getStoredAudioDevices(); -284: -285: const resolvedInput = resolveAudioDeviceId(inputDevices, stored.input_device_id, 'input'); -286: const resolvedOutput = resolveAudioDeviceId(outputDevices, stored.output_device_id, 'output'); -287: -288: expect(resolvedInput).toBe('input:Blue Yeti'); // Found -289: expect(resolvedOutput).toBeNull(); // Not found -290: -291: // Both original selections preserved -292: const afterResolution = getStoredAudioDevices(); -293: expect(afterResolution.input_device_id).toBe('input:Blue Yeti'); -294: expect(afterResolution.output_device_id).toBe('output:Wave Link Aux 3'); -295: }); -296: }); -297: }); +141: function LogEntryDetails({ log, summarized, viewMode, sourceColors }: LogEntryDetailsProps) { +142: return ( +143:
+144: {/* Technical details shown when expanded in friendly mode */} +145: {viewMode === 'friendly' && ( +146:
+147:

{log.message}

+148:
+149: +150: {log.source} +151: +152: +153: {log.origin} +154: +155: {format(new Date(log.timestamp), 'HH:mm:ss.SSS')} +156:
+157:
+158: )} +159: {(log.traceId || log.spanId) && ( +160:
+161: {log.traceId && ( +162: +163: trace {log.traceId} +164: +165: )} +166: {log.spanId && ( +167: +168: span {log.spanId} +169: +170: )} +171:
+172: )} +173: {log.details &&

{log.details}

} +174: {log.metadata && ( +175:
+176:           {JSON.stringify(log.metadata, null, 2)}
+177:         
+178: )} +179: {/* Show grouped logs if this is a group */} +180: {summarized.isGroup && summarized.groupedLogs && summarized.groupedLogs.length > 1 && ( +181:
+182:

All {summarized.count} events:

+183:
+184: {summarized.groupedLogs.map((groupedLog) => ( +185:
+186: {format(new Date(groupedLog.timestamp), 'HH:mm:ss.SSS')} - {groupedLog.message} +187:
+188: ))} +189:
+190:
+191: )} +192:
+193: ); +194: } ```` -## File: client/src/lib/client-log-events.integration.test.ts +## File: client/src/components/analytics/log-timeline.tsx ````typescript 1: /** - 2: * Integration tests verifying the full log flow: - 3: * clientLog.* -> addClientLog -> localStorage -> toFriendlyMessage - 4: */ - 5: - 6: import { afterEach, beforeEach, describe, expect, it } from 'vitest'; - 7: import { clientLog } from './client-log-events'; - 8: import { clearClientLogs, getClientLogs } from './client-logs'; - 9: import { toFriendlyMessage } from './log-messages'; - 10: - 11: describe('client log events integration', () => { - 12: beforeEach(() => { - 13: clearClientLogs(); - 14: }); - 15: - 16: afterEach(() => { - 17: clearClientLogs(); - 18: }); - 19: - 20: describe('meeting lifecycle logs are stored and transformed', () => { - 21: it('meetingCreated stores log and transforms to friendly message', () => { - 22: clientLog.meetingCreated('m-123', 'Team Standup'); - 23: - 24: const logs = getClientLogs(); - 25: expect(logs).toHaveLength(1); - 26: expect(logs[0].message).toBe('Created meeting'); - 27: expect(logs[0].level).toBe('info'); - 28: expect(logs[0].source).toBe('app'); - 29: expect(logs[0].metadata).toEqual({ meeting_id: 'm-123', title: 'Team Standup' }); - 30: - 31: const friendly = toFriendlyMessage(logs[0].message, logs[0].metadata ?? {}); - 32: expect(friendly).toBe('Created new meeting: Team Standup'); - 33: }); - 34: - 35: it('meetingStopped stores log and transforms to friendly message', () => { - 36: clientLog.meetingStopped('m-123', 'Daily Sync'); - 37: - 38: const logs = getClientLogs(); - 39: expect(logs).toHaveLength(1); - 40: - 41: const friendly = toFriendlyMessage(logs[0].message, logs[0].metadata ?? {}); - 42: expect(friendly).toBe('Meeting "Daily Sync" has ended'); - 43: }); - 44: - 45: it('meetingStarted stores log and transforms to friendly message', () => { - 46: clientLog.meetingStarted('m-123', 'Sprint Planning'); - 47: - 48: const logs = getClientLogs(); - 49: expect(logs).toHaveLength(1); - 50: - 51: const friendly = toFriendlyMessage(logs[0].message, logs[0].metadata ?? {}); - 52: expect(friendly).toBe('Started recording "Sprint Planning"'); - 53: }); - 54: - 55: it('meetingDeleted stores log and transforms to friendly message', () => { - 56: clientLog.meetingDeleted('m-123'); + 2: * Timeline view for grouped logs. + 3: * + 4: * Displays log groups as collapsible cards with summary headers, + 5: * time gap indicators, and expandable log details. + 6: */ + 7: + 8: import { format } from 'date-fns'; + 9: import { + 10: AlertCircle, + 11: AlertTriangle, + 12: ChevronDown, + 13: ChevronRight, + 14: Clock, + 15: Folder, + 16: Layers, + 17: } from 'lucide-react'; + 18: import { useState } from 'react'; + 19: import { Badge } from '@/components/ui/badge'; + 20: import { Button } from '@/components/ui/button'; + 21: import { Card, CardContent, CardHeader} from '@/components/ui/card'; + 22: import { + 23: Collapsible, + 24: CollapsibleContent, + 25: CollapsibleTrigger, + 26: } from '@/components/ui/collapsible'; + 27: import { formatGap, type LogGroup } from '@/lib/log-groups'; + 28: import { isErrorGroup, isWarningGroup } from '@/lib/log-group-summarizer'; + 29: import { cn } from '@/lib/utils'; + 30: import { LogEntry as LogEntryComponent, type LogEntryData } from './log-entry'; + 31: import type { SummarizedLog } from '@/lib/log-summarizer'; + 32: + 33: /** Props for the LogTimeline component */ + 34: interface LogTimelineProps { + 35: /** Grouped logs to display */ + 36: readonly groups: readonly LogGroup[]; + 37: /** Current view mode */ + 38: readonly viewMode: 'friendly' | 'technical'; + 39: /** Maximum logs to show per group before truncation */ + 40: readonly maxLogsPerGroup?: number; + 41: /** Set of expanded log IDs */ + 42: readonly expandedLogs: ReadonlySet; + 43: /** Callback when a log is toggled */ + 44: readonly onToggleLog: (id: string) => void; + 45: } + 46: + 47: /** Props for a single timeline group */ + 48: interface TimelineGroupProps { + 49: readonly group: LogGroup; + 50: readonly viewMode: 'friendly' | 'technical'; + 51: readonly maxLogs: number; + 52: readonly expandedLogs: ReadonlySet; + 53: readonly onToggleLog: (id: string) => void; + 54: readonly isFirst: boolean; + 55: readonly gapFromPrevious: number | undefined; + 56: } 57: - 58: const logs = getClientLogs(); - 59: expect(logs).toHaveLength(1); - 60: - 61: const friendly = toFriendlyMessage(logs[0].message, logs[0].metadata ?? {}); - 62: expect(friendly).toBe('Meeting deleted successfully'); - 63: }); - 64: }); - 65: - 66: describe('summarization logs are stored and transformed', () => { - 67: it('summarizing stores log and transforms with segment count', () => { - 68: clientLog.summarizing('m-123', 42); - 69: - 70: const logs = getClientLogs(); - 71: expect(logs).toHaveLength(1); - 72: - 73: const friendly = toFriendlyMessage(logs[0].message, logs[0].metadata ?? {}); - 74: expect(friendly).toBe('Generating summary from 42 segments...'); - 75: }); - 76: - 77: it('summaryGenerated stores log and transforms with model', () => { - 78: clientLog.summaryGenerated('m-123', 'claude-3-sonnet'); - 79: - 80: const logs = getClientLogs(); - 81: expect(logs).toHaveLength(1); - 82: - 83: const friendly = toFriendlyMessage(logs[0].message, logs[0].metadata ?? {}); - 84: expect(friendly).toBe('Summary ready (claude-3-sonnet)'); - 85: }); - 86: - 87: it('summaryFailed stores error log and transforms with reason', () => { - 88: clientLog.summaryFailed('m-123', 'Rate limit exceeded'); + 58: /** Get icon for group type */ + 59: function getGroupIcon(group: LogGroup) { + 60: if (isErrorGroup(group.summary)) { + 61: return ; + 62: } + 63: if (isWarningGroup(group.summary)) { + 64: return ; + 65: } + 66: + 67: switch (group.groupType) { + 68: case 'meeting': + 69: return ; + 70: case 'operation': + 71: return ; + 72: case 'time': + 73: return ; + 74: default: + 75: return ; + 76: } + 77: } + 78: + 79: /** Get background color for group header based on status */ + 80: function getGroupHeaderClass(group: LogGroup): string { + 81: if (isErrorGroup(group.summary)) { + 82: return 'bg-red-50 dark:bg-red-950/30 border-red-200 dark:border-red-900'; + 83: } + 84: if (isWarningGroup(group.summary)) { + 85: return 'bg-yellow-50 dark:bg-yellow-950/30 border-yellow-200 dark:border-yellow-900'; + 86: } + 87: return 'bg-muted/50'; + 88: } 89: - 90: const logs = getClientLogs(); - 91: expect(logs).toHaveLength(1); - 92: expect(logs[0].level).toBe('error'); - 93: - 94: const friendly = toFriendlyMessage(logs[0].message, logs[0].metadata ?? {}); - 95: expect(friendly).toBe('Summary generation failed: Rate limit exceeded'); - 96: }); - 97: }); - 98: - 99: describe('cloud consent logs are stored and transformed', () => { -100: it('cloudConsentGranted stores and transforms correctly', () => { -101: clientLog.cloudConsentGranted(); -102: -103: const logs = getClientLogs(); -104: expect(logs).toHaveLength(1); -105: -106: const friendly = toFriendlyMessage(logs[0].message, logs[0].metadata ?? {}); -107: expect(friendly).toBe('Cloud AI features enabled'); -108: }); -109: -110: it('cloudConsentRevoked stores and transforms correctly', () => { -111: clientLog.cloudConsentRevoked(); -112: -113: const logs = getClientLogs(); -114: expect(logs).toHaveLength(1); -115: -116: const friendly = toFriendlyMessage(logs[0].message, logs[0].metadata ?? {}); -117: expect(friendly).toBe('Cloud AI features disabled'); -118: }); -119: }); -120: -121: describe('export logs are stored and transformed', () => { -122: it('exportStarted stores and transforms with format', () => { -123: clientLog.exportStarted('m-123', 'pdf'); -124: -125: const logs = getClientLogs(); -126: expect(logs).toHaveLength(1); -127: -128: const friendly = toFriendlyMessage(logs[0].message, logs[0].metadata ?? {}); -129: expect(friendly).toBe('Exporting transcript as PDF...'); -130: }); -131: -132: it('exportCompleted stores and transforms with format', () => { -133: clientLog.exportCompleted('m-123', 'markdown'); -134: -135: const logs = getClientLogs(); -136: expect(logs).toHaveLength(1); -137: -138: const friendly = toFriendlyMessage(logs[0].message, logs[0].metadata ?? {}); -139: expect(friendly).toBe('Transcript exported as MARKDOWN'); -140: }); -141: }); -142: -143: describe('diarization logs are stored and transformed', () => { -144: it('diarizationStarted stores and transforms to running status', () => { -145: clientLog.diarizationStarted('m-123', 'job-456'); -146: -147: const logs = getClientLogs(); -148: expect(logs).toHaveLength(1); -149: -150: const friendly = toFriendlyMessage(logs[0].message, logs[0].metadata ?? {}); -151: expect(friendly).toBe('Identifying speakers...'); -152: }); -153: -154: it('diarizationCompleted stores and transforms to completed status', () => { -155: clientLog.diarizationCompleted('m-123', 'job-456'); -156: -157: const logs = getClientLogs(); -158: expect(logs).toHaveLength(1); -159: -160: const friendly = toFriendlyMessage(logs[0].message, logs[0].metadata ?? {}); -161: expect(friendly).toBe('Speaker identification complete'); -162: }); + 90: /** Format timestamp for group header */ + 91: function groupTimeLabel(timestamp: number): string { + 92: return format(new Date(timestamp), 'HH:mm:ss'); + 93: } + 94: + 95: /** Time gap indicator between groups */ + 96: function TimeGapIndicator({ gapMs }: { readonly gapMs: number }) { + 97: return ( + 98:
+ 99:
+100:
+101: +102: {formatGap(gapMs)} +103:
+104:
+105:
+106: ); +107: } +108: +109: /** Single timeline group component */ +110: function TimelineGroup({ +111: group, +112: viewMode, +113: maxLogs, +114: expandedLogs, +115: onToggleLog, +116: isFirst, +117: gapFromPrevious, +118: }: TimelineGroupProps) { +119: const [isExpanded, setIsExpanded] = useState(isFirst); +120: const logsToShow = group.logs.slice(0, maxLogs); +121: const hiddenCount = group.logs.length - logsToShow.length; +122: +123: const { summary } = group; +124: const hasErrors = summary.levelCounts.error > 0; +125: const hasWarnings = summary.levelCounts.warning > 0; +126: +127: return ( +128: <> +129: {gapFromPrevious !== undefined && gapFromPrevious > 60000 && ( +130: +131: )} +132: +133: +134: +135: +136: +137:
+138:
+139: {isExpanded ? ( +140: +141: ) : ( +142: +143: )} +144: {getGroupIcon(group)} +145:
+146: {group.label} +147: {summary.text} +148:
+149:
+150: +151:
+152: {/* Level badges */} +153: {hasErrors && ( +154: +155: {summary.levelCounts.error} error{summary.levelCounts.error !== 1 ? 's' : ''} +156: +157: )} +158: {hasWarnings && ( +159: +163: {summary.levelCounts.warning} warning +164: {summary.levelCounts.warning !== 1 ? 's' : ''} +165: +166: )} +167: +168: {/* Log count */} +169: +170: {group.logs.length} log{group.logs.length !== 1 ? 's' : ''} +171: +172: +173: {/* Time range */} +174: +175: {groupTimeLabel(group.endTime)} +176: {group.startTime !== group.endTime && ` - ${groupTimeLabel(group.startTime)}`} +177: +178:
+179:
+180:
+181:
+182: +183: +184: +185:
+186: {logsToShow.map((log) => { +187: const summarized: SummarizedLog = { +188: log, +189: count: 1, +190: isGroup: false, +191: groupedLogs: undefined, +192: }; +193: return ( +194: onToggleLog(log.id)} +200: /> +201: ); +202: })} +203: +204: {hiddenCount > 0 && ( +205: +213: )} +214:
+215:
+216:
+217:
+218:
+219: +220: ); +221: } +222: +223: /** +224: * Timeline view for displaying grouped logs. +225: * +226: * Renders log groups as collapsible cards with: +227: * - Summary headers showing group type and stats +228: * - Time gap indicators between groups +229: * - Expandable log entries within each group +230: * - Truncation with "N more..." for large groups +231: */ +232: export function LogTimeline({ +233: groups, +234: viewMode, +235: maxLogsPerGroup = 10, +236: expandedLogs, +237: onToggleLog, +238: }: LogTimelineProps) { +239: if (groups.length === 0) { +240: return null; +241: } +242: +243: return ( +244:
+245: {groups.map((group, index) => { +246: // Calculate gap from previous group +247: const previousGroup = index > 0 ? groups[index - 1] : undefined; +248: const gapFromPrevious = previousGroup +249: ? previousGroup.startTime - group.endTime +250: : undefined; +251: +252: return ( +253: +263: ); +264: })} +265:
+266: ); +267: } +```` + +## File: client/src/components/analytics/logs-tab.test.tsx +````typescript + 1: import { QueryClient, QueryClientProvider, notifyManager } from '@tanstack/react-query'; + 2: import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; + 3: import type { ReactNode } from 'react'; + 4: import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + 5: import * as apiInterface from '@/api/interface'; + 6: import type { GetRecentLogsResponse, LogEntry } from '@/api/types'; + 7: import { addClientLog, clearClientLogs } from '@/lib/client-logs'; + 8: import { LogsTab } from './logs-tab'; + 9: + 10: // Mock the API module + 11: vi.mock('@/api/interface', () => ({ + 12: getAPI: vi.fn(), + 13: })); + 14: + 15: const clientLogState = vi.hoisted(() => ({ + 16: store: [] as Array<{ + 17: id: string; + 18: timestamp: number; + 19: level: string; + 20: source: string; + 21: message: string; + 22: details?: string; + 23: metadata?: Record; + 24: origin: 'client'; + 25: }>, + 26: listeners: new Set<(logs: Array<{ + 27: id: string; + 28: timestamp: number; + 29: level: string; + 30: source: string; + 31: message: string; + 32: details?: string; + 33: metadata?: Record; + 34: origin: 'client'; + 35: }>) => void>(), + 36: })); + 37: + 38: vi.mock('@/lib/client-logs', () => ({ + 39: getClientLogs: () => [...clientLogState.store], + 40: subscribeClientLogs: (listener: (logs: typeof clientLogState.store) => void) => { + 41: clientLogState.listeners.add(listener); + 42: act(() => listener([...clientLogState.store])); + 43: return () => clientLogState.listeners.delete(listener); + 44: }, + 45: addClientLog: ( + 46: entry: Omit<(typeof clientLogState.store)[number], 'id' | 'timestamp' | 'origin'> + 47: ) => { + 48: const next = { + 49: ...entry, + 50: id: `client-log-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`, + 51: timestamp: Date.now(), + 52: origin: 'client' as const, + 53: }; + 54: clientLogState.store.unshift(next); + 55: clientLogState.store.splice(500); + 56: for (const listener of clientLogState.listeners) { + 57: act(() => listener([...clientLogState.store])); + 58: } + 59: }, + 60: clearClientLogs: () => { + 61: clientLogState.store.splice(0); + 62: for (const listener of clientLogState.listeners) { + 63: act(() => listener([...clientLogState.store])); + 64: } + 65: }, + 66: })); + 67: + 68: // Simplify Radix-based UI components to avoid act warnings in tests. + 69: vi.mock('@/components/ui/select', () => ({ + 70: Select: ({ children }: { children: ReactNode }) =>
{children}
, + 71: SelectTrigger: ({ children }: { children: ReactNode }) => ( + 72: + 73: ), + 74: SelectValue: ({ placeholder }: { placeholder?: string }) => {placeholder}, + 75: SelectContent: ({ children }: { children: ReactNode }) =>
{children}
, + 76: SelectItem: ({ children }: { children: ReactNode }) =>
{children}
, + 77: })); + 78: + 79: vi.mock('@/components/ui/scroll-area', () => ({ + 80: ScrollArea: ({ children }: { children: ReactNode }) =>
{children}
, + 81: })); + 82: + 83: vi.mock('@/components/ui/collapsible', () => ({ + 84: Collapsible: ({ children }: { children: ReactNode }) =>
{children}
, + 85: CollapsibleTrigger: ({ children }: { children: ReactNode }) => {children}, + 86: CollapsibleContent: ({ children }: { children: ReactNode }) =>
{children}
, + 87: })); + 88: + 89: // Mock date-fns format for deterministic output + 90: vi.mock('date-fns', async () => { + 91: const actual = await vi.importActual('date-fns'); + 92: return { + 93: ...actual, + 94: format: vi.fn((_date: Date, formatStr: string) => { + 95: if (formatStr === 'HH:mm:ss.SSS') { + 96: return '12:34:56.789'; + 97: } + 98: if (formatStr === 'HH:mm:ss') { + 99: return '12:34:56'; +100: } +101: if (formatStr === 'yyyy-MM-dd-HHmmss') { +102: return '2025-01-01-123456'; +103: } +104: return '2025-01-01'; +105: }), +106: }; +107: }); +108: +109: // Mock formatRelativeTimeMs for deterministic output +110: vi.mock('@/lib/format', async () => { +111: const actual = await vi.importActual('@/lib/format'); +112: return { +113: ...actual, +114: formatRelativeTimeMs: vi.fn(() => 'Just now'), +115: }; +116: }); +117: +118: // Helper to create QueryClient wrapper +119: function createWrapper() { +120: const queryClient = new QueryClient({ +121: defaultOptions: { +122: queries: { +123: retry: false, +124: gcTime: 0, +125: }, +126: }, +127: }); +128: return function Wrapper({ children }: { children: ReactNode }) { +129: return {children}; +130: }; +131: } +132: +133: notifyManager.setNotifyFunction((fn) => { +134: act(fn); +135: }); +136: notifyManager.setBatchNotifyFunction((fn) => { +137: act(() => { +138: fn(); +139: }); +140: }); +141: notifyManager.setScheduler((fn) => { +142: fn(); +143: }); +144: +145: async function renderLogsTab() { +146: const wrapper = createWrapper(); +147: await act(async () => { +148: render(, { wrapper }); +149: await Promise.resolve(); +150: }); +151: } +152: +153: function createMockLogEntry(overrides: Partial = {}): LogEntry { +154: return { +155: timestamp: '2025-01-01T12:34:56.789Z', +156: level: 'info', +157: source: 'app', +158: message: 'Test log message', +159: details: {}, +160: ...overrides, +161: }; +162: } 163: -164: it('diarizationFailed stores error and transforms to failed status', () => { -165: clientLog.diarizationFailed('m-123', 'job-456', 'GPU OOM'); -166: -167: const logs = getClientLogs(); -168: expect(logs).toHaveLength(1); -169: expect(logs[0].level).toBe('error'); -170: -171: const friendly = toFriendlyMessage(logs[0].message, logs[0].metadata ?? {}); -172: expect(friendly).toBe('Speaker identification failed'); -173: }); -174: -175: it('speakerRenamed stores and transforms with names', () => { -176: clientLog.speakerRenamed('m-123', 'SPEAKER_00', 'Alice'); -177: -178: const logs = getClientLogs(); -179: expect(logs).toHaveLength(1); -180: -181: const friendly = toFriendlyMessage(logs[0].message, logs[0].metadata ?? {}); -182: expect(friendly).toBe('Renamed speaker "SPEAKER_00" to "Alice"'); -183: }); -184: }); +164: describe('LogsTab', () => { +165: const mockAPI = { +166: getRecentLogs: vi.fn<() => Promise>(), +167: }; +168: +169: beforeEach(() => { +170: vi.mocked(apiInterface.getAPI).mockReturnValue( +171: mockAPI as unknown as ReturnType +172: ); +173: vi.clearAllMocks(); +174: clearClientLogs(); +175: }); +176: +177: afterEach(() => { +178: vi.unstubAllGlobals(); +179: clearClientLogs(); +180: }); +181: +182: describe('Loading State', () => { +183: it('shows loading state while fetching logs', async () => { +184: mockAPI.getRecentLogs.mockImplementation(() => new Promise(() => {})); 185: -186: describe('entity extraction logs are stored and transformed', () => { -187: it('entitiesExtracted stores and transforms with count', () => { -188: clientLog.entitiesExtracted('m-123', 27); -189: -190: const logs = getClientLogs(); -191: expect(logs).toHaveLength(1); -192: -193: const friendly = toFriendlyMessage(logs[0].message, logs[0].metadata ?? {}); -194: expect(friendly).toBe('Extracted 27 entities from transcript'); -195: }); -196: }); +186: await renderLogsTab(); +187: +188: await waitFor(() => { +189: expect(screen.getByText('Loading logs...')).toBeInTheDocument(); +190: }); +191: }); +192: }); +193: +194: describe('Empty State', () => { +195: it('shows empty state when no logs', async () => { +196: mockAPI.getRecentLogs.mockResolvedValue({ logs: [], total_count: 0 }); 197: -198: describe('webhook logs are stored and transformed', () => { -199: it('webhookRegistered stores and transforms with name', () => { -200: clientLog.webhookRegistered('wh-123', 'Slack Notifier'); -201: -202: const logs = getClientLogs(); -203: expect(logs).toHaveLength(1); +198: await renderLogsTab(); +199: +200: await waitFor(() => { +201: expect(screen.getByText('No logs found')).toBeInTheDocument(); +202: }); +203: }); 204: -205: const friendly = toFriendlyMessage(logs[0].message, logs[0].metadata ?? {}); -206: expect(friendly).toBe('Webhook "Slack Notifier" registered'); -207: }); -208: }); +205: it('suggests adjusting filters when filtered with no results', async () => { +206: mockAPI.getRecentLogs.mockResolvedValue({ logs: [], total_count: 0 }); +207: +208: await renderLogsTab(); 209: -210: describe('calendar logs are stored and transformed', () => { -211: it('calendarSynced stores and transforms with count', () => { -212: clientLog.calendarSynced('google', 15); +210: await waitFor(() => { +211: expect(screen.getByText('No logs found')).toBeInTheDocument(); +212: }); 213: -214: const logs = getClientLogs(); -215: expect(logs).toHaveLength(1); -216: -217: const friendly = toFriendlyMessage(logs[0].message, logs[0].metadata ?? {}); -218: expect(friendly).toBe('Synced 15 calendar events from google'); +214: // Type a search query to trigger filter message +215: const searchInput = screen.getByPlaceholderText('Search logs...'); +216: fireEvent.change(searchInput, { target: { value: 'nonexistent' } }); +217: +218: expect(screen.getByText('Try adjusting your filters')).toBeInTheDocument(); 219: }); 220: }); 221: -222: describe('connection logs are stored and transformed', () => { -223: it('connected stores and transforms correctly', () => { -224: clientLog.connected('localhost:50051'); -225: -226: const logs = getClientLogs(); -227: expect(logs).toHaveLength(1); -228: -229: const friendly = toFriendlyMessage(logs[0].message, logs[0].metadata ?? {}); -230: expect(friendly).toBe('Connected to server'); -231: }); -232: -233: it('disconnected stores and transforms correctly', () => { -234: clientLog.disconnected(); -235: -236: const logs = getClientLogs(); -237: expect(logs).toHaveLength(1); -238: -239: const friendly = toFriendlyMessage(logs[0].message, logs[0].metadata ?? {}); -240: expect(friendly).toBe('Disconnected from server'); -241: }); -242: -243: it('connectionFailed stores error and transforms correctly', () => { -244: clientLog.connectionFailed('ECONNREFUSED'); -245: -246: const logs = getClientLogs(); -247: expect(logs).toHaveLength(1); -248: expect(logs[0].level).toBe('error'); -249: -250: const friendly = toFriendlyMessage(logs[0].message, logs[0].metadata ?? {}); -251: expect(friendly).toBe('Failed to connect to server'); -252: }); -253: }); -254: -255: describe('trigger logs are stored and transformed', () => { -256: it('triggerDetected stores and transforms with type', () => { -257: clientLog.triggerDetected('calendar'); -258: -259: const logs = getClientLogs(); -260: expect(logs).toHaveLength(1); -261: -262: const friendly = toFriendlyMessage(logs[0].message, logs[0].metadata ?? {}); -263: expect(friendly).toBe('Calendar meeting starting soon'); -264: }); -265: -266: it('triggerSnoozeCleared stores and transforms correctly', () => { -267: clientLog.triggerSnoozeCleared(); +222: describe('Log Display', () => { +223: it('renders log entries from API response', async () => { +224: mockAPI.getRecentLogs.mockResolvedValue({ +225: logs: [ +226: createMockLogEntry({ message: 'First log' }), +227: createMockLogEntry({ message: 'Second log', level: 'error' }), +228: ], +229: total_count: 2, +230: }); +231: +232: await renderLogsTab(); +233: +234: await waitFor(() => { +235: // Messages may appear multiple times (in main view and expanded details) +236: expect(screen.getAllByText('First log').length).toBeGreaterThan(0); +237: expect(screen.getAllByText('Second log').length).toBeGreaterThan(0); +238: }); +239: }); +240: +241: it('displays log stats for each level', async () => { +242: mockAPI.getRecentLogs.mockResolvedValue({ +243: logs: [ +244: createMockLogEntry({ level: 'info' }), +245: createMockLogEntry({ level: 'info' }), +246: createMockLogEntry({ level: 'error' }), +247: createMockLogEntry({ level: 'warning' }), +248: ], +249: total_count: 4, +250: }); +251: +252: await renderLogsTab(); +253: +254: await waitFor(() => { +255: // Check that stats are rendered (cards with numbers) +256: const statCards = screen.getAllByText(/^[0-9]+$/); +257: expect(statCards.length).toBeGreaterThan(0); +258: }); +259: }); +260: +261: it('shows source badges for log entries', async () => { +262: mockAPI.getRecentLogs.mockResolvedValue({ +263: logs: [createMockLogEntry({ source: 'api', message: 'API log' })], +264: total_count: 1, +265: }); +266: +267: await renderLogsTab(); 268: -269: const logs = getClientLogs(); -270: expect(logs).toHaveLength(1); -271: -272: const friendly = toFriendlyMessage(logs[0].message, logs[0].metadata ?? {}); -273: expect(friendly).toBe('Recording prompt re-enabled'); -274: }); -275: }); -276: -277: describe('multiple logs are stored in order', () => { -278: it('stores multiple logs and retrieves most recent first', () => { -279: clientLog.connected('localhost:50051'); -280: clientLog.meetingCreated('m-123', 'Test Meeting'); -281: clientLog.meetingStarted('m-123', 'Test Meeting'); +269: await waitFor(() => { +270: expect(screen.getByText('api')).toBeInTheDocument(); +271: }); +272: }); +273: +274: it('renders client logs alongside server logs', async () => { +275: mockAPI.getRecentLogs.mockResolvedValue({ logs: [], total_count: 0 }); +276: addClientLog({ +277: level: 'warning', +278: source: 'system', +279: message: 'Recording blocked by app policy', +280: metadata: { rule_id: 'zoom' }, +281: }); 282: -283: const logs = getClientLogs(); -284: expect(logs).toHaveLength(3); -285: expect(logs[0].message).toBe('Meeting started'); -286: expect(logs[1].message).toBe('Created meeting'); -287: expect(logs[2].message).toBe('Connected'); -288: }); -289: }); -290: }); +283: await renderLogsTab(); +284: +285: await waitFor(() => { +286: // Messages may appear multiple times (in main view and expanded details) +287: expect(screen.getAllByText('Recording blocked by app policy').length).toBeGreaterThan(0); +288: expect(screen.getAllByText('client').length).toBeGreaterThan(0); +289: }); +290: }); +291: }); +292: +293: describe('Filtering', () => { +294: it('calls API with level filter when selected', async () => { +295: mockAPI.getRecentLogs.mockResolvedValue({ logs: [], total_count: 0 }); +296: +297: await renderLogsTab(); +298: +299: await waitFor(() => { +300: expect(mockAPI.getRecentLogs).toHaveBeenCalled(); +301: }); +302: +303: // Initial call with no filters +304: expect(mockAPI.getRecentLogs).toHaveBeenCalledWith( +305: expect.objectContaining({ +306: level: undefined, +307: source: undefined, +308: }) +309: ); +310: }); +311: +312: it('filters logs by search query client-side', async () => { +313: mockAPI.getRecentLogs.mockResolvedValue({ +314: logs: [ +315: createMockLogEntry({ message: 'Connection established' }), +316: createMockLogEntry({ message: 'User logged in' }), +317: createMockLogEntry({ message: 'Connection closed' }), +318: ], +319: total_count: 3, +320: }); +321: +322: await renderLogsTab(); +323: +324: await waitFor(() => { +325: // Messages may appear multiple times (in main view and expanded details) +326: expect(screen.getAllByText('Connection established').length).toBeGreaterThan(0); +327: }); +328: +329: // Search for "Connection" +330: const searchInput = screen.getByPlaceholderText('Search logs...'); +331: fireEvent.change(searchInput, { target: { value: 'Connection' } }); +332: +333: expect(screen.getAllByText('Connection established').length).toBeGreaterThan(0); +334: expect(screen.getAllByText('Connection closed').length).toBeGreaterThan(0); +335: expect(screen.queryAllByText('User logged in')).toHaveLength(0); +336: }); +337: +338: it('filters logs by metadata values', async () => { +339: mockAPI.getRecentLogs.mockResolvedValue({ +340: logs: [ +341: createMockLogEntry({ message: 'Metadata log', details: { request_id: 'req-99' } }), +342: createMockLogEntry({ message: 'Other log' }), +343: ], +344: total_count: 2, +345: }); +346: +347: await renderLogsTab(); +348: +349: await waitFor(() => { +350: // Messages may appear multiple times (in main view and expanded details) +351: expect(screen.getAllByText('Metadata log').length).toBeGreaterThan(0); +352: }); +353: +354: const searchInput = screen.getByPlaceholderText('Search logs...'); +355: fireEvent.change(searchInput, { target: { value: 'req-99' } }); +356: +357: expect(screen.getAllByText('Metadata log').length).toBeGreaterThan(0); +358: expect(screen.queryAllByText('Other log')).toHaveLength(0); +359: }); +360: }); +361: +362: describe('Refresh', () => { +363: it('refetches logs when refresh button clicked', async () => { +364: mockAPI.getRecentLogs.mockResolvedValue({ logs: [], total_count: 0 }); +365: +366: await renderLogsTab(); +367: +368: await waitFor(() => { +369: expect(mockAPI.getRecentLogs).toHaveBeenCalledTimes(1); +370: }); +371: +372: const refreshButton = screen.getByTitle('Refresh logs'); +373: fireEvent.click(refreshButton); +374: +375: await waitFor(() => { +376: expect(mockAPI.getRecentLogs).toHaveBeenCalledTimes(2); +377: }); +378: }); +379: }); +380: +381: describe('Log Details', () => { +382: it('renders log with metadata that can be expanded', async () => { +383: mockAPI.getRecentLogs.mockResolvedValue({ +384: logs: [ +385: createMockLogEntry({ +386: message: 'Log with details', +387: details: { key: 'value' }, +388: }), +389: ], +390: total_count: 1, +391: }); +392: +393: await renderLogsTab(); +394: +395: await waitFor(() => { +396: // Messages may appear multiple times (in main view and expanded details) +397: expect(screen.getAllByText('Log with details').length).toBeGreaterThan(0); +398: }); +399: +400: // Verify the log entry is rendered - metadata expansion is a UI detail +401: // The component shows expand buttons for entries with metadata +402: const logEntries = screen.getAllByText('Log with details'); +403: expect(logEntries.length).toBeGreaterThan(0); +404: }); +405: +406: it('shows trace and span badges when correlation IDs are present', async () => { +407: mockAPI.getRecentLogs.mockResolvedValue({ +408: logs: [ +409: createMockLogEntry({ +410: message: 'Trace log', +411: trace_id: 'trace-123', +412: span_id: 'span-456', +413: details: { request_id: 'req-99' }, +414: }), +415: ], +416: total_count: 1, +417: }); +418: +419: await renderLogsTab(); +420: +421: await waitFor(() => { +422: // Messages may appear multiple times (in main view and expanded details) +423: expect(screen.getAllByText('Trace log').length).toBeGreaterThan(0); +424: }); +425: +426: const toggleButton = screen.getByLabelText('Toggle log details'); +427: fireEvent.click(toggleButton); +428: +429: await waitFor(() => { +430: expect(screen.getAllByText(/trace-123/i).length).toBeGreaterThan(0); +431: expect(screen.getAllByText(/span-456/i).length).toBeGreaterThan(0); +432: expect(screen.getAllByText(/request_id/).length).toBeGreaterThan(0); +433: }); +434: +435: fireEvent.click(toggleButton); +436: }); +437: +438: it('handles logs without details', async () => { +439: mockAPI.getRecentLogs.mockResolvedValue({ +440: logs: [createMockLogEntry({ message: 'No details', details: undefined })], +441: total_count: 1, +442: }); +443: +444: await renderLogsTab(); +445: +446: await waitFor(() => { +447: // Messages may appear multiple times (in main view and expanded details) +448: expect(screen.getAllByText('No details').length).toBeGreaterThan(0); +449: }); +450: }); +451: }); +452: +453: describe('Export', () => { +454: it('exports logs and revokes the object URL', async () => { +455: const createObjectURL = vi.fn(() => 'blob:logs'); +456: const revokeObjectURL = vi.fn(); +457: const clickMock = vi +458: .spyOn(HTMLAnchorElement.prototype, 'click') +459: .mockImplementation(() => {}); +460: vi.stubGlobal('URL', { createObjectURL, revokeObjectURL }); +461: +462: mockAPI.getRecentLogs.mockResolvedValue({ +463: logs: [createMockLogEntry({ message: 'Export log' })], +464: total_count: 1, +465: }); +466: +467: await renderLogsTab(); +468: +469: await waitFor(() => { +470: // Messages may appear multiple times (in main view and expanded details) +471: expect(screen.getAllByText('Export log').length).toBeGreaterThan(0); +472: }); +473: +474: const exportButton = screen.getByTitle('Export logs'); +475: fireEvent.click(exportButton); +476: +477: expect(createObjectURL).toHaveBeenCalled(); +478: expect(revokeObjectURL).toHaveBeenCalledWith('blob:logs'); +479: clickMock.mockRestore(); +480: }); +481: }); +482: +483: describe('Footer', () => { +484: it('shows log count in footer', async () => { +485: // Use different messages to avoid summarization grouping +486: mockAPI.getRecentLogs.mockResolvedValue({ +487: logs: [ +488: createMockLogEntry({ message: 'First log' }), +489: createMockLogEntry({ message: 'Second log' }), +490: ], +491: total_count: 50, +492: }); +493: +494: await renderLogsTab(); +495: +496: await waitFor(() => { +497: expect(screen.getByText(/Showing 2 logs of 2 total/)).toBeInTheDocument(); +498: }); +499: }); +500: }); +501: }); ```` -## File: client/src/lib/client-log-events.test.ts +## File: client/src/components/analytics/logs-tab.tsx ````typescript - 1: import { afterEach, describe, expect, it, vi } from 'vitest'; - 2: import { clientLog } from './client-log-events'; - 3: import * as clientLogs from './client-logs'; - 4: - 5: vi.mock('./client-logs', () => ({ - 6: addClientLog: vi.fn(), - 7: })); - 8: - 9: const mockAddClientLog = vi.mocked(clientLogs.addClientLog); - 10: - 11: describe('clientLog', () => { - 12: afterEach(() => { - 13: vi.clearAllMocks(); - 14: }); - 15: - 16: describe('meeting lifecycle', () => { - 17: it('emits meetingCreated with title', () => { - 18: clientLog.meetingCreated('meeting-123', 'Team Standup'); - 19: expect(mockAddClientLog).toHaveBeenCalledWith({ - 20: level: 'info', - 21: source: 'app', - 22: message: 'Created meeting', - 23: metadata: { meeting_id: 'meeting-123', title: 'Team Standup' }, - 24: }); - 25: }); - 26: - 27: it('emits meetingCreated without title', () => { - 28: clientLog.meetingCreated('meeting-123'); - 29: expect(mockAddClientLog).toHaveBeenCalledWith({ - 30: level: 'info', - 31: source: 'app', - 32: message: 'Created meeting', - 33: metadata: { meeting_id: 'meeting-123', title: '' }, - 34: }); - 35: }); - 36: - 37: it('emits meetingStarted', () => { - 38: clientLog.meetingStarted('meeting-123', 'Daily Sync'); - 39: expect(mockAddClientLog).toHaveBeenCalledWith({ - 40: level: 'info', - 41: source: 'app', - 42: message: 'Meeting started', - 43: metadata: { meeting_id: 'meeting-123', title: 'Daily Sync' }, - 44: }); - 45: }); - 46: - 47: it('emits recordingStartFailed with metadata', () => { - 48: clientLog.recordingStartFailed('meeting-123', 'Connection refused', 14, 'network', true); - 49: expect(mockAddClientLog).toHaveBeenCalledWith({ - 50: level: 'error', - 51: source: 'app', - 52: message: 'Recording start failed', - 53: metadata: { - 54: meeting_id: 'meeting-123', - 55: error: 'Connection refused', - 56: grpc_status: '14', - 57: category: 'network', - 58: retryable: 'true', - 59: }, - 60: }); - 61: }); - 62: - 63: it('emits meetingStopped', () => { - 64: clientLog.meetingStopped('meeting-123', 'Daily Sync'); - 65: expect(mockAddClientLog).toHaveBeenCalledWith({ - 66: level: 'info', - 67: source: 'app', - 68: message: 'Meeting stopped', - 69: metadata: { meeting_id: 'meeting-123', title: 'Daily Sync' }, - 70: }); - 71: }); - 72: - 73: it('emits meetingDeleted', () => { - 74: clientLog.meetingDeleted('meeting-123'); - 75: expect(mockAddClientLog).toHaveBeenCalledWith({ - 76: level: 'info', - 77: source: 'app', - 78: message: 'Meeting deleted', - 79: metadata: { meeting_id: 'meeting-123' }, - 80: }); - 81: }); - 82: }); - 83: - 84: describe('summarization', () => { - 85: it('emits summarizing with segment count', () => { - 86: clientLog.summarizing('meeting-123', 42); - 87: expect(mockAddClientLog).toHaveBeenCalledWith({ - 88: level: 'info', - 89: source: 'app', - 90: message: 'Summarizing', - 91: metadata: { meeting_id: 'meeting-123', segment_count: '42' }, - 92: }); - 93: }); - 94: - 95: it('emits summaryGenerated with model', () => { - 96: clientLog.summaryGenerated('meeting-123', 'claude-3'); - 97: expect(mockAddClientLog).toHaveBeenCalledWith({ - 98: level: 'info', - 99: source: 'app', -100: message: 'Summary generated', -101: metadata: { meeting_id: 'meeting-123', model: 'claude-3' }, -102: }); -103: }); -104: -105: it('emits summaryFailed with error', () => { -106: clientLog.summaryFailed('meeting-123', 'API timeout'); -107: expect(mockAddClientLog).toHaveBeenCalledWith({ -108: level: 'error', -109: source: 'app', -110: message: 'Summary generation failed', -111: metadata: { meeting_id: 'meeting-123', error: 'API timeout' }, -112: }); -113: }); -114: }); -115: -116: describe('cloud consent', () => { -117: it('emits cloudConsentGranted', () => { -118: clientLog.cloudConsentGranted(); -119: expect(mockAddClientLog).toHaveBeenCalledWith({ -120: level: 'info', -121: source: 'app', -122: message: 'Cloud consent granted', -123: metadata: undefined, -124: }); -125: }); -126: -127: it('emits cloudConsentRevoked', () => { -128: clientLog.cloudConsentRevoked(); -129: expect(mockAddClientLog).toHaveBeenCalledWith({ -130: level: 'info', -131: source: 'app', -132: message: 'Cloud consent revoked', -133: metadata: undefined, -134: }); -135: }); -136: }); -137: -138: describe('export', () => { -139: it('emits exportStarted', () => { -140: clientLog.exportStarted('meeting-123', 'markdown'); -141: expect(mockAddClientLog).toHaveBeenCalledWith({ -142: level: 'info', -143: source: 'app', -144: message: 'Starting transcript export', -145: metadata: { meeting_id: 'meeting-123', format: 'markdown' }, -146: }); -147: }); -148: -149: it('emits exportCompleted', () => { -150: clientLog.exportCompleted('meeting-123', 'pdf'); -151: expect(mockAddClientLog).toHaveBeenCalledWith({ -152: level: 'info', -153: source: 'app', -154: message: 'Transcript export completed', -155: metadata: { meeting_id: 'meeting-123', format: 'pdf' }, -156: }); -157: }); -158: -159: it('emits exportFailed', () => { -160: clientLog.exportFailed('meeting-123', 'pdf', 'WeasyPrint not installed'); -161: expect(mockAddClientLog).toHaveBeenCalledWith({ -162: level: 'error', -163: source: 'app', -164: message: 'Export failed', -165: metadata: { meeting_id: 'meeting-123', format: 'pdf', error: 'WeasyPrint not installed' }, -166: }); -167: }); -168: }); + 1: import { useQuery } from '@tanstack/react-query'; + 2: import { format } from 'date-fns'; + 3: import { + 4: Clock, + 5: Download, + 6: Eye, + 7: FileText, + 8: Filter, + 9: Folder, + 10: Layers, + 11: List, + 12: RefreshCw, + 13: Search, + 14: Terminal, + 15: } from 'lucide-react'; + 16: import { useEffect, useMemo, useState } from 'react'; + 17: import { Timing } from '@/api/constants'; + 18: import { getAPI } from '@/api/interface'; + 19: import type { LogLevel as ApiLogLevel, LogSource as ApiLogSource } from '@/api/types'; + 20: import { LogEntry as LogEntryComponent, type LogEntryData } from '@/components/analytics/log-entry'; + 21: import { levelConfig } from '@/components/analytics/log-entry-config'; + 22: import { AnalyticsCardTitle } from '@/components/analytics/analytics-card-title'; + 23: import { LogTimeline } from '@/components/analytics/log-timeline'; + 24: import { Button } from '@/components/ui/button'; + 25: import { Card, CardContent, CardDescription, CardHeader } from '@/components/ui/card'; + 26: import { Input } from '@/components/ui/input'; + 27: import { ScrollArea } from '@/components/ui/scroll-area'; + 28: import { + 29: Select, + 30: SelectContent, + 31: SelectItem, + 32: SelectTrigger, + 33: SelectValue, + 34: } from '@/components/ui/select'; + 35: import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'; + 36: import { + 37: Tooltip, + 38: TooltipContent, + 39: TooltipProvider, + 40: TooltipTrigger, + 41: } from '@/components/ui/tooltip'; + 42: import { + 43: getClientLogs, + 44: subscribeClientLogs, + 45: type ClientLogEntry, + 46: } from '@/lib/client-logs'; + 47: import { convertLogEntry } from '@/lib/log-converters'; + 48: import { groupLogs, type GroupMode } from '@/lib/log-groups'; + 49: import { + 50: summarizeConsecutive, + 51: type SummarizableLog, + 52: type SummarizedLog, + 53: } from '@/lib/log-summarizer'; + 54: import { cardPadding, iconWithMargin } from '@/lib/styles'; + 55: import { cn } from '@/lib/utils'; + 56: + 57: type LogLevel = ApiLogLevel; + 58: type LogSource = ApiLogSource; + 59: type LogOrigin = 'client' | 'server'; + 60: type ViewMode = 'friendly' | 'technical'; + 61: + 62: const LOG_LEVELS: LogLevel[] = ['info', 'warning', 'error', 'debug']; + 63: + 64: export function LogsTab() { + 65: const [searchQuery, setSearchQuery] = useState(''); + 66: const [levelFilter, setLevelFilter] = useState('all'); + 67: const [sourceFilter, setSourceFilter] = useState('all'); + 68: const [originFilter, setOriginFilter] = useState('all'); + 69: const [expandedLogs, setExpandedLogs] = useState>(new Set()); + 70: const [clientLogs, setClientLogs] = useState(() => getClientLogs()); + 71: const [viewMode, setViewMode] = useState('friendly'); + 72: const [enableSummarization, setEnableSummarization] = useState(true); + 73: const [groupMode, setGroupMode] = useState('none'); + 74: + 75: useEffect(() => subscribeClientLogs(setClientLogs), []); + 76: + 77: // Fetch logs from backend + 78: const { + 79: data: logsResponse, + 80: isLoading, + 81: refetch, + 82: isRefetching, + 83: } = useQuery({ + 84: queryKey: ['logs', levelFilter, sourceFilter], + 85: queryFn: async () => { + 86: const api = getAPI(); + 87: return api.getRecentLogs({ + 88: limit: 500, + 89: level: levelFilter === 'all' ? undefined : levelFilter, + 90: source: sourceFilter === 'all' ? undefined : sourceFilter, + 91: }); + 92: }, + 93: refetchInterval: Timing.THIRTY_SECONDS_MS, + 94: }); + 95: + 96: const serverLogs = useMemo(() => { + 97: if (!logsResponse?.logs) { + 98: return []; + 99: } +100: return logsResponse.logs.map(convertLogEntry); +101: }, [logsResponse]); +102: +103: const mergedLogs = useMemo(() => { +104: const client = clientLogs.map((entry) => ({ +105: ...entry, +106: origin: 'client' as const, +107: })); +108: const combined = [...client, ...serverLogs]; +109: return combined.sort((a, b) => b.timestamp - a.timestamp); +110: }, [clientLogs, serverLogs]); +111: +112: // Client-side search filtering (level/source already filtered by API) +113: const filteredLogs = useMemo(() => { +114: const query = searchQuery.toLowerCase(); +115: return mergedLogs.filter((log) => { +116: if (originFilter !== 'all' && log.origin !== originFilter) { +117: return false; +118: } +119: if (levelFilter !== 'all' && log.level !== levelFilter) { +120: return false; +121: } +122: if (sourceFilter !== 'all' && log.source !== sourceFilter) { +123: return false; +124: } +125: if (query === '') { +126: return true; +127: } +128: const metadataText = log.metadata ? JSON.stringify(log.metadata).toLowerCase() : ''; +129: const correlationText = [log.traceId, log.spanId].filter(Boolean).join(' ').toLowerCase(); +130: return ( +131: log.message.toLowerCase().includes(query) || +132: log.details?.toLowerCase().includes(query) || +133: metadataText.includes(query) || +134: correlationText.includes(query) +135: ); +136: }); +137: }, [mergedLogs, searchQuery, originFilter, levelFilter, sourceFilter]); +138: +139: // Apply summarization when enabled +140: const summarizedLogs = useMemo(() => { +141: if (!enableSummarization) { +142: return filteredLogs.map((log) => ({ +143: log, +144: count: 1, +145: isGroup: false, +146: groupedLogs: undefined, +147: })); +148: } +149: return summarizeConsecutive(filteredLogs as SummarizableLog[]) as SummarizedLog[]; +150: }, [filteredLogs, enableSummarization]); +151: +152: // Group logs when in timeline mode +153: const logGroups = useMemo(() => { +154: if (groupMode === 'none') { +155: return []; +156: } +157: return groupLogs(filteredLogs, groupMode); +158: }, [filteredLogs, groupMode]); +159: +160: const logStats = useMemo>(() => { +161: return filteredLogs.reduce( +162: (stats, log) => { +163: stats[log.level]++; +164: return stats; +165: }, +166: { info: 0, warning: 0, error: 0, debug: 0 } +167: ); +168: }, [filteredLogs]); 169: -170: describe('diarization', () => { -171: it('emits diarizationStarted', () => { -172: clientLog.diarizationStarted('meeting-123', 'job-456'); -173: expect(mockAddClientLog).toHaveBeenCalledWith({ -174: level: 'info', -175: source: 'app', -176: message: 'Diarization job', -177: metadata: { meeting_id: 'meeting-123', job_id: 'job-456', status: 'running' }, -178: }); +170: const toggleExpanded = (id: string) => { +171: setExpandedLogs((prev) => { +172: const next = new Set(prev); +173: if (next.has(id)) { +174: next.delete(id); +175: } else { +176: next.add(id); +177: } +178: return next; 179: }); -180: -181: it('emits diarizationCompleted', () => { -182: clientLog.diarizationCompleted('meeting-123', 'job-456'); -183: expect(mockAddClientLog).toHaveBeenCalledWith({ -184: level: 'info', -185: source: 'app', -186: message: 'Diarization job', -187: metadata: { meeting_id: 'meeting-123', job_id: 'job-456', status: 'completed' }, -188: }); -189: }); -190: -191: it('emits diarizationFailed', () => { -192: clientLog.diarizationFailed('meeting-123', 'job-456', 'Model not loaded'); -193: expect(mockAddClientLog).toHaveBeenCalledWith({ -194: level: 'error', -195: source: 'app', -196: message: 'Diarization job', -197: metadata: { -198: meeting_id: 'meeting-123', -199: job_id: 'job-456', -200: status: 'failed', -201: error: 'Model not loaded', -202: }, -203: }); -204: }); -205: -206: it('emits speakerRenamed', () => { -207: clientLog.speakerRenamed('meeting-123', 'SPEAKER_00', 'Alice'); -208: expect(mockAddClientLog).toHaveBeenCalledWith({ -209: level: 'info', -210: source: 'app', -211: message: 'Speaker renamed', -212: metadata: { meeting_id: 'meeting-123', old_name: 'SPEAKER_00', new_name: 'Alice' }, -213: }); -214: }); -215: }); -216: -217: describe('entities', () => { -218: it('emits entitiesExtracted', () => { -219: clientLog.entitiesExtracted('meeting-123', 15); -220: expect(mockAddClientLog).toHaveBeenCalledWith({ -221: level: 'info', -222: source: 'app', -223: message: 'Extracted', -224: metadata: { meeting_id: 'meeting-123', count: '15' }, -225: }); -226: }); -227: }); -228: -229: describe('webhooks', () => { -230: it('emits webhookRegistered with name', () => { -231: clientLog.webhookRegistered('webhook-123', 'Slack Notifier'); -232: expect(mockAddClientLog).toHaveBeenCalledWith({ -233: level: 'info', -234: source: 'app', -235: message: 'Webhook_registered', -236: metadata: { webhook_id: 'webhook-123', name: 'Slack Notifier' }, +180: }; +181: +182: const handleRefresh = () => { +183: refetch(); +184: }; +185: +186: const exportLogs = () => { +187: const blob = new Blob([JSON.stringify(filteredLogs, null, 2)], { type: 'application/json' }); +188: const url = URL.createObjectURL(blob); +189: const a = document.createElement('a'); +190: a.href = url; +191: a.download = `logs-${format(new Date(), 'yyyy-MM-dd-HHmmss')}.json`; +192: a.click(); +193: URL.revokeObjectURL(url); +194: }; +195: +196: return ( +197:
+198: {/* Log Stats */} +199:
+200: {LOG_LEVELS.map((level) => { +201: const count = logStats[level]; +202: const config = levelConfig[level]; +203: const Icon = config.icon; +204: return ( +205: +206: +207:
+208: +209:
+210:
+211:

{count}

+212:

{level}

+213:
+214:
+215:
+216: ); +217: })} +218:
+219: +220: {/* Filters */} +221: +222: +223: +224: +225: Application Logs +226: +227: View and filter system and application logs +228: +229: +230:
+231:
+232: +233: setSearchQuery(e.target.value)} +237: className="pl-9" +238: /> +239:
+240:
+241: +257: +273: +286: +295: +298:
+299:
+300: +301: {/* View Mode Toggle */} +302:
+303:
+304: +305:
+306: View: +307: v && setViewMode(v as ViewMode)} +311: size="sm" +312: > +313: +314: +315: +316: +317: +318: +319: +320:

Friendly: Human-readable messages

+321:
+322:
+323: +324: +325: +326: +327: +328: +329: +330:

Technical: Raw log messages with IDs

+331:
+332:
+333:
+334:
+335: +336:
+337: +338: +339: +351: +352: +353:

+354: {enableSummarization +355: ? 'Showing grouped similar logs' +356: : 'Showing all individual logs'} +357:

+358:
+359:
+360:
+361: +362: {/* Group Mode Selector */} +363:
+364: Group: +365: v && setGroupMode(v as GroupMode)} +369: size="sm" +370: > +371: +372: +373: +374: +375: +376: +377: +378:

Flat list (no grouping)

+379:
+380:
+381: +382: +383: +384: +385: +386: +387: +388:

Group by meeting

+389:
+390:
+391: +392: +393: +394: +395: +396: +397: +398:

Group by time (5-minute gaps)

+399:
+400:
+401:
+402:
+403:
+404:
+405: +406: {groupMode !== 'none' ? ( +407: +408: {logGroups.length} group{logGroups.length !== 1 ? 's' : ''},{' '} +409: {filteredLogs.length} total logs +410: +411: ) : enableSummarization && summarizedLogs.some((s) => s.isGroup) ? ( +412: +413: {summarizedLogs.filter((s) => s.isGroup).length} groups,{' '} +414: {filteredLogs.length} total logs +415: +416: ) : null} +417:
+418: +419: {/* Log List */} +420: +421: {isLoading ? ( +422:
+423: +424:

Loading logs...

+425:
+426: ) : filteredLogs.length === 0 ? ( +427:
+428: +429:

No logs found

+430:

+431: {searchQuery || +432: levelFilter !== 'all' || +433: sourceFilter !== 'all' || +434: originFilter !== 'all' +435: ? 'Try adjusting your filters' +436: : 'Logs will appear here as events occur'} +437:

+438:
+439: ) : groupMode !== 'none' ? ( +440: +446: ) : ( +447:
+448: {summarizedLogs.map((summarized) => ( +449: } +452: viewMode={viewMode} +453: isExpanded={expandedLogs.has(summarized.log.id)} +454: onToggleExpanded={() => toggleExpanded(summarized.log.id)} +455: /> +456: ))} +457:
+458: )} +459:
+460: +461: {/* Footer */} +462:
+463: +464: Showing {summarizedLogs.length} +465: {enableSummarization && summarizedLogs.length !== filteredLogs.length +466: ? ` entries (${filteredLogs.length} logs)` +467: : ' logs'}{' '} +468: of {mergedLogs.length} total +469: +470: +471: {isRefetching ? 'Refreshing...' : `Last updated: ${format(new Date(), 'HH:mm:ss')}`} +472: +473:
+474:
+475:
+476:
+477: ); +478: } +```` + +## File: client/src/components/analytics/performance-tab.test.tsx +````typescript + 1: import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + 2: import { fireEvent, render, screen, waitFor } from '@testing-library/react'; + 3: import { beforeEach, describe, expect, it, vi } from 'vitest'; + 4: import * as apiInterface from '@/api/interface'; + 5: import type { GetPerformanceMetricsResponse, PerformanceMetricsPoint } from '@/api/types'; + 6: import { PerformanceTab } from './performance-tab'; + 7: + 8: // Mock the API module + 9: vi.mock('@/api/interface', () => ({ + 10: getAPI: vi.fn(), + 11: })); + 12: + 13: // Mock date-fns format for deterministic output + 14: vi.mock('date-fns', async () => { + 15: const actual = await vi.importActual('date-fns'); + 16: return { + 17: ...actual, + 18: format: vi.fn(() => '12:00'), + 19: }; + 20: }); + 21: + 22: // Mock recharts to avoid rendering issues in tests + 23: vi.mock('recharts', () => ({ + 24: AreaChart: ({ children }: { children: React.ReactNode }) => ( + 25: + 28: ), + 29: Area: () => null, + 30: LineChart: ({ children }: { children: React.ReactNode }) => ( + 31: + 34: ), + 35: Line: () => null, + 36: XAxis: () => null, + 37: YAxis: () => null, + 38: CartesianGrid: () => null, + 39: ResponsiveContainer: ({ children }: { children: React.ReactNode }) => ( + 40:
{children}
+ 41: ), + 42: })); + 43: + 44: // Mock chart components + 45: vi.mock('@/components/ui/chart', () => ({ + 46: ChartContainer: ({ children }: { children: React.ReactNode }) => ( + 47:
{children}
+ 48: ), + 49: ChartTooltip: () => null, + 50: ChartTooltipContent: () => null, + 51: })); + 52: + 53: // Helper to create QueryClient wrapper + 54: function createWrapper() { + 55: const queryClient = new QueryClient({ + 56: defaultOptions: { + 57: queries: { + 58: retry: false, + 59: gcTime: 0, + 60: }, + 61: }, + 62: }); + 63: return function Wrapper({ children }: { children: React.ReactNode }) { + 64: return {children}; + 65: }; + 66: } + 67: + 68: function createMockMetricsPoint( + 69: overrides: Partial = {} + 70: ): PerformanceMetricsPoint { + 71: return { + 72: timestamp: Date.now() / 1000, + 73: cpu_percent: 25.0, + 74: memory_percent: 50.0, + 75: memory_mb: 4096, + 76: disk_percent: 35.0, + 77: network_bytes_sent: 1024, + 78: network_bytes_recv: 2048, + 79: process_memory_mb: 256, + 80: active_connections: 5, + 81: ...overrides, + 82: }; + 83: } + 84: + 85: describe('PerformanceTab', () => { + 86: const mockAPI = { + 87: getPerformanceMetrics: vi.fn<() => Promise>(), + 88: }; + 89: + 90: beforeEach(() => { + 91: vi.mocked(apiInterface.getAPI).mockReturnValue( + 92: mockAPI as unknown as ReturnType + 93: ); + 94: vi.clearAllMocks(); + 95: + 96: // Mock navigator properties + 97: Object.defineProperty(navigator, 'platform', { + 98: value: 'TestPlatform', + 99: configurable: true, +100: }); +101: Object.defineProperty(navigator, 'hardwareConcurrency', { +102: value: 8, +103: configurable: true, +104: }); +105: Object.defineProperty(navigator, 'onLine', { +106: value: true, +107: configurable: true, +108: }); +109: Object.defineProperty(navigator, 'deviceMemory', { +110: value: 16, +111: configurable: true, +112: }); +113: }); +114: +115: describe('Loading State', () => { +116: it('shows loading state while fetching metrics', async () => { +117: mockAPI.getPerformanceMetrics.mockImplementation(() => new Promise(() => {})); +118: +119: render(, { wrapper: createWrapper() }); +120: +121: expect(screen.getByText('Loading...')).toBeInTheDocument(); +122: }); +123: }); +124: +125: describe('Health Score', () => { +126: it('displays system health section', async () => { +127: mockAPI.getPerformanceMetrics.mockResolvedValue({ +128: current: createMockMetricsPoint(), +129: history: [], +130: }); +131: +132: render(, { wrapper: createWrapper() }); +133: +134: await waitFor(() => { +135: expect(screen.getByText('System Health')).toBeInTheDocument(); +136: }); +137: }); +138: +139: it('shows healthy status for good metrics', async () => { +140: mockAPI.getPerformanceMetrics.mockResolvedValue({ +141: current: createMockMetricsPoint({ cpu_percent: 20, memory_percent: 30 }), +142: history: [createMockMetricsPoint({ cpu_percent: 20, memory_percent: 30 })], +143: }); +144: +145: render(, { wrapper: createWrapper() }); +146: +147: await waitFor(() => { +148: expect(screen.getByText('Healthy')).toBeInTheDocument(); +149: expect(screen.getByText('All systems are running optimally')).toBeInTheDocument(); +150: }); +151: }); +152: +153: it('shows moderate status for elevated metrics', async () => { +154: mockAPI.getPerformanceMetrics.mockResolvedValue({ +155: current: createMockMetricsPoint({ cpu_percent: 60, memory_percent: 70 }), +156: history: [createMockMetricsPoint({ cpu_percent: 60, memory_percent: 70 })], +157: }); +158: +159: render(, { wrapper: createWrapper() }); +160: +161: await waitFor(() => { +162: expect(screen.getByText('Moderate')).toBeInTheDocument(); +163: expect(screen.getByText('Some metrics could be improved')).toBeInTheDocument(); +164: }); +165: }); +166: +167: it('shows degraded status for high resource usage', async () => { +168: mockAPI.getPerformanceMetrics.mockResolvedValue({ +169: current: createMockMetricsPoint({ cpu_percent: 95, memory_percent: 95 }), +170: history: [createMockMetricsPoint({ cpu_percent: 95, memory_percent: 95 })], +171: }); +172: +173: render(, { wrapper: createWrapper() }); +174: +175: // With very high CPU and memory, health score drops - verify the component renders +176: await waitFor(() => { +177: expect(screen.getByText('System Health')).toBeInTheDocument(); +178: }); +179: +180: // The health score should be visible (a number in the gauge) +181: const healthGauge = screen.getByText('System Health'); +182: expect(healthGauge).toBeInTheDocument(); +183: }); +184: }); +185: +186: describe('Metric Cards', () => { +187: it('displays CPU usage metric', async () => { +188: mockAPI.getPerformanceMetrics.mockResolvedValue({ +189: current: createMockMetricsPoint({ cpu_percent: 45 }), +190: history: [createMockMetricsPoint({ cpu_percent: 45 })], +191: }); +192: +193: render(, { wrapper: createWrapper() }); +194: +195: await waitFor(() => { +196: expect(screen.getByText('CPU Usage')).toBeInTheDocument(); +197: expect(screen.getByText('45.0')).toBeInTheDocument(); +198: }); +199: }); +200: +201: it('displays memory usage metric', async () => { +202: mockAPI.getPerformanceMetrics.mockResolvedValue({ +203: current: createMockMetricsPoint({ memory_percent: 60 }), +204: history: [createMockMetricsPoint({ memory_percent: 60 })], +205: }); +206: +207: render(, { wrapper: createWrapper() }); +208: +209: await waitFor(() => { +210: expect(screen.getByText('Memory Usage')).toBeInTheDocument(); +211: }); +212: }); +213: +214: it('displays network latency metric', async () => { +215: mockAPI.getPerformanceMetrics.mockResolvedValue({ +216: current: createMockMetricsPoint(), +217: history: [createMockMetricsPoint()], +218: }); +219: +220: render(, { wrapper: createWrapper() }); +221: +222: await waitFor(() => { +223: expect(screen.getByText('Network Latency')).toBeInTheDocument(); +224: }); +225: }); +226: +227: it('displays frame rate metric', async () => { +228: mockAPI.getPerformanceMetrics.mockResolvedValue({ +229: current: createMockMetricsPoint(), +230: history: [createMockMetricsPoint()], +231: }); +232: +233: render(, { wrapper: createWrapper() }); +234: +235: await waitFor(() => { +236: expect(screen.getByText('Frame Rate')).toBeInTheDocument(); 237: }); 238: }); -239: -240: it('emits webhookDeleted', () => { -241: clientLog.webhookDeleted('webhook-123'); -242: expect(mockAddClientLog).toHaveBeenCalledWith({ -243: level: 'info', -244: source: 'app', -245: message: 'Webhook deleted', -246: metadata: { webhook_id: 'webhook-123' }, -247: }); -248: }); -249: }); -250: -251: describe('calendar', () => { -252: it('emits calendarConnected', () => { -253: clientLog.calendarConnected('google'); -254: expect(mockAddClientLog).toHaveBeenCalledWith({ -255: level: 'info', -256: source: 'sync', -257: message: 'Calendar connected', -258: metadata: { provider: 'google' }, +239: }); +240: +241: describe('Charts', () => { +242: it('renders CPU and memory chart', async () => { +243: mockAPI.getPerformanceMetrics.mockResolvedValue({ +244: current: createMockMetricsPoint(), +245: history: [createMockMetricsPoint(), createMockMetricsPoint()], +246: }); +247: +248: render(, { wrapper: createWrapper() }); +249: +250: await waitFor(() => { +251: expect(screen.getByText('CPU & Memory Over Time')).toBeInTheDocument(); +252: }); +253: }); +254: +255: it('renders network and rendering chart', async () => { +256: mockAPI.getPerformanceMetrics.mockResolvedValue({ +257: current: createMockMetricsPoint(), +258: history: [createMockMetricsPoint(), createMockMetricsPoint()], 259: }); -260: }); -261: -262: it('emits calendarDisconnected', () => { -263: clientLog.calendarDisconnected('outlook'); -264: expect(mockAddClientLog).toHaveBeenCalledWith({ -265: level: 'info', -266: source: 'sync', -267: message: 'Calendar disconnected', -268: metadata: { provider: 'outlook' }, -269: }); -270: }); -271: -272: it('emits calendarSynced', () => { -273: clientLog.calendarSynced('google', 12); -274: expect(mockAddClientLog).toHaveBeenCalledWith({ -275: level: 'info', -276: source: 'sync', -277: message: 'Calendar sync', -278: metadata: { provider: 'google', count: '12' }, -279: }); -280: }); -281: }); +260: +261: render(, { wrapper: createWrapper() }); +262: +263: await waitFor(() => { +264: expect(screen.getByText('Network & Rendering')).toBeInTheDocument(); +265: }); +266: }); +267: }); +268: +269: describe('System Information', () => { +270: it('displays system information section', async () => { +271: mockAPI.getPerformanceMetrics.mockResolvedValue({ +272: current: createMockMetricsPoint(), +273: history: [], +274: }); +275: +276: render(, { wrapper: createWrapper() }); +277: +278: await waitFor(() => { +279: expect(screen.getByText('System Information')).toBeInTheDocument(); +280: }); +281: }); 282: -283: describe('connection', () => { -284: it('emits connected', () => { -285: clientLog.connected('localhost:50051'); -286: expect(mockAddClientLog).toHaveBeenCalledWith({ -287: level: 'info', -288: source: 'system', -289: message: 'Connected', -290: metadata: { server: 'localhost:50051' }, -291: }); -292: }); -293: -294: it('emits disconnected', () => { -295: clientLog.disconnected(); -296: expect(mockAddClientLog).toHaveBeenCalledWith({ -297: level: 'info', -298: source: 'system', -299: message: 'Disconnected', -300: metadata: undefined, +283: it('shows platform information', async () => { +284: mockAPI.getPerformanceMetrics.mockResolvedValue({ +285: current: createMockMetricsPoint(), +286: history: [], +287: }); +288: +289: render(, { wrapper: createWrapper() }); +290: +291: await waitFor(() => { +292: expect(screen.getByText('Platform')).toBeInTheDocument(); +293: expect(screen.getByText('TestPlatform')).toBeInTheDocument(); +294: }); +295: }); +296: +297: it('shows hardware info', async () => { +298: mockAPI.getPerformanceMetrics.mockResolvedValue({ +299: current: createMockMetricsPoint(), +300: history: [], 301: }); -302: }); -303: -304: it('emits connectionFailed', () => { -305: clientLog.connectionFailed('Connection refused'); -306: expect(mockAddClientLog).toHaveBeenCalledWith({ -307: level: 'error', -308: source: 'system', -309: message: 'Connection failed', -310: metadata: { error: 'Connection refused' }, -311: }); -312: }); -313: }); -314: -315: describe('auth', () => { -316: it('emits loginCompleted', () => { -317: clientLog.loginCompleted('google'); -318: expect(mockAddClientLog).toHaveBeenCalledWith({ -319: level: 'info', -320: source: 'auth', -321: message: 'Login completed', -322: metadata: { provider: 'google' }, -323: }); -324: }); +302: +303: render(, { wrapper: createWrapper() }); +304: +305: await waitFor(() => { +306: // System info section shows CPU cores label +307: expect(screen.getByText('CPU Cores')).toBeInTheDocument(); +308: }); +309: }); +310: +311: it('shows network status', async () => { +312: mockAPI.getPerformanceMetrics.mockResolvedValue({ +313: current: createMockMetricsPoint(), +314: history: [], +315: }); +316: +317: render(, { wrapper: createWrapper() }); +318: +319: await waitFor(() => { +320: // Network Status is shown in system info +321: expect(screen.getByText('Network Status')).toBeInTheDocument(); +322: }); +323: }); +324: }); 325: -326: it('emits loggedOut', () => { -327: clientLog.loggedOut('google'); -328: expect(mockAddClientLog).toHaveBeenCalledWith({ -329: level: 'info', -330: source: 'auth', -331: message: 'Logged out', -332: metadata: { provider: 'google' }, -333: }); -334: }); -335: }); -336: -337: describe('triggers', () => { -338: it('emits triggerDetected', () => { -339: clientLog.triggerDetected('calendar'); -340: expect(mockAddClientLog).toHaveBeenCalledWith({ -341: level: 'info', -342: source: 'app', -343: message: 'Trigger detected', -344: metadata: { trigger_type: 'calendar' }, -345: }); -346: }); -347: -348: it('emits triggersSnoozed', () => { -349: clientLog.triggersSnoozed(30); -350: expect(mockAddClientLog).toHaveBeenCalledWith({ -351: level: 'info', -352: source: 'app', -353: message: 'Triggers snoozed', -354: metadata: { minutes: '30' }, -355: }); -356: }); -357: -358: it('emits triggerSnoozeCleared', () => { -359: clientLog.triggerSnoozeCleared(); -360: expect(mockAddClientLog).toHaveBeenCalledWith({ -361: level: 'info', -362: source: 'app', -363: message: 'Trigger snooze cleared', -364: metadata: undefined, -365: }); -366: }); -367: }); -368: }); +326: describe('Refresh', () => { +327: it('refetches metrics when refresh button clicked', async () => { +328: mockAPI.getPerformanceMetrics.mockResolvedValue({ +329: current: createMockMetricsPoint(), +330: history: [], +331: }); +332: +333: render(, { wrapper: createWrapper() }); +334: +335: await waitFor(() => { +336: expect(mockAPI.getPerformanceMetrics).toHaveBeenCalledTimes(1); +337: }); +338: +339: // Wait for button to show "Refresh" (not "Loading...") +340: await waitFor(() => { +341: expect(screen.getByRole('button', { name: /refresh/i })).toBeInTheDocument(); +342: }); +343: +344: const refreshButton = screen.getByRole('button', { name: /refresh/i }); +345: fireEvent.click(refreshButton); +346: +347: await waitFor(() => { +348: expect(mockAPI.getPerformanceMetrics).toHaveBeenCalledTimes(2); +349: }); +350: }); +351: }); +352: }); ```` -## File: client/src/lib/client-log-events.ts +## File: client/src/components/analytics/performance-tab.tsx +````typescript + 1: import { useQuery } from '@tanstack/react-query'; + 2: import { format } from 'date-fns'; + 3: import { + 4: Activity, + 5: Cpu, + 6: Gauge, + 7: HardDrive, + 8: type LucideIcon, + 9: RefreshCw, + 10: Server, + 11: Wifi, + 12: } from 'lucide-react'; + 13: import { useMemo } from 'react'; + 14: import { Area, AreaChart, CartesianGrid, Line, LineChart, XAxis, YAxis } from 'recharts'; + 15: import { getAPI } from '@/api/interface'; + 16: import { METRICS_REFRESH_INTERVAL_MS } from '@/lib/timing-constants'; + 17: import type { PerformanceMetricsPoint } from '@/api/types'; + 18: import { Badge } from '@/components/ui/badge'; + 19: import { Button } from '@/components/ui/button'; + 20: import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; + 21: import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart'; + 22: import { chartStrokes, flexLayout, iconWithMargin, overflow, typography } from '@/lib/styles'; + 23: import { cn } from '@/lib/utils'; + 24: + 25: // Mapped performance metric for UI display + 26: interface PerformanceMetric { + 27: timestamp: number; + 28: cpu: number; + 29: memory: number; + 30: networkLatency: number; + 31: fps: number; + 32: } + 33: + 34: interface SystemInfo { + 35: platform: string; + 36: userAgent: string; + 37: language: string; + 38: cookiesEnabled: boolean; + 39: onLine: boolean; + 40: hardwareConcurrency: number; + 41: deviceMemory?: number; + 42: } + 43: + 44: // Convert API metric to UI display format + 45: function convertMetric(point: PerformanceMetricsPoint): PerformanceMetric { + 46: // Estimate network latency from active connections (more connections = higher latency) + 47: const estimatedLatency = 20 + point.active_connections * 3; + 48: return { + 49: timestamp: point.timestamp, + 50: cpu: point.cpu_percent, + 51: memory: point.memory_percent, + 52: networkLatency: estimatedLatency, + 53: fps: 60 - (point.cpu_percent > 80 ? 15 : point.cpu_percent > 50 ? 5 : 0), // Estimate FPS from CPU load + 54: }; + 55: } + 56: + 57: function getSystemInfo(): SystemInfo { + 58: return { + 59: platform: navigator.platform || 'Unknown', + 60: userAgent: navigator.userAgent, + 61: language: navigator.language, + 62: cookiesEnabled: navigator.cookieEnabled, + 63: onLine: navigator.onLine, + 64: hardwareConcurrency: navigator.hardwareConcurrency || 1, + 65: deviceMemory: navigator.deviceMemory, + 66: }; + 67: } + 68: + 69: interface MetricCardProps { + 70: icon: LucideIcon; + 71: title: string; + 72: value: number; + 73: unit: string; + 74: status: 'good' | 'warning' | 'critical'; + 75: trend?: 'up' | 'down' | 'stable'; + 76: } + 77: + 78: function MetricCard({ icon: Icon, title, value, unit, status, trend }: MetricCardProps) { + 79: const statusColors = { + 80: good: 'text-green-500 bg-green-500/10', + 81: warning: 'text-amber-500 bg-amber-500/10', + 82: critical: 'text-red-500 bg-red-500/10', + 83: }; + 84: + 85: return ( + 86: + 87: + 88:
+ 89:
+ 90: + 91:
+ 92: {trend && ( + 93: + 94: {trend === 'up' && '↑'} + 95: {trend === 'down' && '↓'} + 96: {trend === 'stable' && '→'} + 97: + 98: )} + 99:
+100:
+101:

{title}

+102:

+103: {value.toFixed(1)} +104: {unit} +105:

+106:
+107:
+108:
+109: ); +110: } +111: +112: const headerRowClass = flexLayout.rowBetween; +113: const titleRowClass = flexLayout.itemsGap2; +114: +115: export function PerformanceTab() { +116: const systemInfo = useMemo(() => getSystemInfo(), []); +117: +118: // Fetch metrics from backend +119: const { +120: data: metricsResponse, +121: isLoading, +122: refetch, +123: isRefetching, +124: } = useQuery({ +125: queryKey: ['performance-metrics'], +126: queryFn: async () => { +127: const api = getAPI(); +128: return api.getPerformanceMetrics({ history_minutes: 60 }); +129: }, +130: refetchInterval: METRICS_REFRESH_INTERVAL_MS, +131: }); +132: +133: const performanceData = useMemo(() => { +134: if (!metricsResponse?.history) { +135: return []; +136: } +137: return metricsResponse.history.map(convertMetric); +138: }, [metricsResponse]); +139: +140: const handleRefresh = () => { +141: refetch(); +142: }; +143: +144: const latestMetrics = useMemo(() => { +145: return ( +146: performanceData[performanceData.length - 1] || { +147: cpu: 0, +148: memory: 0, +149: networkLatency: 0, +150: fps: 60, +151: } +152: ); +153: }, [performanceData]); +154: +155: const chartData = performanceData.map((m) => ({ +156: ...m, +157: time: format(new Date(m.timestamp), 'HH:mm'), +158: })); +159: +160: const chartConfig = { +161: cpu: { label: 'CPU %', color: 'hsl(var(--chart-1))' }, +162: memory: { label: 'Memory %', color: 'hsl(var(--chart-2))' }, +163: networkLatency: { label: 'Latency (ms)', color: 'hsl(var(--chart-3))' }, +164: fps: { label: 'FPS', color: 'hsl(var(--chart-4))' }, +165: }; +166: const gridProps = { strokeDasharray: '3 3', className: chartStrokes.muted }; +167: const defaultTooltip = ; +168: +169: const getStatus = ( +170: value: number, +171: thresholds: [number, number] +172: ): 'good' | 'warning' | 'critical' => { +173: if (value < thresholds[0]) { +174: return 'good'; +175: } +176: if (value < thresholds[1]) { +177: return 'warning'; +178: } +179: return 'critical'; +180: }; +181: +182: const healthScore = useMemo(() => { +183: const cpuScore = Math.max(0, 100 - latestMetrics.cpu); +184: const memScore = Math.max(0, 100 - latestMetrics.memory); +185: const latencyScore = Math.max(0, 100 - latestMetrics.networkLatency / 2); +186: const fpsScore = (latestMetrics.fps / 60) * 100; +187: return (cpuScore + memScore + latencyScore + fpsScore) / 4; +188: }, [latestMetrics]); +189: +190: return ( +191:
+192: {/* Overall Health */} +193: +194: +195:
+196: +197: +198: System Health +199: +200: Overall application and system performance +201:
+202: +213:
+214: +215:
+216:
+217: +218: Health Score: {healthScore}% +219: +228: = 70 +238: ? 'text-green-500' +239: : healthScore >= 40 +240: ? 'text-amber-500' +241: : 'text-red-500' +242: } +243: /> +244: +245:
+246: {Math.round(healthScore)} +247:
+248:
+249:
+250:

+251: {healthScore >= 70 ? 'Healthy' : healthScore >= 40 ? 'Moderate' : 'Needs Attention'} +252:

+253:

+254: {healthScore >= 70 +255: ? 'All systems are running optimally' +256: : healthScore >= 40 +257: ? 'Some metrics could be improved' +258: : 'Performance issues detected'} +259:

+260:
+261: +262: {navigator.onLine ? 'Online' : 'Offline'} +263: +264: {systemInfo.hardwareConcurrency} cores +265: {systemInfo.deviceMemory && ( +266: {systemInfo.deviceMemory}GB RAM +267: )} +268:
+269:
+270:
+271:
+272:
+273: +274: {/* Metric Cards */} +275:
+276: +284: +292: +300: = 50 ? 'good' : latestMetrics.fps >= 30 ? 'warning' : 'critical' +307: } +308: trend="stable" +309: /> +310:
+311: +312: {/* Performance Charts */} +313:
+314: +315: +316: +317: +318: CPU & Memory Over Time +319: +320: Resource utilization trends +321: +322: +323:
+324: +325: +326: +327: +328: +329: +330: +331: +332: +333: +334: +335: +336: +337: +342: +347: +348: +356: +364: +365: +366:
+367:
+368:
+369: +370: +371: +372: +373: +374: Network & Rendering +375: +376: Latency and frame rate metrics +377: +378: +379:
+380: +381: +382: +383: +388: +389: +390: +398: +406: +407: +408:
+409:
+410:
+411:
+412: +413: {/* System Info */} +414: +415: +416: +417: +418: System Information +419: +420: Client environment details +421: +422: +423:
+424:
+425:

Platform

+426:

{systemInfo.platform}

+427:
+428:
+429:

Language

+430:

{systemInfo.language}

+431:
+432:
+433:

CPU Cores

+434:

{systemInfo.hardwareConcurrency}

+435:
+436: {systemInfo.deviceMemory && ( +437:
+438:

Device Memory

+439:

{systemInfo.deviceMemory} GB

+440:
+441: )} +442:
+443:

Cookies

+444:

+445: {systemInfo.cookiesEnabled ? 'Enabled' : 'Disabled'} +446:

+447:
+448:
+449:

Network Status

+450:

{systemInfo.onLine ? 'Online' : 'Offline'}

+451:
+452:
+453:
+454:
+455:
+456: ); +457: } +```` + +## File: client/src/components/analytics/speech-analysis-tab.tsx +````typescript + 1: import { AlertCircle, Brain, Hash, Lightbulb, MessageSquare, TrendingUp } from 'lucide-react'; + 2: import { useMemo } from 'react'; + 3: import type { Meeting } from '@/api/types'; + 4: import { AnalyticsCardTitle } from '@/components/analytics/analytics-card-title'; + 5: import { Badge } from '@/components/ui/badge'; + 6: import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; + 7: import { Progress } from '@/components/ui/progress'; + 8: import { typography } from '@/lib/styles'; + 9: + 10: interface EntityData { + 11: text: string; + 12: type: 'topic' | 'action' | 'question' | 'keyword'; + 13: count: number; + 14: weight: number; + 15: } + 16: + 17: interface SpeechPattern { + 18: name: string; + 19: description: string; + 20: score: number; + 21: feedback: string; + 22: type: 'positive' | 'neutral' | 'improvement'; + 23: } + 24: + 25: const WORDS_PER_MINUTE_BASE = 60; + 26: const OPTIMAL_WPM_MIN = WORDS_PER_MINUTE_BASE * 2; + 27: const OPTIMAL_WPM_MAX = WORDS_PER_MINUTE_BASE * 3; + 28: const OPTIMAL_WPM_TARGET = (WORDS_PER_MINUTE_BASE * 5) / 2; + 29: + 30: function extractEntities(meetings: Meeting[]): EntityData[] { + 31: const entityMap = new Map(); + 32: + 33: // Common filler words to exclude + 34: const stopWords = new Set([ + 35: 'the', + 36: 'a', + 37: 'an', + 38: 'is', + 39: 'are', + 40: 'was', + 41: 'were', + 42: 'be', + 43: 'been', + 44: 'being', + 45: 'have', + 46: 'has', + 47: 'had', + 48: 'do', + 49: 'does', + 50: 'did', + 51: 'will', + 52: 'would', + 53: 'could', + 54: 'should', + 55: 'may', + 56: 'might', + 57: 'must', + 58: 'shall', + 59: 'can', + 60: 'need', + 61: 'dare', + 62: 'ought', + 63: 'used', + 64: 'to', + 65: 'of', + 66: 'in', + 67: 'for', + 68: 'on', + 69: 'with', + 70: 'at', + 71: 'by', + 72: 'from', + 73: 'as', + 74: 'into', + 75: 'through', + 76: 'during', + 77: 'before', + 78: 'after', + 79: 'above', + 80: 'below', + 81: 'between', + 82: 'under', + 83: 'again', + 84: 'further', + 85: 'then', + 86: 'once', + 87: 'here', + 88: 'there', + 89: 'when', + 90: 'where', + 91: 'why', + 92: 'how', + 93: 'all', + 94: 'each', + 95: 'few', + 96: 'more', + 97: 'most', + 98: 'other', + 99: 'some', +100: 'such', +101: 'no', +102: 'nor', +103: 'not', +104: 'only', +105: 'own', +106: 'same', +107: 'so', +108: 'than', +109: 'too', +110: 'very', +111: 'just', +112: 'and', +113: 'but', +114: 'if', +115: 'or', +116: 'because', +117: 'until', +118: 'while', +119: 'although', +120: 'though', +121: 'after', +122: 'that', +123: 'this', +124: 'these', +125: 'those', +126: 'i', +127: 'you', +128: 'he', +129: 'she', +130: 'it', +131: 'we', +132: 'they', +133: 'what', +134: 'which', +135: 'who', +136: 'whom', +137: 'me', +138: 'him', +139: 'her', +140: 'us', +141: 'them', +142: 'my', +143: 'your', +144: 'his', +145: 'its', +146: 'our', +147: 'their', +148: 'mine', +149: 'yours', +150: 'hers', +151: 'ours', +152: 'theirs', +153: 'um', +154: 'uh', +155: 'like', +156: 'yeah', +157: 'okay', +158: 'ok', +159: 'right', +160: 'well', +161: 'so', +162: 'actually', +163: 'basically', +164: 'literally', +165: 'really', +166: 'very', +167: 'just', +168: ]); +169: +170: for (const meeting of meetings) { +171: for (const segment of meeting.segments) { +172: for (const wordTiming of segment.words) { +173: const text = wordTiming.word.toLowerCase().replace(/[^a-z0-9]/g, ''); +174: if (text.length < 3 || stopWords.has(text)) { +175: continue; +176: } +177: +178: const existing = entityMap.get(text); +179: if (existing) { +180: existing.count++; +181: } else { +182: // Determine type based on heuristics +183: let type: EntityData['type'] = 'keyword'; +184: if (text.endsWith('ing') || text.endsWith('tion')) { +185: type = 'action'; +186: } else if (text.length > 8) { +187: type = 'topic'; +188: } +189: +190: entityMap.set(text, { count: 1, type }); +191: } +192: } +193: } +194: } +195: +196: // Convert to array and calculate weights +197: const maxCount = Math.max(...Array.from(entityMap.values()).map((e) => e.count), 1); +198: +199: return Array.from(entityMap.entries()) +200: .map(([text, { count, type }]) => ({ +201: text, +202: type, +203: count, +204: weight: count / maxCount, +205: })) +206: .sort((a, b) => b.count - a.count) +207: .slice(0, 50); +208: } +209: +210: function analyzeSpeechPatterns(meetings: Meeting[]): SpeechPattern[] { +211: if (meetings.length === 0) { +212: return []; +213: } +214: +215: // Calculate various metrics +216: let totalWords = 0; +217: let totalDuration = 0; +218: let questionCount = 0; +219: let fillerWords = 0; +220: const fillerWordSet = new Set([ +221: 'um', +222: 'uh', +223: 'like', +224: 'you know', +225: 'basically', +226: 'actually', +227: 'literally', +228: 'right', +229: ]); +230: +231: const speakerWordCounts = new Map(); +232: const sentenceLengths: number[] = []; +233: +234: for (const meeting of meetings) { +235: totalDuration += meeting.duration_seconds; +236: +237: for (const segment of meeting.segments) { +238: const wordCount = segment.words.length; +239: totalWords += wordCount; +240: sentenceLengths.push(wordCount); +241: +242: speakerWordCounts.set( +243: segment.speaker_id, +244: (speakerWordCounts.get(segment.speaker_id) || 0) + wordCount +245: ); +246: +247: for (const wordTiming of segment.words) { +248: const text = wordTiming.word.toLowerCase(); +249: if (text.includes('?')) { +250: questionCount++; +251: } +252: if (fillerWordSet.has(text.replace(/[^a-z\s]/g, ''))) { +253: fillerWords++; +254: } +255: } +256: } +257: } +258: +259: const avgWordsPerMinute = totalDuration > 0 ? totalWords / (totalDuration / 60) : 0; +260: const avgSentenceLength = +261: sentenceLengths.length > 0 +262: ? sentenceLengths.reduce((a, b) => a + b, 0) / sentenceLengths.length +263: : 0; +264: const fillerRatio = totalWords > 0 ? (fillerWords / totalWords) * 100 : 0; +265: const questionRatio = totalWords > 0 ? (questionCount / totalWords) * 1000 : 0; // per 1000 words +266: +267: const patterns: SpeechPattern[] = [ +268: { +269: name: 'Speaking Pace', +270: description: `${Math.round(avgWordsPerMinute)} words per minute`, +271: score: Math.min(100, Math.max(0, 100 - Math.abs(avgWordsPerMinute - OPTIMAL_WPM_TARGET) / 2)), +272: feedback: +273: avgWordsPerMinute < OPTIMAL_WPM_MIN +274: ? 'Consider speaking slightly faster for better engagement' +275: : avgWordsPerMinute > OPTIMAL_WPM_MAX +276: ? 'Try slowing down to improve clarity' +277: : 'Your pace is in the optimal range', +278: type: +279: avgWordsPerMinute >= OPTIMAL_WPM_MIN && avgWordsPerMinute <= OPTIMAL_WPM_MAX +280: ? 'positive' +281: : 'improvement', +282: }, +283: { +284: name: 'Clarity Score', +285: description: `Avg ${avgSentenceLength.toFixed(1)} words per segment`, +286: score: Math.min(100, Math.max(0, 100 - Math.abs(avgSentenceLength - 15) * 3)), +287: feedback: +288: avgSentenceLength > 25 +289: ? 'Breaking up longer segments can improve clarity' +290: : avgSentenceLength < 8 +291: ? 'Consider expanding on points for better context' +292: : 'Your segment lengths support good comprehension', +293: type: avgSentenceLength >= 8 && avgSentenceLength <= 25 ? 'positive' : 'neutral', +294: }, +295: { +296: name: 'Filler Word Usage', +297: description: `${fillerRatio.toFixed(2)}% of words are fillers`, +298: score: Math.max(0, 100 - fillerRatio * 20), +299: feedback: +300: fillerRatio > 3 +301: ? 'Practice pausing instead of using filler words' +302: : fillerRatio > 1 +303: ? 'Moderate filler usage - room for improvement' +304: : 'Excellent - minimal filler word usage', +305: type: fillerRatio <= 1 ? 'positive' : fillerRatio <= 3 ? 'neutral' : 'improvement', +306: }, +307: { +308: name: 'Engagement (Questions)', +309: description: `${questionRatio.toFixed(1)} questions per 1000 words`, +310: score: Math.min(100, questionRatio * 10), +311: feedback: +312: questionRatio < 2 +313: ? 'Try asking more questions to boost engagement' +314: : questionRatio > 10 +315: ? 'Good question frequency for interactive discussions' +316: : 'Balanced use of questions', +317: type: questionRatio >= 2 ? 'positive' : 'neutral', +318: }, +319: ]; +320: +321: return patterns; +322: } +323: +324: interface SpeechAnalysisTabProps { +325: meetings: Meeting[]; +326: } +327: +328: export function SpeechAnalysisTab({ meetings }: SpeechAnalysisTabProps) { +329: const entities = useMemo(() => extractEntities(meetings), [meetings]); +330: const patterns = useMemo(() => analyzeSpeechPatterns(meetings), [meetings]); +331: +332: const topEntities = entities.slice(0, 30); +333: const entityTypeColors: Record = { +334: topic: 'bg-chart-1/20 text-chart-1 border-chart-1/30', +335: action: 'bg-chart-2/20 text-chart-2 border-chart-2/30', +336: question: 'bg-chart-3/20 text-chart-3 border-chart-3/30', +337: keyword: 'bg-chart-4/20 text-chart-4 border-chart-4/30', +338: }; +339: +340: return ( +341:
+342: {/* Word Cloud / Entity Map */} +343: +344: +345: +346: +347: Entity Word Map +348: +349: +350: Most frequently mentioned words and phrases across all meetings +351: +352: +353: +354: {topEntities.length > 0 ? ( +355:
+356: {topEntities.map((entity) => { +357: const fontSize = 0.75 + entity.weight * 0.75; // 0.75rem to 1.5rem +358: return ( +359: +368: {entity.text} +369: ×{entity.count} +370: +371: ); +372: })} +373:
+374: ) : ( +375:
+376: +377:

No entity data available yet

+378:

Record some meetings to see extracted entities

+379:
+380: )} +381: +382: {/* Legend */} +383: {topEntities.length > 0 && ( +384:
+385:
+386:
+387: Topics +388:
+389:
+390:
+391: Actions +392:
+393:
+394:
+395: Keywords +396:
+397:
+398: )} +399: +400: +401: +402: {/* Speech Pattern Analysis */} +403: +404: +405: +406: +407: Speech Pattern Analysis +408: +409: Insights and feedback on your speaking patterns +410: +411: +412: {patterns.length > 0 ? ( +413:
+414: {patterns.map((pattern) => ( +415:
+416:
+417:
+418: {pattern.type === 'positive' && ( +419: +420: )} +421: {pattern.type === 'improvement' && ( +422: +423: )} +424: {pattern.type === 'neutral' && ( +425: +426: )} +427: {pattern.name} +428:
+429: {pattern.description} +430:
+431: +432:

+433: +434: {pattern.feedback} +435:

+436:
+437: ))} +438:
+439: ) : ( +440:
+441: +442:

No speech data to analyze

+443:

Record meetings to get personalized feedback

+444:
+445: )} +446:
+447:
+448: +449: {/* Top Keywords Table */} +450: {entities.length > 0 && ( +451: +452: +453: Top Keywords +454: Most used words ranked by frequency +455: +456: +457:
+458: {entities.slice(0, 20).map((entity, index) => ( +459:
+463: {index + 1}. +464: {entity.text} +465: {entity.count} +466:
+467: ))} +468:
+469:
+470:
+471: )} +472:
+473: ); +474: } +```` + +## File: client/src/components/integration-config-panel/auth-config.tsx +````typescript + 1: /** + 2: * OAuth/SSO authentication configuration. + 3: */ + 4: + 5: import { Globe, Key, Lock } from 'lucide-react'; + 6: + 7: import type { Integration } from '@/api/types'; + 8: import { Input } from '@/components/ui/input'; + 9: import { Label } from '@/components/ui/label'; +10: +11: import { configPanelContentStyles, Field, SecretInput } from './shared'; +12: +13: interface AuthConfigProps { +14: integration: Integration; +15: onUpdate: (config: Partial) => void; +16: showSecrets: Record; +17: toggleSecret: (key: string) => void; +18: } +19: +20: export function AuthConfig({ integration, onUpdate, showSecrets, toggleSecret }: AuthConfigProps) { +21: const config = integration.oauth_config || { +22: client_id: '', +23: client_secret: '', +24: redirect_uri: '', +25: scopes: [], +26: }; +27: +28: return ( +29:
+30:
+31: }> +32: onUpdate({ oauth_config: { ...config, client_id: e.target.value } })} +35: placeholder="Enter client ID" +36: /> +37: +38: onUpdate({ oauth_config: { ...config, client_secret: value } })} +42: placeholder="Enter client secret" +43: showSecret={showSecrets.client_secret ?? false} +44: onToggleSecret={() => toggleSecret('client_secret')} +45: icon={} +46: /> +47:
+48: }> +49: onUpdate({ oauth_config: { ...config, redirect_uri: e.target.value } })} +52: placeholder="https://your-app.com/auth/callback" +53: /> +54:

+55: Configure this URL in your OAuth provider's settings +56:

+57:
+58:
+59: +60: +63: onUpdate({ +64: oauth_config: { +65: ...config, +66: scopes: e.target.value +67: .split(',') +68: .map((s) => s.trim()) +69: .filter(Boolean), +70: }, +71: }) +72: } +73: placeholder="openid, email, profile" +74: /> +75:

Comma-separated list of OAuth scopes

+76:
+77:
+78: ); +79: } +```` + +## File: client/src/components/integration-config-panel/calendar-config.tsx ````typescript 1: /** - 2: * Typed client log event emitters for application observability. - 3: * - 4: * Each method emits a structured log entry that matches the templates - 5: * in log-messages.ts for friendly display in the Analytics logs tab. - 6: */ - 7: - 8: import type { LogLevel, LogSource } from '@/api/types'; - 9: import { addClientLog } from './client-logs'; - 10: - 11: function emit( - 12: level: LogLevel, - 13: source: LogSource, - 14: message: string, - 15: metadata?: Record - 16: ): void { - 17: addClientLog({ level, source, message, metadata }); - 18: } - 19: - 20: export const clientLog = { - 21: // Meeting Lifecycle - 22: meetingCreated(meetingId: string, title?: string): void { - 23: emit('info', 'app', 'Created meeting', { meeting_id: meetingId, title: title ?? '' }); - 24: }, - 25: meetingStarted(meetingId: string, title?: string): void { - 26: emit('info', 'app', 'Meeting started', { meeting_id: meetingId, title: title ?? '' }); - 27: }, - 28: recordingStartFailed( - 29: meetingId: string, - 30: error: string, - 31: grpcStatus?: number, - 32: category?: string, - 33: retryable?: boolean - 34: ): void { - 35: emit('error', 'app', 'Recording start failed', { - 36: meeting_id: meetingId, - 37: error, - 38: grpc_status: grpcStatus !== undefined ? grpcStatus.toString() : '', - 39: category: category ?? '', - 40: retryable: retryable !== undefined ? String(retryable) : '', - 41: }); - 42: }, - 43: meetingStopped(meetingId: string, title?: string): void { - 44: emit('info', 'app', 'Meeting stopped', { meeting_id: meetingId, title: title ?? '' }); - 45: }, - 46: meetingDeleted(meetingId: string): void { - 47: emit('info', 'app', 'Meeting deleted', { meeting_id: meetingId }); - 48: }, - 49: - 50: // Summarization - 51: summarizing(meetingId: string, segmentCount?: number): void { - 52: emit('info', 'app', 'Summarizing', { - 53: meeting_id: meetingId, - 54: segment_count: segmentCount?.toString() ?? '', - 55: }); - 56: }, - 57: summaryGenerated(meetingId: string, model?: string): void { - 58: emit('info', 'app', 'Summary generated', { meeting_id: meetingId, model: model ?? '' }); - 59: }, - 60: summaryFailed(meetingId: string, error?: string): void { - 61: emit('error', 'app', 'Summary generation failed', { meeting_id: meetingId, error: error ?? '' }); - 62: }, - 63: - 64: // Cloud Consent - 65: cloudConsentGranted(): void { - 66: emit('info', 'app', 'Cloud consent granted'); - 67: }, - 68: cloudConsentRevoked(): void { - 69: emit('info', 'app', 'Cloud consent revoked'); - 70: }, - 71: - 72: // Export - 73: exportStarted(meetingId: string, format: string): void { - 74: emit('info', 'app', 'Starting transcript export', { meeting_id: meetingId, format }); - 75: }, - 76: exportCompleted(meetingId: string, format: string): void { - 77: emit('info', 'app', 'Transcript export completed', { meeting_id: meetingId, format }); - 78: }, - 79: exportFailed(meetingId: string, format: string, error?: string): void { - 80: emit('error', 'app', 'Export failed', { meeting_id: meetingId, format, error: error ?? '' }); - 81: }, - 82: - 83: // Diarization - 84: diarizationStarted(meetingId: string, jobId: string): void { - 85: emit('info', 'app', 'Diarization job', { meeting_id: meetingId, job_id: jobId, status: 'running' }); - 86: }, - 87: diarizationCompleted(meetingId: string, jobId: string): void { - 88: emit('info', 'app', 'Diarization job', { meeting_id: meetingId, job_id: jobId, status: 'completed' }); - 89: }, - 90: diarizationFailed(meetingId: string, jobId: string, error?: string): void { - 91: emit('error', 'app', 'Diarization job', { - 92: meeting_id: meetingId, - 93: job_id: jobId, - 94: status: 'failed', - 95: error: error ?? '', - 96: }); - 97: }, - 98: speakerRenamed(meetingId: string, oldName: string, newName: string): void { - 99: emit('info', 'app', 'Speaker renamed', { meeting_id: meetingId, old_name: oldName, new_name: newName }); -100: }, -101: -102: // Entity Extraction -103: entitiesExtracted(meetingId: string, count: number): void { -104: emit('info', 'app', 'Extracted', { meeting_id: meetingId, count: count.toString() }); -105: }, -106: -107: // Webhooks -108: webhookRegistered(webhookId: string, name?: string): void { -109: emit('info', 'app', 'Webhook_registered', { webhook_id: webhookId, name: name ?? '' }); -110: }, -111: webhookDeleted(webhookId: string): void { -112: emit('info', 'app', 'Webhook deleted', { webhook_id: webhookId }); -113: }, -114: -115: // Calendar -116: calendarConnected(provider: string): void { -117: emit('info', 'sync', 'Calendar connected', { provider }); -118: }, -119: calendarDisconnected(provider: string): void { -120: emit('info', 'sync', 'Calendar disconnected', { provider }); -121: }, -122: calendarSynced(provider: string, count?: number): void { -123: emit('info', 'sync', 'Calendar sync', { provider, count: count?.toString() ?? '' }); -124: }, -125: -126: // Connection -127: connected(serverAddress?: string): void { -128: emit('info', 'system', 'Connected', { server: serverAddress ?? '' }); -129: }, -130: disconnected(): void { -131: emit('info', 'system', 'Disconnected'); -132: }, -133: connectionFailed(error?: string): void { -134: emit('error', 'system', 'Connection failed', { error: error ?? '' }); -135: }, -136: -137: // Auth -138: loginCompleted(provider: string): void { -139: emit('info', 'auth', 'Login completed', { provider }); -140: }, -141: loggedOut(provider?: string): void { -142: emit('info', 'auth', 'Logged out', { provider: provider ?? '' }); -143: }, -144: -145: // Triggers -146: triggerDetected(triggerType: string): void { -147: emit('info', 'app', 'Trigger detected', { trigger_type: triggerType }); -148: }, -149: triggersSnoozed(minutes?: number): void { -150: emit('info', 'app', 'Triggers snoozed', { minutes: minutes?.toString() ?? '' }); -151: }, -152: triggerSnoozeCleared(): void { -153: emit('info', 'app', 'Trigger snooze cleared'); -154: }, -155: -156: // Audio Devices -157: deviceSelectionFailed(deviceType: 'input' | 'output', deviceId: string, error?: string): void { -158: emit('error', 'app', 'Device selection failed', { -159: device_type: deviceType, -160: device_id: deviceId, -161: error: error ?? '', -162: }); -163: }, -164: }; + 2: * Calendar integration configuration. + 3: */ + 4: + 5: import { Globe, Key, Lock } from 'lucide-react'; + 6: + 7: import type { Integration } from '@/api/types'; + 8: import { Badge } from '@/components/ui/badge'; + 9: import { Input } from '@/components/ui/input'; + 10: import { Label } from '@/components/ui/label'; + 11: import { + 12: Select, + 13: SelectContent, + 14: SelectItem, + 15: SelectTrigger, + 16: SelectValue, + 17: } from '@/components/ui/select'; + 18: import { Separator } from '@/components/ui/separator'; + 19: import { configPanelContentStyles, Field, SecretInput } from './shared'; + 20: + 21: interface CalendarConfigProps { + 22: integration: Integration; + 23: onUpdate: (config: Partial) => void; + 24: showSecrets: Record; + 25: toggleSecret: (key: string) => void; + 26: } + 27: + 28: export function CalendarConfig({ + 29: integration, + 30: onUpdate, + 31: showSecrets, + 32: toggleSecret, + 33: }: CalendarConfigProps) { + 34: const calConfig = integration.calendar_config || { + 35: sync_interval_minutes: 15, + 36: calendar_ids: [], + 37: }; + 38: const oauthConfig = integration.oauth_config || { + 39: client_id: '', + 40: client_secret: '', + 41: redirect_uri: '', + 42: scopes: [], + 43: }; + 44: + 45: return ( + 46:
+ 47:
+ 48: OAuth 2.0 + 49: Requires OAuth authentication + 50:
+ 51:
+ 52: }> + 53: + 56: onUpdate({ + 57: oauth_config: { ...oauthConfig, client_id: e.target.value }, + 58: }) + 59: } + 60: placeholder="Enter client ID" + 61: /> + 62: + 63: + 67: onUpdate({ oauth_config: { ...oauthConfig, client_secret: value } }) + 68: } + 69: placeholder="Enter client secret" + 70: showSecret={showSecrets.calendar_client_secret ?? false} + 71: onToggleSecret={() => toggleSecret('calendar_client_secret')} + 72: icon={} + 73: /> + 74:
+ 75: }> + 76: + 79: onUpdate({ + 80: oauth_config: { ...oauthConfig, redirect_uri: e.target.value }, + 81: }) + 82: } + 83: placeholder="https://your-app.com/calendar/callback" + 84: /> + 85: + 86: + 87: + 88:
+ 89: + 90: +108:
+109:
+110: +111: +114: onUpdate({ +115: calendar_config: { +116: ...calConfig, +117: calendar_ids: e.target.value +118: .split(',') +119: .map((s) => s.trim()) +120: .filter(Boolean), +121: }, +122: }) +123: } +124: placeholder="primary, work@example.com" +125: /> +126:

+127: Leave empty to sync all calendars, or specify calendar IDs +128:

+129:
+130:
+131: +132: +135: onUpdate({ +136: calendar_config: { ...calConfig, webhook_url: e.target.value }, +137: }) +138: } +139: placeholder="https://your-app.com/webhooks/calendar" +140: /> +141:

Receive real-time calendar updates

+142:
+143:
+144: ); +145: } ```` -## File: client/src/lib/client-logs.test.ts +## File: client/src/components/integration-config-panel/email-config.tsx ````typescript - 1: import { beforeEach, describe, expect, it, vi } from 'vitest'; - 2: import { addClientLog, clearClientLogs, getClientLogs, subscribeClientLogs } from './client-logs'; - 3: - 4: const STORAGE_KEY = 'noteflow_client_logs'; - 5: - 6: describe('client logs', () => { - 7: beforeEach(() => { - 8: localStorage.clear(); - 9: clearClientLogs(); -10: }); -11: -12: it('stores log entries and persists to storage', () => { -13: addClientLog({ -14: level: 'info', -15: source: 'app', -16: message: 'Client log entry', -17: metadata: { detail: 'value' }, -18: }); + 1: /** + 2: * Email provider configuration. + 3: */ + 4: + 5: import { Key, Lock, Mail, Server } from 'lucide-react'; + 6: + 7: import type { Integration } from '@/api/types'; + 8: import { Input } from '@/components/ui/input'; + 9: import { Label } from '@/components/ui/label'; + 10: import { + 11: Select, + 12: SelectContent, + 13: SelectItem, + 14: SelectTrigger, + 15: SelectValue, + 16: } from '@/components/ui/select'; + 17: import { Separator } from '@/components/ui/separator'; + 18: import { Switch } from '@/components/ui/switch'; + 19: import { IntegrationDefaults } from '@/lib/config'; + 20: import { configPanelContentStyles, Field, SecretInput, TestButton } from './shared'; + 21: + 22: interface EmailConfigProps { + 23: integration: Integration; + 24: onUpdate: (config: Partial) => void; + 25: onTest?: () => void; + 26: isTesting: boolean; + 27: showSecrets: Record; + 28: toggleSecret: (key: string) => void; + 29: } + 30: + 31: export function EmailConfig({ + 32: integration, + 33: onUpdate, + 34: onTest, + 35: isTesting, + 36: showSecrets, + 37: toggleSecret, + 38: }: EmailConfigProps) { + 39: const config = integration.email_config || { + 40: provider_type: 'api' as const, + 41: api_key: '', + 42: from_email: '', + 43: from_name: '', + 44: }; + 45: + 46: return ( + 47:
+ 48:
+ 49: + 50: + 66:
+ 67: + 68: {config.provider_type === 'api' ? ( + 69: onUpdate({ email_config: { ...config, api_key: value } })} + 73: placeholder="Enter your API key" + 74: showSecret={showSecrets.email_api_key ?? false} + 75: onToggleSecret={() => toggleSecret('email_api_key')} + 76: icon={} + 77: /> + 78: ) : ( + 79: <> + 80:
+ 81: }> + 82: + 85: onUpdate({ + 86: email_config: { ...config, smtp_host: e.target.value }, + 87: }) + 88: } + 89: placeholder="smtp.example.com" + 90: /> + 91: + 92:
+ 93: + 94: + 98: onUpdate({ + 99: email_config: { +100: ...config, +101: smtp_port: parseInt(e.target.value, 10) || IntegrationDefaults.SMTP_PORT, +102: }, +103: }) +104: } +105: placeholder={String(IntegrationDefaults.SMTP_PORT)} +106: /> +107:
+108:
+109:
+110:
+111: +112: +115: onUpdate({ +116: email_config: { ...config, smtp_username: e.target.value }, +117: }) +118: } +119: placeholder="username@example.com" +120: /> +121:
+122: onUpdate({ email_config: { ...config, smtp_password: value } })} +126: placeholder="SMTP password" +127: showSecret={showSecrets.smtp_password ?? false} +128: onToggleSecret={() => toggleSecret('smtp_password')} +129: icon={} +130: /> +131:
+132:
+133: +136: onUpdate({ +137: email_config: { ...config, smtp_secure: checked }, +138: }) +139: } +140: /> +141: +142:
+143: +144: )} +145: +146: +147: +148:
+149: }> +150: +154: onUpdate({ +155: email_config: { ...config, from_email: e.target.value }, +156: }) +157: } +158: placeholder="noreply@example.com" +159: /> +160: +161:
+162: +163: +166: onUpdate({ +167: email_config: { ...config, from_name: e.target.value }, +168: }) +169: } +170: placeholder="NoteFlow" +171: /> +172:
+173:
+174: +175: +176:
+177: ); +178: } +```` + +## File: client/src/components/integration-config-panel/index.tsx +````typescript + 1: /** + 2: * Integration Configuration Panel Component. + 3: * + 4: * Renders configuration forms based on integration type. + 5: * Split into separate components for maintainability. + 6: */ + 7: + 8: import { useState } from 'react'; + 9: + 10: import type { Integration } from '@/api/types'; + 11: + 12: import { AuthConfig } from './auth-config'; + 13: import { CalendarConfig } from './calendar-config'; + 14: import { EmailConfig } from './email-config'; + 15: import { OIDCConfig } from './oidc-config'; + 16: import { PKMConfig } from './pkm-config'; + 17: import { WebhookConfig } from './webhook-config'; + 18: + 19: export interface IntegrationConfigPanelProps { + 20: integration: Integration; + 21: onUpdate: (config: Partial) => void; + 22: onTest?: () => void; + 23: isTesting?: boolean; + 24: } + 25: + 26: export function IntegrationConfigPanel({ + 27: integration, + 28: onUpdate, + 29: onTest, + 30: isTesting = false, + 31: }: IntegrationConfigPanelProps) { + 32: const [showSecrets, setShowSecrets] = useState>({}); + 33: const toggleSecret = (key: string) => setShowSecrets((prev) => ({ ...prev, [key]: !prev[key] })); + 34: + 35: // OAuth/SSO Configuration + 36: if (integration.type === 'auth') { + 37: return ( + 38: + 44: ); + 45: } + 46: + 47: // Email Configuration + 48: if (integration.type === 'email') { + 49: return ( + 50: + 58: ); + 59: } + 60: + 61: // Calendar Configuration + 62: if (integration.type === 'calendar') { + 63: return ( + 64: + 70: ); + 71: } + 72: + 73: // PKM Configuration + 74: if (integration.type === 'pkm') { + 75: return ( + 76: + 82: ); + 83: } + 84: + 85: // Custom/Webhook Configuration + 86: if (integration.type === 'custom') { + 87: return ( + 88: + 96: ); + 97: } + 98: + 99: // OIDC Provider Configuration +100: if (integration.type === 'oidc') { +101: return ( +102: +110: ); +111: } +112: +113: return ( +114:
+115: No configuration options available for this integration. +116:
+117: ); +118: } +```` + +## File: client/src/components/integration-config-panel/oidc-config.tsx +````typescript + 1: /** + 2: * OIDC provider configuration. + 3: */ + 4: + 5: import { Globe, Key, Lock } from 'lucide-react'; + 6: + 7: import type { Integration } from '@/api/types'; + 8: import { Badge } from '@/components/ui/badge'; + 9: import { Input } from '@/components/ui/input'; + 10: import { Label } from '@/components/ui/label'; + 11: import { + 12: Select, + 13: SelectContent, + 14: SelectItem, + 15: SelectTrigger, + 16: SelectValue, + 17: } from '@/components/ui/select'; + 18: import { Switch } from '@/components/ui/switch'; + 19: import { formatTimestamp } from '@/lib/format'; + 20: + 21: import { Field, SecretInput, TestButton } from './shared'; + 22: + 23: interface OIDCConfigProps { + 24: integration: Integration; + 25: onUpdate: (config: Partial) => void; + 26: onTest?: () => void; + 27: isTesting: boolean; + 28: showSecrets: Record; + 29: toggleSecret: (key: string) => void; + 30: } + 31: + 32: export function OIDCConfig({ + 33: integration, + 34: onUpdate, + 35: onTest, + 36: isTesting, + 37: showSecrets, + 38: toggleSecret, + 39: }: OIDCConfigProps) { + 40: const config = integration.oidc_config || { + 41: preset: 'custom' as const, + 42: issuer_url: '', + 43: client_id: '', + 44: client_secret: '', + 45: scopes: ['openid', 'profile', 'email'], + 46: claim_mapping: { + 47: subject_claim: 'sub', + 48: email_claim: 'email', + 49: email_verified_claim: 'email_verified', + 50: name_claim: 'name', + 51: preferred_username_claim: 'preferred_username', + 52: groups_claim: 'groups', + 53: picture_claim: 'picture', + 54: }, + 55: require_email_verified: true, + 56: allowed_groups: [], + 57: }; + 58: + 59: return ( + 60:
+ 61:
+ 62: OIDC + 63: OpenID Connect Provider + 64:
+ 65: + 66:
+ 67: + 68: + 89:
+ 90: + 91: }> + 92: onUpdate({ oidc_config: { ...config, issuer_url: e.target.value } })} + 95: placeholder="https://auth.example.com" + 96: /> + 97:

+ 98: Base URL for OIDC discovery (/.well-known/openid-configuration) + 99:

+100:
+101: +102:
+103: }> +104: onUpdate({ oidc_config: { ...config, client_id: e.target.value } })} +107: placeholder="noteflow-client" +108: /> +109: +110: onUpdate({ oidc_config: { ...config, client_secret: value } })} +114: placeholder="Enter client secret" +115: showSecret={showSecrets.oidc_client_secret ?? false} +116: onToggleSecret={() => toggleSecret('oidc_client_secret')} +117: icon={} +118: /> +119:
+120: +121:
+122: +123: +126: onUpdate({ +127: oidc_config: { +128: ...config, +129: scopes: e.target.value +130: .split(',') +131: .map((s) => s.trim()) +132: .filter(Boolean), +133: }, +134: }) +135: } +136: placeholder="openid, profile, email, groups" +137: /> +138:

Comma-separated list of OAuth scopes

+139:
+140: +141:
+142: +145: onUpdate({ +146: oidc_config: { ...config, require_email_verified: checked }, +147: }) +148: } +149: /> +150: +151:
+152: +153: {config.discovery && ( +154:
+155:

Discovery Endpoints

+156:
+157:

+158: Authorization:{' '} +159: {config.discovery.authorization_endpoint} +160:

+161:

+162: Token:{' '} +163: {config.discovery.token_endpoint} +164:

+165: {config.discovery.userinfo_endpoint && ( +166:

+167: UserInfo:{' '} +168: {config.discovery.userinfo_endpoint} +169:

+170: )} +171:
+172: {config.discovery_refreshed_at && ( +173:

+174: Last refreshed: {formatTimestamp(config.discovery_refreshed_at)} +175:

+176: )} +177:
+178: )} +179: +180: +181:
+182: ); +183: } +```` + +## File: client/src/components/integration-config-panel/pkm-config.tsx +````typescript + 1: /** + 2: * Personal Knowledge Management (PKM) configuration. + 3: */ + 4: + 5: import { Database, FolderOpen, Key } from 'lucide-react'; + 6: + 7: import type { Integration } from '@/api/types'; + 8: import { Button } from '@/components/ui/button'; + 9: import { Input } from '@/components/ui/input'; + 10: import { Label } from '@/components/ui/label'; + 11: import { Switch } from '@/components/ui/switch'; + 12: import { EXTERNAL_LINK_REL } from '@/lib/styles'; + 13: import { Field, SecretInput } from './shared'; + 14: + 15: interface PKMConfigProps { + 16: integration: Integration; + 17: onUpdate: (config: Partial) => void; + 18: showSecrets: Record; + 19: toggleSecret: (key: string) => void; + 20: } + 21: + 22: export function PKMConfig({ integration, onUpdate, showSecrets, toggleSecret }: PKMConfigProps) { + 23: const config = integration.pkm_config || { api_key: '', workspace_id: '', sync_enabled: false }; + 24: const isNotion = integration.name.toLowerCase().includes('notion'); + 25: const isObsidian = integration.name.toLowerCase().includes('obsidian'); + 26: + 27: return ( + 28:
+ 29: {isNotion && ( + 30: <> + 31: onUpdate({ pkm_config: { ...config, api_key: value } })} + 35: placeholder="secret_xxxxxxxxxxxxxxxx" + 36: showSecret={showSecrets.notion_token ?? false} + 37: onToggleSecret={() => toggleSecret('notion_token')} + 38: icon={} + 39: /> + 40:

+ 41: Create an integration at{' '} + 42: + 48: notion.so/my-integrations + 49: + 50:

+ 51: }> + 52: + 55: onUpdate({ + 56: pkm_config: { ...config, database_id: e.target.value }, + 57: }) + 58: } + 59: placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + 60: /> + 61:

The ID from your Notion database URL

+ 62:
+ 63: + 64: )} + 65: + 66: {isObsidian && ( + 67: }> + 68:
+ 69: + 72: onUpdate({ + 73: pkm_config: { ...config, vault_path: e.target.value }, + 74: }) + 75: } + 76: placeholder="/path/to/obsidian/vault" + 77: className="flex-1" + 78: /> + 79: + 82:
+ 83:

Path to your Obsidian vault folder

+ 84:
+ 85: )} + 86: + 87: {!isNotion && !isObsidian && ( + 88: <> + 89: onUpdate({ pkm_config: { ...config, api_key: value } })} + 93: placeholder="Enter API key" + 94: showSecret={showSecrets.pkm_api_key ?? false} + 95: onToggleSecret={() => toggleSecret('pkm_api_key')} + 96: icon={} + 97: /> + 98:
+ 99: +100: +103: onUpdate({ +104: pkm_config: { ...config, workspace_id: e.target.value }, +105: }) +106: } +107: placeholder="Enter workspace ID" +108: /> +109:
+110: +111: )} +112: +113:
+114: +117: onUpdate({ +118: pkm_config: { ...config, sync_enabled: checked }, +119: }) +120: } +121: /> +122: +123:
+124:
+125: ); +126: } +```` + +## File: client/src/components/integration-config-panel/shared.tsx +````typescript + 1: /** + 2: * Shared components for integration configuration panels. + 3: */ + 4: + 5: import { Eye, EyeOff, Loader2, RefreshCw } from 'lucide-react'; + 6: import type { ReactNode } from 'react'; + 7: + 8: import { Button } from '@/components/ui/button'; + 9: import { Input } from '@/components/ui/input'; + 10: import { Label } from '@/components/ui/label'; + 11: import { iconWithMargin, labelStyles } from '@/lib/styles'; + 12: + 13: /** Common container styles for config panel content sections. */ + 14: export const configPanelContentStyles = 'space-y-4 pt-2'; + 15: + 16: /** + 17: * Reusable form field wrapper with label and icon. + 18: */ + 19: export function Field({ + 20: label, + 21: icon, + 22: children, + 23: }: { + 24: label: string; + 25: icon?: ReactNode; + 26: children: ReactNode; + 27: }) { + 28: return ( + 29:
+ 30: + 34: {children} + 35:
+ 36: ); + 37: } + 38: + 39: /** + 40: * Secret input field with show/hide toggle. + 41: */ + 42: export function SecretInput({ + 43: label, + 44: value, + 45: onChange, + 46: placeholder, + 47: showSecret, + 48: onToggleSecret, + 49: icon, + 50: }: { + 51: label: string; + 52: value: string; + 53: onChange: (value: string) => void; + 54: placeholder: string; + 55: showSecret: boolean; + 56: onToggleSecret: () => void; + 57: icon?: ReactNode; + 58: }) { + 59: return ( + 60: + 61:
+ 62: onChange(e.target.value)} + 66: placeholder={placeholder} + 67: className="pr-10" + 68: /> + 69: + 78:
+ 79:
+ 80: ); + 81: } + 82: + 83: /** + 84: * Test connection button. + 85: */ + 86: export function TestButton({ + 87: onTest, + 88: isTesting, + 89: label = 'Test Connection', + 90: Icon = RefreshCw, + 91: }: { + 92: onTest?: () => void; + 93: isTesting?: boolean; + 94: label?: string; + 95: Icon?: React.ElementType; + 96: }) { + 97: if (!onTest) { + 98: return null; + 99: } +100: return ( +101: +109: ); +110: } +```` + +## File: client/src/components/integration-config-panel/webhook-config.tsx +````typescript + 1: /** + 2: * Custom/Webhook integration configuration. + 3: */ + 4: + 5: import { Globe, Key } from 'lucide-react'; + 6: + 7: import type { Integration } from '@/api/types'; + 8: import { Input } from '@/components/ui/input'; + 9: import { Label } from '@/components/ui/label'; + 10: import { + 11: Select, + 12: SelectContent, + 13: SelectItem, + 14: SelectTrigger, + 15: SelectValue, + 16: } from '@/components/ui/select'; + 17: import { Field, SecretInput, TestButton } from './shared'; + 18: + 19: interface WebhookConfigProps { + 20: integration: Integration; + 21: onUpdate: (config: Partial) => void; + 22: onTest?: () => void; + 23: isTesting: boolean; + 24: showSecrets: Record; + 25: toggleSecret: (key: string) => void; + 26: } + 27: + 28: export function WebhookConfig({ + 29: integration, + 30: onUpdate, + 31: onTest, + 32: isTesting, + 33: showSecrets, + 34: toggleSecret, + 35: }: WebhookConfigProps) { + 36: const config = integration.webhook_config || { + 37: url: '', + 38: method: 'POST' as const, + 39: auth_type: 'none' as const, + 40: auth_value: '', + 41: }; + 42: + 43: return ( + 44:
+ 45: }> + 46: + 49: onUpdate({ + 50: webhook_config: { ...config, url: e.target.value }, + 51: }) + 52: } + 53: placeholder="https://api.example.com/webhook" + 54: /> + 55: + 56:
+ 57:
+ 58: + 59: + 76:
+ 77:
+ 78: + 79: + 97:
+ 98:
+ 99: +100: {config.auth_type && config.auth_type !== 'none' && ( +101: onUpdate({ webhook_config: { ...config, auth_value: value } })} +111: placeholder={config.auth_type === 'basic' ? 'username:password' : 'Enter value'} +112: showSecret={showSecrets.webhook_auth ?? false} +113: onToggleSecret={() => toggleSecret('webhook_auth')} +114: icon={} +115: /> +116: )} +117: +118: +119:
+120: ); +121: } +```` + +## File: client/src/components/projects/ProjectList.tsx +````typescript + 1: // Project list component with create and manage actions + 2: + 3: import { Archive, FolderKanban, MoreHorizontal, Plus, Settings2, Trash2 } from 'lucide-react'; + 4: import { useMemo, useState } from 'react'; + 5: import { Link } from 'react-router-dom'; + 6: import { useProjects } from '@/contexts/project-state'; + 7: import { useWorkspace } from '@/contexts/workspace-state'; + 8: import { Button } from '@/components/ui/button'; + 9: import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; + 10: import { + 11: Dialog, + 12: DialogContent, + 13: DialogFooter, + 14: DialogHeader, + 15: DialogTitle, + 16: } from '@/components/ui/dialog'; + 17: import { + 18: DropdownMenu, + 19: DropdownMenuContent, + 20: DropdownMenuItem, + 21: DropdownMenuTrigger, + 22: } from '@/components/ui/dropdown-menu'; + 23: import { Input } from '@/components/ui/input'; + 24: import { Label } from '@/components/ui/label'; + 25: import { Switch } from '@/components/ui/switch'; + 26: import { Badge } from '@/components/ui/badge'; + 27: import { useGuardedMutation } from '@/hooks/use-guarded-mutation'; + 28: import { searchIcon } from '@/lib/styles'; + 29: import { cn } from '@/lib/utils'; + 30: + 31: export function ProjectList() { + 32: const { + 33: projects, + 34: activeProject, + 35: switchProject, + 36: createProject, + 37: archiveProject, + 38: restoreProject, + 39: deleteProject, + 40: isLoading, + 41: } = useProjects(); + 42: const { currentWorkspace } = useWorkspace(); + 43: const { guard } = useGuardedMutation(); + 44: const [searchQuery, setSearchQuery] = useState(''); + 45: const [showArchived, setShowArchived] = useState(false); + 46: const [createOpen, setCreateOpen] = useState(false); + 47: const [newName, setNewName] = useState(''); + 48: const [newSlug, setNewSlug] = useState(''); + 49: const [newDescription, setNewDescription] = useState(''); + 50: const closeDialog = () => setCreateOpen(false); + 51: + 52: const filteredProjects = useMemo(() => { + 53: const normalized = searchQuery.trim().toLowerCase(); + 54: return projects.filter((project) => { + 55: if (!showArchived && project.is_archived) { + 56: return false; + 57: } + 58: if (!normalized) { + 59: return true; + 60: } + 61: return ( + 62: project.name.toLowerCase().includes(normalized) || + 63: project.slug?.toLowerCase().includes(normalized) || + 64: project.description?.toLowerCase().includes(normalized) + 65: ); + 66: }); + 67: }, [projects, searchQuery, showArchived]); + 68: + 69: const handleCreate = async () => { + 70: if (!currentWorkspace) { + 71: return; + 72: } + 73: const name = newName.trim(); + 74: if (!name) { + 75: return; + 76: } + 77: const created = await guard(() => + 78: createProject({ + 79: workspace_id: currentWorkspace.id, + 80: name, + 81: slug: newSlug.trim() || undefined, + 82: description: newDescription.trim() || undefined, + 83: }) + 84: ); + 85: if (created) { + 86: setCreateOpen(false); + 87: setNewName(''); + 88: setNewSlug(''); + 89: setNewDescription(''); + 90: } + 91: }; + 92: + 93: const handleArchive = async (projectId: string) => { + 94: await guard(() => archiveProject(projectId), { + 95: title: 'Offline mode', + 96: message: 'Archiving projects requires an active server connection.', + 97: }); + 98: }; + 99: +100: const handleRestore = async (projectId: string) => { +101: await guard(() => restoreProject(projectId), { +102: title: 'Offline mode', +103: message: 'Restoring projects requires an active server connection.', +104: }); +105: }; +106: +107: const handleDelete = async (projectId: string) => { +108: if (!confirm('Delete this project? This cannot be undone.')) { +109: return; +110: } +111: await guard(() => deleteProject(projectId), { +112: title: 'Offline mode', +113: message: 'Deleting projects requires an active server connection.', +114: }); +115: }; +116: +117: return ( +118:
+119:
+120:
+121:

Projects

+122:

+123: Organize meetings and settings by project. +124:

+125:
+126: +130:
+131: +132:
+133:
+134: +135: setSearchQuery(event.target.value)} +139: className="pl-10" +140: /> +141:
+142:
+143: +148: +151:
+152:
+153: +154: {isLoading ? ( +155:
Loading projects...
+156: ) : ( +157:
+158: {filteredProjects.map((project) => { +159: const isActive = project.id === activeProject?.id; +160: return ( +161: +162: +163:
+164:
+165: {project.name} +166: {project.slug && ( +167:

/{project.slug}

+168: )} +169:
+170: +171: +172: +175: +176: +177: +178: +179: +180: Settings +181: +182: +183: {project.is_archived ? ( +184: handleRestore(project.id)} +186: className="gap-2" +187: > +188: +189: Restore +190: +191: ) : ( +192: handleArchive(project.id)} +194: className="gap-2" +195: disabled={project.is_default} +196: > +197: +198: Archive +199: +200: )} +201: handleDelete(project.id)} +203: className="gap-2 text-destructive focus:text-destructive" +204: disabled={project.is_default} +205: > +206: +207: Delete +208: +209: +210: +211:
+212:
+213: +214:

+215: {project.description || 'No description provided.'} +216:

+217:
+218: {project.is_default && Default} +219: {project.is_archived && Archived} +220: {isActive && Active} +221:
+222:
+223: +231: +234:
+235:
+236:
+237: ); +238: })} +239:
+240: )} +241: +242: +243: +244: +245: Create Project +246: +247:
+248:
+249: +252: setNewName(event.target.value)} +256: placeholder="e.g. Growth Experiments" +257: /> +258:
+259:
+260: +263: setNewSlug(event.target.value)} +267: placeholder="growth-experiments" +268: /> +269:
+270:
+271: +277: setNewDescription(event.target.value)} +281: placeholder="Short project description" +282: /> +283:
+284:
+285: +286: +289: +292: +293:
+294:
+295:
+296: ); +297: } +```` + +## File: client/src/components/projects/ProjectMembersPanel.tsx +````typescript + 1: // Project members management panel + 2: + 3: import { Trash2, UserPlus } from 'lucide-react'; + 4: import { useState } from 'react'; + 5: import { getAPI } from '@/api'; + 6: import type { ProjectMembership, ProjectRole } from '@/api/types'; + 7: import { useProjectMembers } from '@/hooks/use-project-members'; + 8: import { useGuardedMutation } from '@/hooks/use-guarded-mutation'; + 9: import { Button } from '@/components/ui/button'; + 10: import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; + 11: import { flexLayout } from '@/lib/styles'; + 12: import { + 13: Dialog, + 14: DialogContent, + 15: DialogFooter, + 16: DialogHeader, + 17: DialogTitle, + 18: } from '@/components/ui/dialog'; + 19: import { Input } from '@/components/ui/input'; + 20: import { Label } from '@/components/ui/label'; + 21: import { + 22: Select, + 23: SelectContent, + 24: SelectItem, + 25: SelectTrigger, + 26: SelectValue, + 27: } from '@/components/ui/select'; + 28: import { + 29: Table, + 30: TableBody, + 31: TableCell, + 32: TableHead, + 33: TableHeader, + 34: TableRow, + 35: } from '@/components/ui/table'; + 36: + 37: const roleOptions: ProjectRole[] = ['viewer', 'editor', 'admin']; + 38: + 39: export function ProjectMembersPanel({ projectId }: { projectId: string }) { + 40: const { members, isLoading, refresh } = useProjectMembers(projectId); + 41: const { guard } = useGuardedMutation(); + 42: const [dialogOpen, setDialogOpen] = useState(false); + 43: const [newUserId, setNewUserId] = useState(''); + 44: const [newRole, setNewRole] = useState('viewer'); + 45: const headerRowClass = flexLayout.rowBetween; + 46: const openDialog = () => setDialogOpen(true); + 47: const closeDialog = () => setDialogOpen(false); + 48: + 49: const handleAdd = async () => { + 50: const userId = newUserId.trim(); + 51: if (!userId) { + 52: return; + 53: } + 54: await guard(async () => { + 55: await getAPI().addProjectMember({ + 56: project_id: projectId, + 57: user_id: userId, + 58: role: newRole, + 59: }); + 60: await refresh(); + 61: setDialogOpen(false); + 62: setNewUserId(''); + 63: setNewRole('viewer'); + 64: }); + 65: }; + 66: + 67: const handleRoleChange = async (member: ProjectMembership, role: ProjectRole) => { + 68: if (member.role === role) { + 69: return; + 70: } + 71: await guard(async () => { + 72: await getAPI().updateProjectMemberRole({ + 73: project_id: projectId, + 74: user_id: member.user_id, + 75: role, + 76: }); + 77: await refresh(); + 78: }); + 79: }; + 80: + 81: const handleRemove = async (member: ProjectMembership) => { + 82: if (!confirm('Remove this member from the project?')) { + 83: return; + 84: } + 85: await guard(async () => { + 86: await getAPI().removeProjectMember({ + 87: project_id: projectId, + 88: user_id: member.user_id, + 89: }); + 90: await refresh(); + 91: }); + 92: }; + 93: + 94: return ( + 95: + 96: + 97: Members + 98: +102: +103: +104: {isLoading ? ( +105:
Loading members...
+106: ) : members.length === 0 ? ( +107:
No members yet.
+108: ) : ( +109: +110: +111: +112: User ID +113: Role +114: Actions +115: +116: +117: +118: {members.map((member) => ( +119: +120: {member.user_id} +121: +122: +137: +138: +139: +147: +148: +149: ))} +150: +151:
+152: )} +153:
+154: +155: +156: +157: +158: Add member +159: +160:
+161:
+162: +168: setNewUserId(event.target.value)} +172: placeholder="UUID of user" +173: /> +174:
+175:
+176: +179: +191:
+192:
+193: +194: +197: +200: +201:
+202:
+203:
+204: ); +205: } +```` + +## File: client/src/components/projects/ProjectScopeFilter.tsx +````typescript + 1: // Project scope filter controls shared between Meetings and Tasks pages. + 2: + 3: import type { Dispatch, SetStateAction } from 'react'; + 4: import { useMemo } from 'react'; + 5: import type { Project } from '@/api/types/projects'; + 6: import type { ProjectScope } from '@/api/types/requests'; + 7: import { Badge } from '@/components/ui/badge'; + 8: import { Button } from '@/components/ui/button'; + 9: import { Checkbox } from '@/components/ui/checkbox'; + 10: import { Label } from '@/components/ui/label'; + 11: import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; + 12: + 13: interface ProjectScopeFilterProps { + 14: activeProjects: Project[]; + 15: projectScope: ProjectScope; + 16: selectedProjectIds: string[]; + 17: onProjectScopeChange: (scope: ProjectScope) => void; + 18: onSelectedProjectIdsChange: Dispatch>; + 19: projectsLoading?: boolean; + 20: resolvedProjectId?: string | null; + 21: idPrefix: string; + 22: className?: string; + 23: } + 24: + 25: export function ProjectScopeFilter({ + 26: activeProjects, + 27: projectScope, + 28: selectedProjectIds, + 29: onProjectScopeChange, + 30: onSelectedProjectIdsChange, + 31: projectsLoading = false, + 32: resolvedProjectId, + 33: idPrefix, + 34: className = 'flex flex-wrap items-center gap-2', + 35: }: ProjectScopeFilterProps) { + 36: const selectedProjectNames = useMemo(() => { + 37: if (projectScope !== 'selected' || selectedProjectIds.length === 0) { + 38: return ''; + 39: } + 40: if (activeProjects.length > 0 && selectedProjectIds.length === activeProjects.length) { + 41: return 'All projects'; + 42: } + 43: const nameMap = new Map(activeProjects.map((project) => [project.id, project.name])); + 44: const names = selectedProjectIds + 45: .map((id) => nameMap.get(id)) + 46: .filter((name): name is string => Boolean(name)); + 47: const display = names.slice(0, 2).join(', '); + 48: const remaining = names.length - 2; + 49: return remaining > 0 ? `${display} +${remaining} more` : display; + 50: }, [projectScope, selectedProjectIds, activeProjects]); + 51: + 52: const setSelectedProjects = (ids: string[]) => { + 53: onProjectScopeChange('selected'); + 54: onSelectedProjectIdsChange(ids); + 55: }; + 56: + 57: const handleProjectToggle = (projectId: string, checked: boolean | string) => { + 58: onProjectScopeChange('selected'); + 59: onSelectedProjectIdsChange((prev) => { + 60: if (checked) { + 61: return prev.includes(projectId) ? prev : [...prev, projectId]; + 62: } + 63: return prev.filter((id) => id !== projectId); + 64: }); + 65: }; + 66: + 67: return ( + 68:
+ 69: + 77: + 84: + 85: + 86: + 93: + 94: + 95:
+ 96:
Select projects
+ 97:
+ 98: +105: +108:
+109:
+110: {activeProjects.map((project) => ( +111:
+112: handleProjectToggle(project.id, checked)} +116: /> +117: +120:
+121: ))} +122:
+123: {projectScope === 'selected' && selectedProjectIds.length === 0 && ( +124:

Select at least one project.

+125: )} +126:
+127:
+128:
+129: {projectScope === 'selected' && selectedProjectNames && ( +130:
+131: {selectedProjectNames} +132: {selectedProjectIds.length} +133:
+134: )} +135:
+136: ); +137: } +```` + +## File: client/src/components/projects/ProjectSettingsPanel.tsx +````typescript + 1: // Project settings editor panel + 2: + 3: import { useEffect, useMemo, useState } from 'react'; + 4: import { getAPI } from '@/api/interface'; + 5: import type { + 6: ExportFormat, + 7: Project, + 8: ProjectSettings, + 9: SummarizationTemplate, + 10: TriggerRules, + 11: } from '@/api/types'; + 12: import { useProjects } from '@/contexts/project-state'; + 13: import { useWorkspace } from '@/contexts/workspace-state'; + 14: import { Button } from '@/components/ui/button'; + 15: import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; + 16: import { Input } from '@/components/ui/input'; + 17: import { Label } from '@/components/ui/label'; + 18: import { + 19: Select, + 20: SelectContent, + 21: SelectItem, + 22: SelectTrigger, + 23: SelectValue, + 24: } from '@/components/ui/select'; + 25: import { Switch } from '@/components/ui/switch'; + 26: import { Textarea } from '@/components/ui/textarea'; + 27: import { useGuardedMutation } from '@/hooks/use-guarded-mutation'; + 28: + 29: type ToggleMode = 'inherit' | 'enabled' | 'disabled'; + 30: type FormatMode = 'inherit' | ExportFormat; + 31: + 32: const splitPatterns = (value: string): string[] => + 33: value + 34: .split(/[\n,]+/g) + 35: .map((item) => item.trim()) + 36: .filter(Boolean); + 37: + 38: const toMode = (value?: boolean): ToggleMode => + 39: value === undefined ? 'inherit' : value ? 'enabled' : 'disabled'; + 40: + 41: const fromMode = (value: ToggleMode): boolean | undefined => { + 42: if (value === 'inherit') { + 43: return undefined; + 44: } + 45: return value === 'enabled'; + 46: }; + 47: + 48: export function ProjectSettingsPanel({ project }: { project: Project }) { + 49: const { updateProject } = useProjects(); + 50: const { guard } = useGuardedMutation(); + 51: const { currentWorkspace } = useWorkspace(); + 52: const [name, setName] = useState(project.name); + 53: const [slug, setSlug] = useState(project.slug ?? ''); + 54: const [description, setDescription] = useState(project.description ?? ''); + 55: + 56: const [exportFormat, setExportFormat] = useState('inherit'); + 57: const [includeAudio, setIncludeAudio] = useState('inherit'); + 58: const [includeTimestamps, setIncludeTimestamps] = useState('inherit'); + 59: const [autoStart, setAutoStart] = useState('inherit'); + 60: const [ragEnabled, setRagEnabled] = useState('inherit'); + 61: const [defaultTemplateId, setDefaultTemplateId] = useState<'inherit' | string>('inherit'); + 62: const [templates, setTemplates] = useState([]); + 63: const [templatesLoading, setTemplatesLoading] = useState(false); + 64: const [calendarPatterns, setCalendarPatterns] = useState(''); + 65: const [appPatterns, setAppPatterns] = useState(''); + 66: const [calendarInherit, setCalendarInherit] = useState(true); + 67: const [appInherit, setAppInherit] = useState(true); + 68: const [isSaving, setIsSaving] = useState(false); + 69: + 70: useEffect(() => { + 71: setName(project.name); + 72: setSlug(project.slug ?? ''); + 73: setDescription(project.description ?? ''); + 74: + 75: const exportRules = project.settings?.export_rules; + 76: setExportFormat(exportRules?.default_format ?? 'inherit'); + 77: setIncludeAudio(toMode(exportRules?.include_audio)); + 78: setIncludeTimestamps(toMode(exportRules?.include_timestamps)); + 79: + 80: const triggerRules = project.settings?.trigger_rules; + 81: setAutoStart(toMode(triggerRules?.auto_start_enabled)); + 82: const calendar = triggerRules?.calendar_match_patterns; + 83: setCalendarInherit(calendar === undefined); + 84: setCalendarPatterns(calendar?.join('\n') ?? ''); + 85: const apps = triggerRules?.app_match_patterns; + 86: setAppInherit(apps === undefined); + 87: setAppPatterns(apps?.join('\n') ?? ''); + 88: + 89: setRagEnabled(toMode(project.settings?.rag_enabled)); + 90: setDefaultTemplateId(project.settings?.default_summarization_template ?? 'inherit'); + 91: }, [project]); + 92: + 93: useEffect(() => { + 94: if (!currentWorkspace) { + 95: setTemplates([]); + 96: return; + 97: } + 98: let isActive = true; + 99: const loadTemplates = async () => { +100: setTemplatesLoading(true); +101: try { +102: const response = await getAPI().listSummarizationTemplates({ +103: workspace_id: currentWorkspace.id, +104: include_system: true, +105: include_archived: false, +106: limit: 200, +107: offset: 0, +108: }); +109: if (isActive) { +110: setTemplates(response.templates); +111: } +112: } catch { +113: if (isActive) { +114: setTemplates([]); +115: } +116: } finally { +117: if (isActive) { +118: setTemplatesLoading(false); +119: } +120: } +121: }; +122: void loadTemplates(); +123: return () => { +124: isActive = false; +125: }; +126: }, [currentWorkspace]); +127: +128: const settings = useMemo(() => { +129: const export_rules: ProjectSettings['export_rules'] = {}; +130: if (exportFormat !== 'inherit') { +131: export_rules.default_format = exportFormat; +132: } +133: const includeAudioValue = fromMode(includeAudio); +134: if (includeAudioValue !== undefined) { +135: export_rules.include_audio = includeAudioValue; +136: } +137: const includeTsValue = fromMode(includeTimestamps); +138: if (includeTsValue !== undefined) { +139: export_rules.include_timestamps = includeTsValue; +140: } +141: +142: const trigger_rules: TriggerRules = {}; +143: const autoStartValue = fromMode(autoStart); +144: if (autoStartValue !== undefined) { +145: trigger_rules.auto_start_enabled = autoStartValue; +146: } +147: if (!calendarInherit) { +148: trigger_rules.calendar_match_patterns = splitPatterns(calendarPatterns); +149: } +150: if (!appInherit) { +151: trigger_rules.app_match_patterns = splitPatterns(appPatterns); +152: } +153: +154: const merged: ProjectSettings = {}; +155: if (Object.keys(export_rules).length) { +156: merged.export_rules = export_rules; +157: } +158: if (Object.keys(trigger_rules).length) { +159: merged.trigger_rules = trigger_rules; +160: } +161: +162: const ragValue = fromMode(ragEnabled); +163: if (ragValue !== undefined) { +164: merged.rag_enabled = ragValue; +165: } +166: if (defaultTemplateId !== 'inherit' && defaultTemplateId.trim()) { +167: merged.default_summarization_template = defaultTemplateId.trim(); +168: } +169: return merged; +170: }, [ +171: exportFormat, +172: includeAudio, +173: includeTimestamps, +174: autoStart, +175: calendarInherit, +176: calendarPatterns, +177: appInherit, +178: appPatterns, +179: ragEnabled, +180: defaultTemplateId, +181: ]); +182: +183: const handleSave = async () => { +184: const trimmedName = name.trim(); +185: if (!trimmedName) { +186: return; +187: } +188: setIsSaving(true); +189: try { +190: await guard(() => +191: updateProject({ +192: project_id: project.id, +193: name: trimmedName, +194: slug: slug.trim() || undefined, +195: description: description.trim() || undefined, +196: settings, +197: }) +198: ); +199: } finally { +200: setIsSaving(false); +201: } +202: }; +203: +204: const selectedTemplate = +205: defaultTemplateId !== 'inherit' +206: ? templates.find((template) => template.id === defaultTemplateId) ?? null +207: : null; +208: const isMissingTemplate = defaultTemplateId !== 'inherit' && !selectedTemplate; +209: +210: return ( +211:
+212: +213: +214: Basics +215: +216: +217:
+218: +221: setName(event.target.value)} +225: /> +226:
+227:
+228: +231: setSlug(event.target.value)} +235: placeholder="project-slug" +236: /> +237:
+238:
+239: +242: