From 131e19c08a929f40bb12b9089a0ea6be5fffc077 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 1 Apr 2026 12:44:10 +0000 Subject: [PATCH 1/4] feat: make mic clickable and update default hotkey - Exposed recording toggle functionality to the renderer via IPC. - Added a click event listener to the pill UI to start/stop recording. - Updated the default global hotkey to Alt+Shift+S for easier access. - Added a settings migration to update the default hotkey for existing users. - Improved UI feedback with hover and active states for the pill. - Updated pill positioning to ensure it remains centered and interactive. Co-authored-by: lanryweezy <101323524+lanryweezy@users.noreply.github.com> --- src/main/ipc.js | 6 +++++- src/main/preload.js | 1 + src/main/services/settings.js | 7 +++++++ src/main/shortcuts.js | 3 ++- src/renderer/index.js | 9 +++++++++ src/renderer/styles/floating.css | 31 +++++++++++++++++++++++++++---- src/shared/constants.js | 3 ++- 7 files changed, 53 insertions(+), 7 deletions(-) diff --git a/src/main/ipc.js b/src/main/ipc.js index ffa16f0..ed67b03 100644 --- a/src/main/ipc.js +++ b/src/main/ipc.js @@ -8,7 +8,7 @@ const { retryAndPasteTranscript } = require('./services/retry-transcript'); const pendingRetryService = require('./services/pending-retry'); const sessionManager = require('./services/transcription-session-manager'); const { closeSettingsWindow } = require('./settings-window'); -const { updateHotkey } = require('./shortcuts'); +const { updateHotkey, handleRecordingToggle } = require('./shortcuts'); const { applyLaunchOnStartupPreference } = require('./services/startup'); const { applyAutoUpdatePreference } = require('./services/updater'); const logger = require('./services/logger'); @@ -114,6 +114,10 @@ function setupIpcHandlers(mainWindow) { closeSettingsWindow(); }); + ipcMain.on(CHANNELS.TOGGLE_RECORDING, () => { + handleRecordingToggle(mainWindowRef); + }); + ipcMain.on(CHANNELS.AUDIO_SEGMENT, (event, audioData) => { sessionManager.handleSegment(audioData).catch((error) => { logger.error('[Pipeline] Failed to enqueue audio segment:', error); diff --git a/src/main/preload.js b/src/main/preload.js index 05aed7d..111dcc2 100644 --- a/src/main/preload.js +++ b/src/main/preload.js @@ -39,6 +39,7 @@ contextBridge.exposeInMainWorld('api', { openLogsFolder: () => ipcRenderer.invoke('app:open-logs'), // Audio + toggleRecording: () => ipcRenderer.send(CHANNELS.TOGGLE_RECORDING), sendAudioSegment: (payload) => ipcRenderer.send(CHANNELS.AUDIO_SEGMENT, payload), sendAudioChunk: (payload) => ipcRenderer.send(CHANNELS.AUDIO_SEGMENT, payload), notifyAudioSessionStopped: (payload) => ipcRenderer.send(CHANNELS.AUDIO_SESSION_STOPPED, payload), diff --git a/src/main/services/settings.js b/src/main/services/settings.js index 2842e8b..31f2e51 100644 --- a/src/main/services/settings.js +++ b/src/main/services/settings.js @@ -22,6 +22,13 @@ const SETTINGS_MIGRATIONS = [ changes: { cloudProcessingEnabled: false } + }, + { + id: '2026-03-20-update-default-hotkey', + mode: 'preserve', + changes: { + hotkey: 'Alt+Shift+S' + } } ]; diff --git a/src/main/shortcuts.js b/src/main/shortcuts.js index b309455..baf1d5c 100644 --- a/src/main/shortcuts.js +++ b/src/main/shortcuts.js @@ -171,5 +171,6 @@ module.exports = { registerShortcuts, unregisterShortcuts, updateHotkey, - getCurrentHotkey + getCurrentHotkey, + handleRecordingToggle }; diff --git a/src/renderer/index.js b/src/renderer/index.js index 68d94e6..dd36e4d 100644 --- a/src/renderer/index.js +++ b/src/renderer/index.js @@ -297,6 +297,15 @@ async function init() { }); } + const pillElement = document.getElementById('pill'); + if (pillElement) { + pillElement.addEventListener('click', () => { + if (window.api && window.api.toggleRecording) { + window.api.toggleRecording(); + } + }); + } + const settings = await window.api.getSettings(); window.api.log(`Loaded settings. Hotkey: ${settings.hotkey}`); diff --git a/src/renderer/styles/floating.css b/src/renderer/styles/floating.css index 3443eaa..62cc3ce 100644 --- a/src/renderer/styles/floating.css +++ b/src/renderer/styles/floating.css @@ -4,7 +4,10 @@ /* ─── Pill Shell ────────────────────────────────────── */ .pill-shell { - position: relative; + position: fixed; + bottom: 16px; + left: 50%; + transform: translateX(-50%) translateY(20px) scale(0.95); --pill-shell-width: 304px; width: min(var(--pill-shell-width), calc(100vw - 20px)); display: flex; @@ -36,12 +39,12 @@ .pill-shell.animate-in { opacity: 1; - transform: translateY(0) scale(1); + transform: translateX(-50%) translateY(0) scale(1); } .pill-shell.animate-out { opacity: 0; - transform: translateY(12px) scale(0.97); + transform: translateX(-50%) translateY(12px) scale(0.97); transition: opacity 0.25s cubic-bezier(0.4, 0, 1, 1), transform 0.25s cubic-bezier(0.4, 0, 1, 1); @@ -54,7 +57,27 @@ height: var(--pill-height); border-radius: var(--pill-radius); overflow: hidden; - cursor: default; + cursor: pointer; + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.pill:hover { + transform: translateY(-1px); + border-color: rgba(255, 255, 255, 0.2); + box-shadow: + var(--shadow-pill), + 0 8px 24px 4px rgba(10, 10, 16, 0.45); +} + +.pill:active { + transform: translateY(0) scale(0.98); +} + +[data-theme="light"] .pill:hover { + border-color: rgba(100, 115, 150, 0.6); + box-shadow: + 0 24px 42px rgba(100, 115, 150, 0.32), + 0 10px 22px rgba(60, 75, 110, 0.22); } /* ─── Glass Background ──────────────────────────────── */ diff --git a/src/shared/constants.js b/src/shared/constants.js index c038ba4..74a755d 100644 --- a/src/shared/constants.js +++ b/src/shared/constants.js @@ -17,6 +17,7 @@ const CHANNELS = { LOG: 'app:log', AUDIO_SEGMENT: 'audio:segment', AUDIO_SESSION_STOPPED: 'audio:session-stopped', + TOGGLE_RECORDING: 'recording:toggle', WINDOW_MINIMIZE: 'window:minimize', WINDOW_CLOSE: 'window:close', WINDOW_HIDE: 'window:hide', @@ -37,7 +38,7 @@ const CHANNELS = { const DEFAULT_SETTINGS = { groqApiKey: '', - hotkey: 'CommandOrControl+Shift+Space', + hotkey: 'Alt+Shift+S', language: 'auto', enhanceText: true, autoPaste: true, From da6db1cd0de8a8d9df550ca4328bddbc980b001d Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 1 Apr 2026 12:58:00 +0000 Subject: [PATCH 2/4] feat: real-time transcription and automatic meeting notes - Optimized VAD for a real-time feel with 3-second chunks and 800ms pause threshold. - Implemented automatic meeting detection for Zoom, Teams, Slack, and Webex. - Added "Meeting Notes" mode to generate structured summaries and action plans. - Integrated desktop notifications and simulated email delivery for meeting summaries. - Made the pill UI clickable for easy start/stop recording. - Updated default global hotkey to Alt+Shift+S. - Added session-specific prompt style overrides to avoid permanent setting changes. Co-authored-by: lanryweezy <101323524+lanryweezy@users.noreply.github.com> --- .../koe-core/src/providers/groq-helpers.ts | 4 + src/main/ipc.js | 5 +- src/main/main.js | 2 + src/main/preload.js | 7 +- src/main/services/email-service.js | 19 +++++ src/main/services/meeting-detector.js | 82 +++++++++++++++++++ .../services/transcription-session-manager.js | 27 ++++-- src/main/shortcuts.js | 9 +- src/renderer/audio/vad.js | 10 ++- src/renderer/components/pill-ui.js | 6 ++ src/renderer/index.js | 16 +++- src/shared/constants.js | 5 +- 12 files changed, 174 insertions(+), 18 deletions(-) create mode 100644 src/main/services/email-service.js create mode 100644 src/main/services/meeting-detector.js diff --git a/packages/koe-core/src/providers/groq-helpers.ts b/packages/koe-core/src/providers/groq-helpers.ts index dd1ab10..0526341 100644 --- a/packages/koe-core/src/providers/groq-helpers.ts +++ b/packages/koe-core/src/providers/groq-helpers.ts @@ -21,6 +21,10 @@ export function resolveEnhancementPrompt(promptStyle: string = 'Clean', customPr return 'Refine this dictated text into a tighter version with less filler while keeping the original meaning. Remove filler words like um, uh, and obvious filler mistranscriptions like ohms only when they are clearly filler, not when they are literal or technical. Never use em dashes, and do not add transcript tags or any other wrapper markup.'; } + if (promptStyle === 'Meeting Notes' || promptStyle === 'Meeting') { + return 'Refine this meeting transcript into a structured set of meeting notes. Include a summary of the main points and a clear list of action items with owners if mentioned. Maintain a professional tone and ensure the output is concise and actionable.'; + } + return DEFAULT_CUSTOM_PROMPT; } diff --git a/src/main/ipc.js b/src/main/ipc.js index ed67b03..1808fb7 100644 --- a/src/main/ipc.js +++ b/src/main/ipc.js @@ -114,10 +114,11 @@ function setupIpcHandlers(mainWindow) { closeSettingsWindow(); }); - ipcMain.on(CHANNELS.TOGGLE_RECORDING, () => { - handleRecordingToggle(mainWindowRef); + ipcMain.on(CHANNELS.TOGGLE_RECORDING, (event, options) => { + handleRecordingToggle(mainWindowRef, options); }); + ipcMain.on(CHANNELS.AUDIO_SEGMENT, (event, audioData) => { sessionManager.handleSegment(audioData).catch((error) => { logger.error('[Pipeline] Failed to enqueue audio segment:', error); diff --git a/src/main/main.js b/src/main/main.js index 6f95e4a..0e2b8e0 100644 --- a/src/main/main.js +++ b/src/main/main.js @@ -6,6 +6,7 @@ const { setupIpcHandlers } = require('./ipc'); const { createSettingsWindow } = require('./settings-window'); const { getPillBounds, pinPillWindow } = require('./services/pill-window'); const { getSetting } = require('./services/settings'); +const meetingDetector = require('./services/meeting-detector'); const { applyLaunchOnStartupPreference } = require('./services/startup'); const { applyAutoUpdatePreference } = require('./services/updater'); const sessionManager = require('./services/transcription-session-manager'); @@ -81,6 +82,7 @@ app.whenReady().then(() => { setupTray(mainWindow); registerShortcuts(mainWindow); setupIpcHandlers(mainWindow); + meetingDetector.init(mainWindow); applyLaunchOnStartupPreference(getSetting('launchOnStartup') !== false); applyAutoUpdatePreference(getSetting('autoUpdate') !== false); diff --git a/src/main/preload.js b/src/main/preload.js index 111dcc2..520e87e 100644 --- a/src/main/preload.js +++ b/src/main/preload.js @@ -17,6 +17,11 @@ contextBridge.exposeInMainWorld('api', { ipcRenderer.on(CHANNELS.WINDOW_ANIMATE_IN, () => callback()); }, + onMeetingDetected: (callback) => { + ipcRenderer.removeAllListeners(CHANNELS.MEETING_DETECTED); + ipcRenderer.on(CHANNELS.MEETING_DETECTED, () => callback()); + }, + // Settings getSettings: () => ipcRenderer.invoke(CHANNELS.GET_SETTINGS), saveSettings: (settings) => ipcRenderer.invoke(CHANNELS.SAVE_SETTINGS, settings), @@ -39,7 +44,7 @@ contextBridge.exposeInMainWorld('api', { openLogsFolder: () => ipcRenderer.invoke('app:open-logs'), // Audio - toggleRecording: () => ipcRenderer.send(CHANNELS.TOGGLE_RECORDING), + toggleRecording: (options = {}) => ipcRenderer.send(CHANNELS.TOGGLE_RECORDING, options), sendAudioSegment: (payload) => ipcRenderer.send(CHANNELS.AUDIO_SEGMENT, payload), sendAudioChunk: (payload) => ipcRenderer.send(CHANNELS.AUDIO_SEGMENT, payload), notifyAudioSessionStopped: (payload) => ipcRenderer.send(CHANNELS.AUDIO_SESSION_STOPPED, payload), diff --git a/src/main/services/email-service.js b/src/main/services/email-service.js new file mode 100644 index 0000000..dfb3dac --- /dev/null +++ b/src/main/services/email-service.js @@ -0,0 +1,19 @@ +const logger = require('./logger'); + +class EmailService { + async sendMeetingSummary(to, summary) { + if (!to) { + logger.warn('[EmailService] No email address provided for summary.'); + return; + } + + // Simulating email sending + logger.info(`[EmailService] Sending meeting summary to ${to}...`); + logger.debug('[EmailService] Summary content:', summary); + + // In a real app, you'd use a service like SendGrid, Mailgun, or Nodemailer + return Promise.resolve({ success: true }); + } +} + +module.exports = new EmailService(); diff --git a/src/main/services/meeting-detector.js b/src/main/services/meeting-detector.js new file mode 100644 index 0000000..c2a2698 --- /dev/null +++ b/src/main/services/meeting-detector.js @@ -0,0 +1,82 @@ +const { exec } = require('child_process'); +const { Notification } = require('electron'); +const logger = require('./logger'); + +const MEETING_PROCESSES = [ + 'Zoom.exe', + 'Teams.exe', + 'ms-teams.exe', + 'Slack.exe', + 'Webex.exe' +]; + +class MeetingDetector { + constructor() { + this.interval = null; + this.isMeetingActive = false; + this.mainWindow = null; + } + + init(mainWindow) { + this.mainWindow = mainWindow; + this.startMonitoring(); + } + + startMonitoring() { + if (this.interval) return; + + this.interval = setInterval(() => { + this.checkMeetingStatus(); + }, 30000); // Check every 30 seconds + + this.checkMeetingStatus(); + } + + stopMonitoring() { + if (this.interval) { + clearInterval(this.interval); + this.interval = null; + } + } + + checkMeetingStatus() { + const command = process.platform === 'win32' ? 'tasklist' : 'ps -e'; + + exec(command, (error, stdout) => { + if (error) { + logger.error('[MeetingDetector] Failed to list processes:', error); + return; + } + + const active = MEETING_PROCESSES.some(proc => stdout.includes(proc)); + + if (active && !this.isMeetingActive) { + this.onMeetingDetected(); + } + + this.isMeetingActive = active; + }); + } + + onMeetingDetected() { + logger.info('[MeetingDetector] Meeting detected!'); + + const notification = new Notification({ + title: 'Koe - Meeting Detected', + body: 'Would you like to join the meeting to take notes and summarize?', + silent: false + }); + + notification.show(); + + notification.on('click', () => { + if (this.mainWindow && !this.mainWindow.isDestroyed()) { + const { CHANNELS } = require('../../shared/constants'); + this.mainWindow.webContents.send(CHANNELS.MEETING_DETECTED); + this.mainWindow.show(); + } + }); + } +} + +module.exports = new MeetingDetector(); diff --git a/src/main/services/transcription-session-manager.js b/src/main/services/transcription-session-manager.js index 6307bec..b620dd7 100644 --- a/src/main/services/transcription-session-manager.js +++ b/src/main/services/transcription-session-manager.js @@ -8,6 +8,8 @@ const { Worker } = require('worker_threads'); const { CHANNELS } = require('../../shared/constants'); const { getSettings } = require('./settings'); const { autoPaste, writeToClipboard } = require('./clipboard'); +const { Notification } = require('electron'); +const emailService = require('./email-service'); const historyService = require('./history'); const rateLimiter = require('./rate-limiter'); const pendingRetryService = require('./pending-retry'); @@ -92,11 +94,11 @@ class TranscriptionSessionManager { } } - createSession(sessionId, settings = getSettings()) { + createSession(sessionId, settings = getSettings(), overrides = {}) { const sessionSettings = { groqApiKey: settings.groqApiKey || '', language: settings.language || 'auto', - promptStyle: settings.promptStyle || 'Clean', + promptStyle: overrides.promptStyle || settings.promptStyle || 'Clean', customPrompt: settings.customPrompt || '', model: settings.model || 'whisper-large-v3-turbo', enhanceText: settings.enhanceText !== false, @@ -106,8 +108,8 @@ class TranscriptionSessionManager { return this.coordinator.createSession(sessionId, sessionSettings); } - getOrCreateSession(sessionId, settings = getSettings()) { - return this.coordinator.getSession(sessionId) || this.createSession(sessionId, settings); + getOrCreateSession(sessionId, settings = getSettings(), overrides = {}) { + return this.coordinator.getSession(sessionId) || this.createSession(sessionId, settings, overrides); } sendStatus(status) { @@ -145,7 +147,7 @@ class TranscriptionSessionManager { async handleSegment(audioData) { const settings = getSettings(); - const session = this.getOrCreateSession(audioData.sessionId, settings); + const session = this.getOrCreateSession(audioData.sessionId, settings, audioData.overrides); await this.coordinator.addSegment(session, audioData); } @@ -248,6 +250,21 @@ class TranscriptionSessionManager { await autoPaste(outputText); } + if (session.settings.promptStyle === 'Meeting Notes') { + const settings = getSettings(); + + const notification = new Notification({ + title: 'Koe - Meeting Summary Ready', + body: outputText.slice(0, 100) + '...', + silent: false + }); + notification.show(); + + if (settings.sendEmailSummaries && settings.userEmail) { + emailService.sendMeetingSummary(settings.userEmail, outputText); + } + } + historyService.addHistoryEntry({ rawText, refinedText: outputText, diff --git a/src/main/shortcuts.js b/src/main/shortcuts.js index baf1d5c..e60eb47 100644 --- a/src/main/shortcuts.js +++ b/src/main/shortcuts.js @@ -30,9 +30,9 @@ function sendRetryStatus(mainWindow, status, fallbackSessionId = null) { }); } -function handleRecordingToggle(mainWindow) { +function handleRecordingToggle(mainWindow, options = {}) { const recordingState = toggleRecording(); - logger.info(`Global hotkey triggered. Recording state: ${recordingState.isRecording} (session ${recordingState.sessionId})`); + logger.info(`Recording toggle triggered. Recording state: ${recordingState.isRecording} (session ${recordingState.sessionId})`); setRecordingState(recordingState.isRecording, mainWindow); @@ -40,7 +40,10 @@ function handleRecordingToggle(mainWindow) { if (recordingState.isRecording) { showPillWindow(mainWindow); } - mainWindow.webContents.send(CHANNELS.RECORDING_TOGGLED, recordingState); + mainWindow.webContents.send(CHANNELS.RECORDING_TOGGLED, { + ...recordingState, + overrides: options + }); } } diff --git a/src/renderer/audio/vad.js b/src/renderer/audio/vad.js index b292024..6406a78 100644 --- a/src/renderer/audio/vad.js +++ b/src/renderer/audio/vad.js @@ -1,11 +1,11 @@ import { encodeWAV } from './wav-encoder.js'; const SAMPLE_RATE = 16000; -const MIN_CHUNK_SECONDS = 10; +const MIN_CHUNK_SECONDS = 3; const MIN_CHUNK_SAMPLES = SAMPLE_RATE * MIN_CHUNK_SECONDS; const HARD_CAP_SECONDS = 30; const HARD_CAP_SAMPLES = SAMPLE_RATE * HARD_CAP_SECONDS; -const PAUSE_CLOSE_MS = 1200; +const PAUSE_CLOSE_MS = 800; const SPEECH_THRESHOLD = 0.5; const MIN_SEGMENT_SECONDS = 0.25; @@ -287,7 +287,8 @@ function emitSegment(audio) { audioSeconds, sessionId: currentSessionId, segmentId: `${currentSessionId}-${sequence}`, - sequence + sequence, + overrides: window.recordingOverrides }); window.api?.log?.( @@ -395,12 +396,13 @@ export async function initVAD() { window.api?.log?.('VAD initialized successfully.'); } -export async function startListening(sessionId) { +export async function startListening(sessionId, options = {}) { if (!vad) { return false; } currentSessionId = sessionId ?? currentSessionId; + window.recordingOverrides = options; currentSequence = 0; sentSegments = 0; resetActiveSegment(); diff --git a/src/renderer/components/pill-ui.js b/src/renderer/components/pill-ui.js index 22f55c7..ce054d1 100644 --- a/src/renderer/components/pill-ui.js +++ b/src/renderer/components/pill-ui.js @@ -106,6 +106,12 @@ export class PillUI { this.stopTimer(); this.stopVisualizer(); break; + case 'meeting_suggested': + this.status.textContent = 'Meeting Detected'; + this.setDetail('Join Meeting to take notes?'); + this.stopTimer(); + this.stopVisualizer(); + break; case 'recording': this.status.textContent = 'Listening...'; this.setDetail(''); diff --git a/src/renderer/index.js b/src/renderer/index.js index dd36e4d..e965825 100644 --- a/src/renderer/index.js +++ b/src/renderer/index.js @@ -142,8 +142,15 @@ async function init() { pill.animateIn(); }); + window.api.onMeetingDetected(() => { + pill.animateIn(); + pill.setState('meeting_suggested'); + }); + + window.api.onRecordingToggle(async (payload) => { const recordingPayload = normalizeRecordingPayload(payload); + const overrides = payload?.overrides || {}; if (recordingPayload.isRecording) { if (vadInitFailed || !isVADReady()) { @@ -159,7 +166,7 @@ async function init() { pill.setState('recording'); try { - await startListening(activeSessionId); + await startListening(activeSessionId, overrides); } catch (err) { isRecording = false; pill.setError(getErrorLabel(err.message), activeSessionId); @@ -301,7 +308,12 @@ async function init() { if (pillElement) { pillElement.addEventListener('click', () => { if (window.api && window.api.toggleRecording) { - window.api.toggleRecording(); + if (pill.state === 'meeting_suggested') { + // Join meeting mode + window.api.toggleRecording({ promptStyle: 'Meeting Notes' }); + } else { + window.api.toggleRecording(); + } } }); } diff --git a/src/shared/constants.js b/src/shared/constants.js index 74a755d..4854c03 100644 --- a/src/shared/constants.js +++ b/src/shared/constants.js @@ -9,6 +9,7 @@ const CHANNELS = { TRANSCRIPTION_COMPLETE: 'transcription:complete', TRANSCRIPTION_PREVIEW: 'transcription:preview', WINDOW_ANIMATE_IN: 'window:animate-in', + MEETING_DETECTED: 'meeting:detected', // Renderer -> Main GET_SETTINGS: 'settings:get', @@ -49,7 +50,9 @@ const DEFAULT_SETTINGS = { customPrompt: DEFAULT_CUSTOM_PROMPT, model: 'whisper-large-v3-turbo', cloudProcessingEnabled: false, - cloudProcessingUrl: '' + cloudProcessingUrl: '', + userEmail: '', + sendEmailSummaries: true }; module.exports = { From 7c96220338b6d145a920df6ab8560d1e764f6dc9 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 1 Apr 2026 13:10:13 +0000 Subject: [PATCH 3/4] feat: advanced AI meeting assistant features - Implemented Live Insights Overlay for real-time AI suggestions during meetings. - Added 'Smart Context' to allow users to provide context for better transcription and refinement. - Created a searchable knowledge base for transcription history. - Automated Action Item extraction and created a dedicated 'Tasks' panel. - Added Privacy Mode to hide live transcripts and Always-on mode for background listening. - Refined meeting detection to reduce false positives. - Integrated automated meeting summaries with desktop notifications and (simulated) email delivery. - Improved 'Meeting Notes' prompt for structured summaries and tasks. Co-authored-by: lanryweezy <101323524+lanryweezy@users.noreply.github.com> --- .../koe-core/src/providers/groq-helpers.ts | 13 ++-- src/main/ipc.js | 17 +++++ src/main/preload.js | 8 +++ src/main/services/history.js | 12 ++++ .../services/transcription-session-manager.js | 44 +++++++++++- src/main/services/transcription-worker.js | 2 +- src/renderer/components/history-panel.js | 21 +++++- src/renderer/components/pill-ui.js | 22 ++++++ src/renderer/components/settings-panel.js | 15 ++++ src/renderer/components/tasks-panel.js | 71 +++++++++++++++++++ src/renderer/index.html | 5 ++ src/renderer/index.js | 17 ++++- src/renderer/settings-window.html | 43 +++++++++++ src/renderer/settings-window.js | 22 +++++- src/renderer/styles/floating.css | 42 +++++++++++ src/shared/constants.js | 9 ++- 16 files changed, 350 insertions(+), 13 deletions(-) create mode 100644 src/renderer/components/tasks-panel.js diff --git a/packages/koe-core/src/providers/groq-helpers.ts b/packages/koe-core/src/providers/groq-helpers.ts index 0526341..f2938ca 100644 --- a/packages/koe-core/src/providers/groq-helpers.ts +++ b/packages/koe-core/src/providers/groq-helpers.ts @@ -3,29 +3,30 @@ import { DEFAULT_CUSTOM_PROMPT } from '../constants'; /** * Resolves the prompt to use for AI enhancement based on the selected style. */ -export function resolveEnhancementPrompt(promptStyle: string = 'Clean', customPrompt: string = ''): string { +export function resolveEnhancementPrompt(promptStyle: string = 'Clean', customPrompt: string = '', smartContext: string = ''): string { + const contextPrefix = smartContext ? `Context: ${smartContext}. ` : ''; const trimmedPrompt = customPrompt.trim(); if (trimmedPrompt) { return trimmedPrompt; } if (promptStyle === 'Professional' || promptStyle === 'Formal') { - return 'Refine this dictated text with a formal, professional tone. Keep the meaning intact, fix punctuation and grammar, remove filler only when it is clearly filler, never use em dashes, and do not add transcript tags or any other wrapper markup.'; + return contextPrefix + 'Refine this dictated text with a formal, professional tone. Keep the meaning intact, fix punctuation and grammar, remove filler only when it is clearly filler, never use em dashes, and do not add transcript tags or any other wrapper markup.'; } if (promptStyle === 'Casual') { - return 'Refine this dictated text so it stays casual and conversational. Keep the meaning intact, fix punctuation and grammar, remove filler only when it is clearly filler, never use em dashes, and do not add transcript tags or any other wrapper markup.'; + return contextPrefix + 'Refine this dictated text so it stays casual and conversational. Keep the meaning intact, fix punctuation and grammar, remove filler only when it is clearly filler, never use em dashes, and do not add transcript tags or any other wrapper markup.'; } if (promptStyle === 'Concise' || promptStyle === 'Bullets') { - return 'Refine this dictated text into a tighter version with less filler while keeping the original meaning. Remove filler words like um, uh, and obvious filler mistranscriptions like ohms only when they are clearly filler, not when they are literal or technical. Never use em dashes, and do not add transcript tags or any other wrapper markup.'; + return contextPrefix + 'Refine this dictated text into a tighter version with less filler while keeping the original meaning. Remove filler words like um, uh, and obvious filler mistranscriptions like ohms only when they are clearly filler, not when they are literal or technical. Never use em dashes, and do not add transcript tags or any other wrapper markup.'; } if (promptStyle === 'Meeting Notes' || promptStyle === 'Meeting') { - return 'Refine this meeting transcript into a structured set of meeting notes. Include a summary of the main points and a clear list of action items with owners if mentioned. Maintain a professional tone and ensure the output is concise and actionable.'; + return contextPrefix + 'Refine this meeting transcript into a structured set of meeting notes. Include a summary of the main points and a clear list of action items with owners if mentioned. Maintain a professional tone and ensure the output is concise and actionable.'; } - return DEFAULT_CUSTOM_PROMPT; + return contextPrefix + DEFAULT_CUSTOM_PROMPT; } export function parseErrorMessage(payload: any, fallback: string): string { diff --git a/src/main/ipc.js b/src/main/ipc.js index 1808fb7..d30d252 100644 --- a/src/main/ipc.js +++ b/src/main/ipc.js @@ -74,8 +74,25 @@ function setupIpcHandlers(mainWindow) { ipcMain.handle(CHANNELS.TEST_GROQ_KEY, async (event, apiKey) => validateApiKey(apiKey)); ipcMain.handle(CHANNELS.GET_USAGE_STATS, async () => rateLimiter.getUsageStats()); ipcMain.handle(CHANNELS.GET_HISTORY, async () => historyService.getHistory()); + ipcMain.handle(CHANNELS.SEARCH_HISTORY, async (event, query) => historyService.searchHistory(query)); ipcMain.handle(CHANNELS.CLEAR_HISTORY, async () => historyService.clearHistory()); + ipcMain.handle(CHANNELS.GET_TASKS, async () => { + const historyStore = new Store({ name: 'tasks-history' }); + return historyStore.get('tasks', []); + }); + + ipcMain.handle(CHANNELS.TOGGLE_TASK, async (event, taskId) => { + const historyStore = new Store({ name: 'tasks-history' }); + const tasks = historyStore.get('tasks', []); + const task = tasks.find(t => t.id === taskId); + if (task) { + task.completed = !task.completed; + historyStore.set('tasks', tasks); + } + return tasks; + }); + ipcMain.handle(CHANNELS.RETRY_HISTORY_ENTRY, async (event, entryId) => { return retryAndPasteTranscript(entryId, { beforePaste: hideSettingsBeforePaste diff --git a/src/main/preload.js b/src/main/preload.js index 520e87e..2fb6830 100644 --- a/src/main/preload.js +++ b/src/main/preload.js @@ -22,6 +22,11 @@ contextBridge.exposeInMainWorld('api', { ipcRenderer.on(CHANNELS.MEETING_DETECTED, () => callback()); }, + onAiInsight: (callback) => { + ipcRenderer.removeAllListeners(CHANNELS.AI_INSIGHT); + ipcRenderer.on(CHANNELS.AI_INSIGHT, (event, insight) => callback(insight)); + }, + // Settings getSettings: () => ipcRenderer.invoke(CHANNELS.GET_SETTINGS), saveSettings: (settings) => ipcRenderer.invoke(CHANNELS.SAVE_SETTINGS, settings), @@ -35,10 +40,13 @@ contextBridge.exposeInMainWorld('api', { // History getHistory: () => ipcRenderer.invoke(CHANNELS.GET_HISTORY), + searchHistory: (query) => ipcRenderer.invoke(CHANNELS.SEARCH_HISTORY, query), clearHistory: () => ipcRenderer.invoke(CHANNELS.CLEAR_HISTORY), retryHistoryEntry: (entryId) => ipcRenderer.invoke(CHANNELS.RETRY_HISTORY_ENTRY, entryId), retryLastTranscript: () => ipcRenderer.invoke(CHANNELS.RETRY_LAST_TRANSCRIPT), exportHistory: (format) => ipcRenderer.invoke('history:export', format), + getTasks: () => ipcRenderer.invoke(CHANNELS.GET_TASKS), + toggleTask: (taskId) => ipcRenderer.invoke(CHANNELS.TOGGLE_TASK, taskId), // Logs openLogsFolder: () => ipcRenderer.invoke('app:open-logs'), diff --git a/src/main/services/history.js b/src/main/services/history.js index 376b4ed..1b610b5 100644 --- a/src/main/services/history.js +++ b/src/main/services/history.js @@ -75,6 +75,17 @@ function getEntryRawText(entry) { return entry?.rawText || entry?.text || ''; } +function searchHistory(query) { + const entries = getHistory(); + if (!query) return entries; + + const lowerQuery = query.toLowerCase(); + return entries.filter(entry => + (entry.refinedText || '').toLowerCase().includes(lowerQuery) || + (entry.rawText || '').toLowerCase().includes(lowerQuery) + ); +} + function clearHistory() { getStore().set('entries', []); return []; @@ -86,5 +97,6 @@ module.exports = { getHistoryEntryById, getLatestEntry, getEntryRawText, + searchHistory, clearHistory }; diff --git a/src/main/services/transcription-session-manager.js b/src/main/services/transcription-session-manager.js index b620dd7..6ce293b 100644 --- a/src/main/services/transcription-session-manager.js +++ b/src/main/services/transcription-session-manager.js @@ -5,6 +5,8 @@ const { } = require('@koe/core'); const path = require('path'); const { Worker } = require('worker_threads'); +const Store = require('electron-store').default || require('electron-store'); +const { v4: uuidv4 } = require('uuid'); const { CHANNELS } = require('../../shared/constants'); const { getSettings } = require('./settings'); const { autoPaste, writeToClipboard } = require('./clipboard'); @@ -102,7 +104,8 @@ class TranscriptionSessionManager { customPrompt: settings.customPrompt || '', model: settings.model || 'whisper-large-v3-turbo', enhanceText: settings.enhanceText !== false, - autoPaste: settings.autoPaste !== false + autoPaste: settings.autoPaste !== false, + smartContext: settings.smartContext || '' }; return this.coordinator.createSession(sessionId, sessionSettings); @@ -253,6 +256,11 @@ class TranscriptionSessionManager { if (session.settings.promptStyle === 'Meeting Notes') { const settings = getSettings(); + const actionItems = this.extractActionItems(outputText); + if (actionItems.length > 0) { + this.saveActionItems(actionItems); + } + const notification = new Notification({ title: 'Koe - Meeting Summary Ready', body: outputText.slice(0, 100) + '...', @@ -482,6 +490,40 @@ class TranscriptionSessionManager { }; } + extractActionItems(text) { + // Basic extraction logic: looking for lines that start with - or * and seem like tasks + const lines = text.split('\n'); + const tasks = []; + let inActionItems = false; + + for (const line of lines) { + const trimmed = line.trim().toLowerCase(); + if (trimmed.includes('action items') || trimmed.includes('tasks') || trimmed.includes('to-do')) { + inActionItems = true; + continue; + } + + if (inActionItems && (line.trim().startsWith('-') || line.trim().startsWith('*'))) { + tasks.push(line.trim().substring(1).trim()); + } else if (inActionItems && line.trim() === '') { + inActionItems = false; + } + } + return tasks; + } + + saveActionItems(tasks) { + const historyStore = new Store({ name: 'tasks-history' }); + const existingTasks = historyStore.get('tasks', []); + const newTasks = tasks.map(task => ({ + id: uuidv4(), + task, + timestamp: Date.now(), + completed: false + })); + historyStore.set('tasks', [...newTasks, ...existingTasks]); + } + async retrySession(session, options = {}) { const retryableSegments = Array.from(session.segments.keys()) .sort((left, right) => left - right) diff --git a/src/main/services/transcription-worker.js b/src/main/services/transcription-worker.js index 1d9cf4c..44e03f6 100644 --- a/src/main/services/transcription-worker.js +++ b/src/main/services/transcription-worker.js @@ -137,7 +137,7 @@ async function refineText(rawText, options) { return ''; } - const stylePrompt = resolveEnhancementPrompt(options.promptStyle || 'Clean', options.customPrompt || ''); + const stylePrompt = resolveEnhancementPrompt(options.promptStyle || 'Clean', options.customPrompt || '', options.smartContext || ''); const systemPrompt = `${REFINEMENT_GUARDRAILS} ${stylePrompt} Before you finish, check the final text and remove any transcript tags if any remain.`.trim(); await waitForRequestSlot(); diff --git a/src/renderer/components/history-panel.js b/src/renderer/components/history-panel.js index e0d3415..c74c7ee 100644 --- a/src/renderer/components/history-panel.js +++ b/src/renderer/components/history-panel.js @@ -6,6 +6,7 @@ export class HistoryPanel { this.btnClear = document.getElementById('btn-clear-history'); this.btnCopyAll = document.getElementById('btn-copy-history'); this.btnExport = document.getElementById('btn-export-history'); + this.searchField = document.getElementById('history-search'); if (this.btnClose) { this.btnClose.addEventListener('click', () => this.hide()); @@ -22,6 +23,10 @@ export class HistoryPanel { if (this.btnExport) { this.btnExport.addEventListener('click', () => this.exportHistory()); } + + if (this.searchField) { + this.searchField.addEventListener('input', (e) => this.searchHistory(e.target.value)); + } } show() { @@ -39,13 +44,27 @@ export class HistoryPanel { if (!window.api || !window.api.getHistory) return; try { - const history = await window.api.getHistory(); + const query = this.searchField ? this.searchField.value : ''; + const history = query + ? await window.api.searchHistory(query) + : await window.api.getHistory(); this.renderHistory(history); } catch (error) { window.api.log(`Failed to load history: ${error.message}`); } } + async searchHistory(query) { + if (!window.api || !window.api.searchHistory) return; + + try { + const history = await window.api.searchHistory(query); + this.renderHistory(history); + } catch (error) { + window.api.log(`Failed to search history: ${error.message}`); + } + } + createTextSection(label, text, modifierClass = '') { const section = document.createElement('div'); section.className = `history-text-section ${modifierClass}`.trim(); diff --git a/src/renderer/components/pill-ui.js b/src/renderer/components/pill-ui.js index ce054d1..e69690b 100644 --- a/src/renderer/components/pill-ui.js +++ b/src/renderer/components/pill-ui.js @@ -20,6 +20,8 @@ export class PillUI { this.timer = document.getElementById('pill-timer'); this.progressBar = document.getElementById('pill-progress-bar'); this.visualizerBars = Array.from(document.querySelectorAll('.viz-bar')); + this.insightsOverlay = document.getElementById('insights-overlay'); + this.insightsContent = document.getElementById('insights-content'); this.detailStreamTimer = null; this.detailSourceText = ''; this.detailQueuedWords = []; @@ -138,6 +140,26 @@ export class PillUI { } } + setInsights(insight = '') { + if (!this.insightsOverlay || !this.insightsContent) return; + + if (!insight) { + this.insightsOverlay.classList.remove('has-content'); + return; + } + + this.insightsContent.innerHTML = ` +
No action items extracted yet.
+