8 Commits

Author SHA1 Message Date
michael.borak
31f59ba4a2 chore: bump version to 1.1.1 2026-01-21 13:24:06 +01:00
michael.borak
b848154942 feat: Adjust VAD sensitivity, enable global auto-stop, update docs 2026-01-21 11:09:54 +01:00
michael.borak
79db6adf45 feat: v1.1.0 - Long meeting support, email in history, custom logo
- Add MP3 conversion and chunking for files >18MB
- Support meetings up to 2+ hours
- Add email button directly in history view
- Implement custom logo upload in Settings for white-labeling
- Add read_image_as_base64 Rust command
- Update README with new features and ffmpeg requirement
2026-01-21 10:14:16 +01:00
michael.borak
a06e473e85 Release 1.1.0: Add Import Audio Files feature
- New Import tab with drag-and-drop support for audio files
- Support for 8 formats: MP3, MP4, WAV, M4A, FLAC, OGG, AAC, WMA
- File metadata display (duration, size, format)
- Editable meeting titles
- Progress tracking with visual indicators
- Smart template selection
- Auto-navigation to Transcription view
- Updated README with BlackHole requirement and Teams config
- Added get_audio_metadata Rust command
- Version bump to 1.1.0
2026-01-21 09:08:56 +01:00
michael.borak
79f509951c Release 1.1: Pre-meeting models, Calendar improvements, Logs, Compact Recorder 2026-01-20 17:15:14 +01:00
michael.borak
f61bcf1cc3 docs: update README with latest features 2026-01-20 15:02:00 +01:00
michael.borak
52ccd7ee03 feat: complete history, attendees list, and smart templates 2026-01-20 15:00:56 +01:00
michael.borak
d266de942a docs: add troubleshooting for app damaged error 2026-01-20 10:22:29 +01:00
24 changed files with 5359 additions and 672 deletions

138
README.md
View File

@@ -7,22 +7,37 @@
## ✨ Features
* **🎙️ Dual-Channel Recording**: seamlessly capture your voice and meeting audio from apps like Microsoft Teams, Zoom, or Google Meet.
* **📁 Import Audio Files**: Upload existing recordings (MP3, MP4, WAV, M4A, FLAC, OGG, AAC, WMA) for transcription and summarization.
* **⏱️ Long Meeting Support**: Record meetings up to 2+ hours with automatic MP3 conversion and chunking.
* **🎵 Smart Auto-Stop**:
* **Universal Auto-Stop**: Automatically stops recording after **20 seconds of silence** in ALL modes (Voice Memo & Meeting).
* **Noise Filtering**: Enhanced VAD (Voice Activity Detection) ignores background noise and keyboard typing, only triggering on clear speech.
* **📅 Microsoft 365 Integration**:
* **Upcoming Meetings**: View your daily schedule and join with **one click**.
* **Meeting Details**: View full agenda and **invited attendee status** (Accepted/Declined).
* **💾 Persistent History**: Automatically saves all transcripts and summaries to disk. Search and review past meetings anytime.
* **✉️ Email Summaries**: Send professional, formatted HTML summaries (with preview) directly to attendees via your own SMTP server.
* **🧠 Powered by Infomaniak AI**:
* **Precision Transcription**: Standard-compliant formatting with **second-by-second timestamps** (e.g., `[00:12]`).
* **Smart Summaries**: Uses advanced LLMs (Mixtral, Llama 3) to create actionable meeting notes.
* **📝 Professional Templates**: Comes with 3 built-in expert prompts:
* **Meeting Protocol**: For general business meetings.
* **1:1 / Jour Fixe**: For confidential personnel discussions.
* **Client Meeting**: For official, client-ready documentation.
* **💾 Auto-Save**: All recordings and summaries are saved locally to your history.
* **Precision Transcription**: Standard-compliant formatting with **second-by-second timestamps**.
* **Smart Summaries**: Uses **Smart Templates** to automatically select the best format (Business Protocol vs. 1:1) based on meeting content.
* **🔇 Smart VAD**: Automatically filters out silence and stops recording when you stop talking.
* **🎨 White-Labeling**: Upload your **custom company logo** in Settings to brand the application.
* **🔒 Privacy-First**: Data is processed securely via your own Infomaniak API keys.
---
## 🚀 Getting Started
### 1. Prerequisites
* **macOS** (Apple Silicon or Intel).
### Required
* **macOS** (tested on macOS Monterey and later)
* **BlackHole 2ch Driver** ([Download here](https://existential.audio/blackhole/))
* **MANDATORY** for system audio capture (MS Teams, Zoom, etc.)
* Without this, you can only record microphone input
* **ffmpeg** for audio processing
```bash
brew install ffmpeg
```
* **Infomaniak AI Account**: You need an API Key and Product ID from the [Infomaniak Developer Portal](https://manager.infomaniak.com/).
### 2. Installation
@@ -34,25 +49,21 @@
## 🎧 Recording System Audio (Teams, Zoom, etc.)
To record clear meeting audio from other applications, you need a "virtual cable". We recommend **BlackHole 2ch**.
We've made this easy! **Note: You must have the BlackHole driver installed.**
1. **Install BlackHole**: Download and install [BlackHole 2ch](https://existential.audio/blackhole/).
2. **Create a Multi-Output Device** (So you can hear the audio too!):
* Open **Audio MIDI Setup** on your Mac.
* Create a "Multi-Output Device".
* Select both **BlackHole 2ch** AND your **Headphones/Speakers**.
* *Tip: Use this Multi-Output Device as your SPEAKER in Teams/Zoom.*
![Multi-Output Device Setup](docs/screenshots/multi_output_setup.png)
1. **Create "Hearbit Audio" Device**:
* Open the app and select **Meeting** mode.
* If you don't have the device yet, click the **"🪄 Create Hearbit Audio Device"** button.
* This creates a specialized "Multi-Output Device" that routes audio to both your headphones/speakers AND the app.
3. **Create Aggregate Device (Optional)**:
* If you want to record BOTH your Microphone and System Audio, create an **Aggregate Device**.
* Select **BlackHole 2ch** AND your **Microphone**.
![Aggregate Device Setup](docs/screenshots/aggregate_device_setup.png)
2. **Configure Teams / Zoom / Webex**:
* **Speaker / Output**: Change this to **Hearbit Audio**.
* *Why?* This ensures the audio goes to the recording app *and* your ears.
* **Microphone / Input**: Leave this as your normal microphone (e.g., MacBook Pro Mic).
* *Note:* Do **not** select Hearbit Audio as your microphone in Teams.
4. **Select Input in Hearbit AI**:
* In Hearbit AI, select **BlackHole 2ch** (or your new Aggregate Device) as the **Input Device**.
3. **Start Recording**:
* In Hearbit AI, ensure **Hearbit Audio** is selected as the input.
---
@@ -61,20 +72,91 @@ To record clear meeting audio from other applications, you need a "virtual cable
1. **Configuration**:
* Click the **Settings** (gear icon).
* Enter your **Infomaniak API Key** and **Product ID**.
* (Optional) Customize where recordings are saved.
2. **Recording**:
2. **Connect M365 (Optional)**:
* Copy the **Application (client) ID**.
* Click the **Meetings** tab.
* Enter your **Client ID** and click "Connect".
* Proceed with MS login.
* View your upcoming meetings.
3. **Recording**:
* Choose your **Template** (e.g., "Meeting Protocol").
* Select your **Input Device**.
* Click **Start Recording**.
3. **Processing**:
4. **Processing**:
* Click **Stop** when finished.
* The app will transcribe the audio (with timestamps!) and generate a summary based on your selected template.
* You will be automatically taken to the **Transcription** tab to review the results.
---
## 🎨 Custom Branding (White-Labeling)
You can replace the default Livtec logo with your own company branding:
1. Go to **Settings** (gear icon) → **Branding**.
2. Click **Upload Logo**.
3. Select your file (PNG, JPG, SVG).
4. The content changes immediately across the app.
5. *Tip*: Use a transparent PNG for best results.
---
## 📧 Advanced Email Templates
The email system supports **full HTML & JavaScript** templates. This allows for dynamic dashboards, charts, and interactive reports.
**How to use:**
1. Go to **Settings** → **Email**.
2. Create a new template.
3. Use `{{summary}}` as a placeholder for the raw AI JSON output.
4. In your HTML/Script, parse it:
```javascript
const reportData = {{summary}};
// Now you can use reportData.todos, reportData.updates, etc.
```
5. Use `{{date}}` for the current date and `{{subject}}` for the meeting title.
*Example*: Create a "Daily Standup Dashboard" that visualizes Blocker/Updates/Todos in a grid layout.
---
## ❓ Troubleshooting
### "Hearbit AI is damaged and can't be opened"
This is a standard macOS warning for apps not signed with an Apple Developer Certificate. To fix it:
1. Open **Terminal**.
2. Run the following command:
```bash
sudo xattr -cr /Applications/Hearbit\ AI.app
```
3. Enter your password.
4. Open the app again.
### Long Meetings (> 1 hour)
**Automatic Handling**: The app automatically handles long recordings:
- **MP3 Conversion**: All recordings are converted to MP3 (64kbps) for 10x compression
- **Chunking**: Files ≥18 MB are automatically split into 10-minute segments
- **Processing**: Each segment is transcribed separately and merged with timestamps
**Example**: A 2-hour meeting:
1. Records as WAV (~120 MB)
2. Converts to MP3 (~12 MB)
3. Stays under limit → No chunking needed!
**Very long meetings** (e.g., all-day workshops):
- Automatically chunks into segments
- Shows progress: "Processing chunk 1/15..."
- Merges all transcriptions seamlessly
### No Audio / Can't Hear Meeting Participants
---
## 👨‍💻 Development
Built with **Tauri**, **React**, and **TypeScript**.

81
RELEASE_NOTES_1.1.0.md Normal file
View File

@@ -0,0 +1,81 @@
# Release Notes - Version 1.1.0
**Release Date**: January 21, 2026
## 🎉 What's New
### Import Audio Files Feature
We've added a powerful new **Import** tab that allows you to upload and process existing audio/video files!
**Key Features:**
- **Drag-and-Drop Upload**: Simply drag your audio files into the app
- **8 Format Support**: MP3, MP4, WAV, M4A, FLAC, OGG, AAC, WMA
- **Smart Metadata Display**: See file duration, size, and format before processing
- **Editable Meeting Titles**: Customize the name (defaults to filename)
- **Progress Tracking**: Visual indicators for each stage (Validating → Transcribing → Summarizing)
- **Same AI Power**: Uses the same AI templates and Smart Select as live recordings
- **Auto-Navigation**: Seamlessly transition to Transcription view when complete
**Use Cases:**
- Process pre-recorded meetings you forgot to record live
- Batch process voice memos
- Import recordings from other devices
- Archive and transcribe old meeting recordings
---
## 📝 Documentation Updates
### README Enhancements
- Added mandatory **BlackHole 2ch Driver** requirement to Prerequisites
- Clarified **Teams/Zoom configuration** (Speaker vs. Microphone settings)
- Added detailed setup instructions for meeting audio capture
---
## 🔧 Technical Improvements
- Added `get_audio_metadata` Rust command for file metadata extraction
- Improved tab navigation with new Import tab
- Enhanced error handling for file validation
- Code optimizations and cleanup
---
## 📦 Installation
Download the DMG file:
```
Hearbit_AI_1.1.0_aarch64.dmg
```
**Location**: `src-tauri/target/release/bundle/dmg/`
### First-time Installation
If you see "Hearbit AI is damaged and can't be opened":
```bash
sudo xattr -cr /Applications/Hearbit\ AI.app
```
---
## 🐛 Known Issues
None reported for this release.
---
## 🙏 Credits
Built with ❤️ by the Livtec team using Tauri, React, and TypeScript.
---
## What's Next?
Potential future enhancements:
- Meeting auto-stop when meeting ends (via M365 API)
- Batch file import
- Audio preview player
- More audio format conversions

10
package-lock.json generated
View File

@@ -11,6 +11,7 @@
"@tailwindcss/postcss": "^4.1.18",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-fs": "^2.4.5",
"@tauri-apps/plugin-opener": "^2",
"jimp": "^1.6.0",
"lucide-react": "^0.562.0",
@@ -2086,6 +2087,15 @@
"@tauri-apps/api": "^2.8.0"
}
},
"node_modules/@tauri-apps/plugin-fs": {
"version": "2.4.5",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-fs/-/plugin-fs-2.4.5.tgz",
"integrity": "sha512-dVxWWGE6VrOxC7/jlhyE+ON/Cc2REJlM35R3PJX3UvFw2XwYhLGQVAIyrehenDdKjotipjYEVc4YjOl3qq90fA==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.8.0"
}
},
"node_modules/@tauri-apps/plugin-opener": {
"version": "2.5.3",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.3.tgz",

View File

@@ -1,7 +1,7 @@
{
"name": "hearbit-ai",
"private": true,
"version": "0.1.0",
"version": "1.1.1",
"type": "module",
"scripts": {
"dev": "vite",
@@ -13,6 +13,7 @@
"@tailwindcss/postcss": "^4.1.18",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-fs": "^2.4.5",
"@tauri-apps/plugin-opener": "^2",
"jimp": "^1.6.0",
"lucide-react": "^0.562.0",
@@ -32,4 +33,4 @@
"typescript": "~5.8.3",
"vite": "^7.0.4"
}
}
}

1574
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package]
name = "hearbit-ai"
version = "0.1.0"
version = "0.1.2"
description = "A Tauri App"
authors = ["you"]
edition = "2021"
@@ -18,7 +18,7 @@ crate-type = ["staticlib", "cdylib", "rlib"]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri = { version = "2", features = ["tray-icon"] }
tauri-plugin-opener = "2"
tauri-plugin-dialog = "2"
serde = { version = "1", features = ["derive"] }
@@ -26,5 +26,15 @@ serde_json = "1.0"
chrono = "0.4"
cpal = "0.17.1"
hound = "3.5.1"
reqwest = { version = "0.13.1", features = ["json", "multipart"] }
reqwest = { version = "0.11", features = ["json", "multipart"] }
tokio = { version = "1.40.0", features = ["full"] }
tauri-plugin-fs = "2.4.5"
voice_activity_detector = "0.2.1"
rubato = "0.14.1"
tauri-plugin-oauth = "2.0.0"
oauth2 = "4.4"
url = "2.5"
lettre = { version = "0.11", features = ["tokio1", "tokio1-native-tls", "builder"] }
tauri-plugin-log = "2.0.0"
tauri-plugin-shell = "2.3.4"
base64 = "0.22"

View File

@@ -8,6 +8,7 @@
"permissions": [
"core:default",
"opener:default",
"dialog:default"
"dialog:default",
"fs:default"
]
}

View File

@@ -0,0 +1,191 @@
#!/usr/bin/env swift
import Foundation
import CoreAudio
// Extensions and Helpers
extension Int32 {
var fourCC: String {
let utf16 = [
UInt16((self >> 24) & 0xFF),
UInt16((self >> 16) & 0xFF),
UInt16((self >> 8) & 0xFF),
UInt16(self & 0xFF)
]
return String(utf16CodeUnits: utf16, count: 4)
}
}
// Safer Property Getter
func getPropertyData<T>(objectID: AudioObjectID, selector: AudioObjectPropertySelector, initialValue: T) -> T? {
var address = AudioObjectPropertyAddress(
mSelector: selector,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain
)
var size = UInt32(MemoryLayout<T>.size)
var value = initialValue
let status = AudioObjectGetPropertyData(objectID, &address, 0, nil, &size, &value)
if status == noErr {
return value
}
return nil
}
// CFString Helper
func getStringProperty(objectID: AudioObjectID, selector: AudioObjectPropertySelector) -> String? {
var address = AudioObjectPropertyAddress(
mSelector: selector,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain
)
// CFStringRef is just a pointer, so size of Optional<Unmanaged<CFString>> is pointer size
var size = UInt32(MemoryLayout<Unmanaged<CFString>?>.size)
var value: Unmanaged<CFString>?
let status = AudioObjectGetPropertyData(objectID, &address, 0, nil, &size, &value)
if status == noErr, let existingValue = value {
return existingValue.takeRetainedValue() as String
}
return nil
}
func findDeviceByName(_ name: String) -> AudioObjectID? {
// System Object is 1
let systemID = AudioObjectID(kAudioObjectSystemObject)
// Get all devices
var address = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDevices,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain
)
var size: UInt32 = 0
guard AudioObjectGetPropertyDataSize(systemID, &address, 0, nil, &size) == noErr else { return nil }
let count = Int(size) / MemoryLayout<AudioObjectID>.size
var deviceIDs = [AudioObjectID](repeating: 0, count: count)
guard AudioObjectGetPropertyData(systemID, &address, 0, nil, &size, &deviceIDs) == noErr else { return nil }
for id in deviceIDs {
if let devName = getStringProperty(objectID: id, selector: kAudioDevicePropertyDeviceNameCFString) {
if devName == name {
return id
}
}
}
return nil
}
func findDeviceByUID(_ uid: String) -> AudioObjectID? {
let systemID = AudioObjectID(kAudioObjectSystemObject)
var address = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDevices,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain
)
var size: UInt32 = 0
guard AudioObjectGetPropertyDataSize(systemID, &address, 0, nil, &size) == noErr else { return nil }
let count = Int(size) / MemoryLayout<AudioObjectID>.size
var deviceIDs = [AudioObjectID](repeating: 0, count: count)
guard AudioObjectGetPropertyData(systemID, &address, 0, nil, &size, &deviceIDs) == noErr else { return nil }
for id in deviceIDs {
if let devUID = getStringProperty(objectID: id, selector: kAudioDevicePropertyDeviceUID) {
if devUID == uid {
return id
}
}
}
return nil
}
func createAggregateDevice() {
print("Searching for devices...")
guard let blackHoleID = findDeviceByName("BlackHole 2ch") else {
print("Error: BlackHole 2ch not found. Please install it first.")
exit(1)
}
print("Found BlackHole 2ch (ID: \(blackHoleID))")
// --- PART 1: Hearbit Audio (Input: Mic + BlackHole) ---
print("\n--- Creating 'Hearbit Audio' (Input) ---")
// Default Input
var defaultInputID: AudioObjectID = 0
var size = UInt32(MemoryLayout<AudioObjectID>.size)
var address = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDefaultInputDevice,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain
)
if AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject), &address, 0, nil, &size, &defaultInputID) != noErr {
print("Error: Could not find default input.")
exit(1)
}
print("Found Default Input (ID: \(defaultInputID))")
// Check for existing "Hearbit Audio"
let inputUID = "hearbit_audio_aggregate_v1"
if let existingID = findDeviceByUID(inputUID) {
print("Found existing Hearbit Audio (ID: \(existingID)). Destroying...")
AudioHardwareDestroyAggregateDevice(existingID)
Thread.sleep(forTimeInterval: 0.5)
}
guard let bhUID = getStringProperty(objectID: blackHoleID, selector: kAudioDevicePropertyDeviceUID) else {
print("Error: Could not get BlackHole UID.")
exit(1)
}
guard let micUID = getStringProperty(objectID: defaultInputID, selector: kAudioDevicePropertyDeviceUID) else {
print("Error: Could not get Default Input UID.")
exit(1)
}
var subDevicesUIDs = [bhUID]
if micUID != bhUID {
subDevicesUIDs.append(micUID)
}
let subDevicesArray = subDevicesUIDs.map { [kAudioSubDeviceUIDKey: $0] }
let inputDesc: [String: Any] = [
kAudioAggregateDeviceNameKey: "Hearbit Audio",
kAudioAggregateDeviceUIDKey: inputUID,
kAudioAggregateDeviceIsPrivateKey: Int(0),
kAudioAggregateDeviceIsStackedKey: Int(0),
kAudioAggregateDeviceSubDeviceListKey: subDevicesArray
]
var outInputID: AudioObjectID = 0
let errIn = AudioHardwareCreateAggregateDevice(inputDesc as CFDictionary, &outInputID)
if errIn == noErr {
print("Success! Created 'Hearbit Audio' with ID: \(outInputID)")
} else {
print("Failed to create 'Hearbit Audio'. Error: \(errIn)")
}
// --- PART 2: Cleanup Unstable "Hearbit Speakers" ---
// The previous "Hearbit Speakers" device caused MS Teams to crash.
// We strictly remove it here to restore stability.
print("\n--- Cleaning up Unstable Devices ---")
let stopOutputUID = "hearbit_speakers_aggregate_v1"
if let existingOutID = findDeviceByUID(stopOutputUID) {
print("Found unstable 'Hearbit Speakers' (ID: \(existingOutID)). Removing to fix Teams crash...")
let errDist = AudioHardwareDestroyAggregateDevice(existingOutID)
if errDist == noErr {
print("Successfully removed unstable device.")
} else {
print("Warning: Failed to remove device. Error: \(errDist)")
}
} else {
print("No unstable 'Hearbit Speakers' found. System is clean.")
}
exit(0)
}
createAggregateDevice()

View File

@@ -0,0 +1,242 @@
use std::sync::{Arc, Mutex};
use tauri::{AppHandle, Emitter};
use cpal::Sample;
use hound::WavWriter;
use rubato::{Resampler, FastFixedIn, PolynomialDegree};
use voice_activity_detector::VoiceActivityDetector;
pub struct AudioProcessor {
// VAD
vad: VoiceActivityDetector,
vad_chunk_size: usize,
vad_buffer: Vec<f32>,
// Audio Config
channel_count: u16,
// Resampler
resampler: FastFixedIn<f32>,
resample_input_buffer: Vec<f32>,
resample_output_buffer: Vec<f32>,
// State
is_speech_active: bool,
last_speech_time: u64, // In samples or frames
hangover_samples: u64,
// Waiting Mode
waiting_for_speech: bool,
// Ring Buffer (for pre-roll)
ring_buffer: Vec<f32>,
ring_pos: usize,
ring_size: usize,
// Output
writer: Arc<Mutex<WavWriter<std::io::BufWriter<std::fs::File>>>>,
sample_rate: u32,
total_processed_samples: u64,
// Event Emission
app_handle: Option<AppHandle>,
last_event_time: std::time::Instant,
}
impl AudioProcessor {
pub fn new(
sample_rate: u32,
channel_count: u16,
writer: Arc<Mutex<WavWriter<std::io::BufWriter<std::fs::File>>>>,
app_handle: AppHandle,
wait_for_speech: bool
) -> Result<Self, String> {
let vad_sample_rate = 16000;
let vad_chunk_size = 512;
// Initialize VAD
let vad = VoiceActivityDetector::builder()
.sample_rate(vad_sample_rate as u32)
.chunk_size(vad_chunk_size)
.build()
.map_err(|e| format!("Failed to init VAD: {:?}", e))?;
// Initialize Resampler (Input Rate -> 16000)
let resampler = FastFixedIn::<f32>::new(
16000.0 / sample_rate as f64,
1.0,
PolynomialDegree::Septic,
1024,
1
).map_err(|e| format!("Failed to init Resampler: {:?}", e))?;
// Pre-roll buffer (1.0 seconds) * Channels (interleaved store)
let ring_curr_seconds = 1.0;
// WavWriter writes interleaved, so we store interleaved.
let ring_size = (sample_rate as f32 * ring_curr_seconds) as usize * channel_count as usize;
Ok(Self {
vad,
vad_chunk_size,
vad_buffer: Vec::new(),
channel_count,
resampler,
resample_input_buffer: Vec::new(),
resample_output_buffer: Vec::new(),
is_speech_active: false,
last_speech_time: 0,
// Hangover counts "processed samples" which are actually frames * channels in current logic?
// Actually total_processed_samples usually counts FRAMES in audio terminology, but here we count elements.
// Let's stick to elements to match existing logic logic.
hangover_samples: (sample_rate as f32 * 1.5 * channel_count as f32) as u64,
waiting_for_speech: wait_for_speech,
ring_buffer: vec![0.0; ring_size],
ring_pos: 0,
ring_size,
writer,
sample_rate,
total_processed_samples: 0,
app_handle: Some(app_handle),
last_event_time: std::time::Instant::now(),
})
}
pub fn process(&mut self, data: &[f32]) {
// 1. Add to Ring Buffer (Interleaved data - Record EVERYTHING)
for &sample in data {
self.ring_buffer[self.ring_pos] = sample;
self.ring_pos = (self.ring_pos + 1) % self.ring_size;
}
// 2. Prepare VAD Signal (Mono Mixdown)
// FRESH START LOGIC (v0.2.0):
// We expect standard Stereo Input (BlackHole 2ch).
// No magic 3-channel aggregate.
let channels = self.channel_count as usize;
let frame_count = data.len() / channels;
let mut vad_input_chunk = Vec::with_capacity(frame_count);
for i in 0..frame_count {
let frame_start = i * channels;
let mix_sample = if channels >= 2 {
// Stereo -> Average L + R
(data[frame_start] + data[frame_start + 1]) / 2.0
} else {
// Mono -> Take as is
data[frame_start]
};
vad_input_chunk.push(mix_sample);
}
// 3. Resample for VAD
self.resample_input_buffer.extend_from_slice(&vad_input_chunk);
let needed = self.resampler.input_frames_next();
while self.resample_input_buffer.len() >= needed {
let chunk: Vec<f32> = self.resample_input_buffer.drain(0..needed).collect();
// Resample (mono)
let waves_in = vec![chunk];
// Allocate output (approx)
let mut waves_out = vec![vec![0.0; (needed as f64 * (16000.0 / self.sample_rate as f64)).ceil() as usize + 10]; 1]; // +10 padding
if let Ok((_in_len, out_len)) = self.resampler.process_into_buffer(&waves_in, &mut waves_out, None) {
if out_len > 0 {
self.vad_buffer.extend_from_slice(&waves_out[0][0..out_len]);
}
}
// Update output buffer usage... logic is tricky with drain.
}
// 4. Process VAD
while self.vad_buffer.len() >= self.vad_chunk_size {
let vad_chunk: Vec<f32> = self.vad_buffer.drain(0..self.vad_chunk_size).collect();
// Run Detection
let probability = self.vad.predict(vad_chunk.clone());
// Calculate RMS for this chunk to use as fallback/hybrid detection
let sq_sum: f32 = vad_chunk.iter().map(|x| x * x).sum();
let rms = (sq_sum / vad_chunk.len() as f32).sqrt();
// Hybrid VAD: Probability > 0.9 OR RMS > 0.025
// INCREASED THRESHOLDS (v1.1.1):
// Reduced sensitivity to avoid background noise triggering recording.
let is_speech = probability > 0.9 || rms > 0.025;
if is_speech {
self.is_speech_active = true;
self.last_speech_time = self.total_processed_samples;
}
// Emit VAD event periodically (every 500ms is enough for non-diagnostic mode)
if self.last_event_time.elapsed().as_millis() > 500 {
if let Some(app) = &self.app_handle {
#[derive(Clone, serde::Serialize)]
struct VadEvent {
is_speech: bool,
probability: f32,
}
let _ = app.emit("vad-event", VadEvent {
probability,
is_speech: self.is_speech_active,
});
}
self.last_event_time = std::time::Instant::now();
// IMPORTANT: We reset is_speech_active after emitting,
// so we don't latch it forever if the user stops talking.
// However, the main loop sets it to true if current chunk is speech.
// This logic is a bit of a "latch for X ms".
self.is_speech_active = false;
}
}
// 4. Update Hangover and Check Write condition
if self.waiting_for_speech {
if self.is_speech_active {
// Trigger Detected!
println!("Auto-Start: Speech detected. Flushing pre-roll...");
self.waiting_for_speech = false;
// Flush Ring Buffer (Orderly: from ring_pos to end, then 0 to ring_pos)
let mut guard = self.writer.lock().unwrap();
let amplitude = i16::MAX as f32;
// Part 1: ring_pos to end
for i in self.ring_pos..self.ring_size {
let sample = self.ring_buffer[i];
guard.write_sample((sample * amplitude) as i16).ok();
}
// Part 2: 0 to ring_pos
for i in 0..self.ring_pos {
let sample = self.ring_buffer[i];
guard.write_sample((sample * amplitude) as i16).ok();
}
// Emit event to notify frontend that "real" recording started
if let Some(app) = &self.app_handle {
let _ = app.emit("auto-recording-triggered", ());
}
} else {
// Still waiting, do not write to file.
return;
}
}
// Standard Recording Logic (Active or Hangover)
let time_since_speech = self.total_processed_samples.saturating_sub(self.last_speech_time);
if self.is_speech_active || time_since_speech < self.hangover_samples {
let mut guard = self.writer.lock().unwrap();
for &sample in data {
let amplitude = i16::MAX as f32;
guard.write_sample((sample * amplitude) as i16).ok();
}
}
self.total_processed_samples += data.len() as u64;
}
}

112
src-tauri/src/auth.rs Normal file
View File

@@ -0,0 +1,112 @@
use tauri::{AppHandle, Runtime};
use tauri_plugin_opener::OpenerExt;
use tauri_plugin_oauth::start;
use url::Url;
use oauth2::{
basic::BasicClient, AuthUrl, ClientId, CsrfToken, PkceCodeChallenge, RedirectUrl, Scope,
TokenResponse, TokenUrl,
};
use oauth2::reqwest::async_http_client;
// const CLIENT_ID: &str = "YOUR_CLIENT_ID_HERE";
const AUTH_URL: &str = "https://login.microsoftonline.com/common/oauth2/v2.0/authorize";
const TOKEN_URL: &str = "https://login.microsoftonline.com/common/oauth2/v2.0/token";
#[tauri::command]
pub async fn start_auth_flow<R: Runtime>(app: AppHandle<R>, client_id: String) -> Result<String, String> {
// 1. Start Localhost Server
let (tx, rx) = std::sync::mpsc::channel();
// tauri-plugin-oauth start() returns a port and stops server when callback received
let port = start(move |url| {
// Ignore favicon requests to avoid triggering early
if url.contains("favicon.ico") {
return;
}
let _ = tx.send(url);
})
.map_err(|e| format!("Failed to start oauth server: {}", e))?;
let redirect_uri = format!("http://localhost:{}/auth/callback", port);
// 2. Setup OAuth Client
let client = BasicClient::new(
ClientId::new(client_id),
None, // No client secret for PKCE public client
AuthUrl::new(AUTH_URL.to_string()).map_err(|e| e.to_string())?,
Some(TokenUrl::new(TOKEN_URL.to_string()).map_err(|e| e.to_string())?),
)
.set_redirect_uri(RedirectUrl::new(redirect_uri.clone()).map_err(|e| e.to_string())?);
// 3. Generate PKCE Challenge
let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
// 4. Generate Auth URL
let (auth_url, _csrf_token) = client
.authorize_url(CsrfToken::new_random)
.add_scope(Scope::new("User.Read".to_string()))
.add_scope(Scope::new("Calendars.Read".to_string()))
.set_pkce_challenge(pkce_challenge)
.url();
// 5. Open Browser
app.opener().open_url(auth_url.as_str(), None::<&str>)
.map_err(|e| format!("Failed to open browser: {}", e))?;
// 6. Wait for Callback
let received_url_str = rx.recv().map_err(|e| format!("Failed to receive auth code: {}", e))?;
// 7. Parse Code from URL
// Actually we need to parse the query params from received_url_str
let parsed_url = Url::parse(&received_url_str).map_err(|e| e.to_string())?;
let pairs: std::collections::HashMap<_, _> = parsed_url.query_pairs().into_owned().collect();
if let Some(err) = pairs.get("error") {
let desc = pairs.get("error_description").map(|s| s.as_str()).unwrap_or("No description");
return Err(format!("OAuth Error: {} ({})", err, desc));
}
let code = pairs.get("code").ok_or_else(|| format!("No code in redirect callback. Received URL: {}", received_url_str))?;
// 8. Exchange Code for Token
let token_result = client
.exchange_code(oauth2::AuthorizationCode::new(code.clone()))
.set_pkce_verifier(pkce_verifier)
.request_async(async_http_client)
.await
.map_err(|e| format!("Failed to exchange token: {}", e))?;
let access_token = token_result.access_token().secret();
// Save token? Or just return it.
// Ideally we save it in key storage, but for MVP return it.
Ok(access_token.clone())
}
#[tauri::command]
pub async fn get_calendar_events(token: String) -> Result<Vec<serde_json::Value>, String> {
let client = reqwest::Client::new();
let res = client
.get("https://graph.microsoft.com/v1.0/me/calendarView")
.bearer_auth(token)
.query(&[
("startDateTime", chrono::Utc::now().to_rfc3339()),
("endDateTime", (chrono::Utc::now() + chrono::Duration::days(7)).to_rfc3339()),
("$select", "id,subject,start,end,location,onlineMeeting,bodyPreview,body,attendees".to_string())
])
.header("Prefer", "outlook.timezone=\"UTC\"")
.send()
.await
.map_err(|e| e.to_string())?
.json::<serde_json::Value>()
.await
.map_err(|e| e.to_string())?;
// Extract 'value' array
if let Some(events) = res.get("value").and_then(|v| v.as_array()) {
Ok(events.clone())
} else {
Ok(vec![])
}
}

96
src-tauri/src/email.rs Normal file
View File

@@ -0,0 +1,96 @@
use tauri::AppHandle;
use lettre::{Message, SmtpTransport, Transport, AsyncTransport};
use lettre::transport::smtp::authentication::Credentials;
use lettre::transport::smtp::AsyncSmtpTransport;
use lettre::Tokio1Executor;
#[derive(serde::Deserialize)]
pub struct SmtpConfig {
host: String,
port: u16,
username: String,
password: String,
sender_email: String,
sender_name: Option<String>,
}
#[derive(serde::Deserialize)]
pub struct EmailMessage {
to: Vec<String>,
subject: String,
body_html: String,
}
#[tauri::command]
pub async fn send_smtp_email(app: AppHandle, config: SmtpConfig, message: EmailMessage) -> Result<String, String> {
println!("SMTP: Preparing to send email to {:?}", message.to);
// 1. Build Message
let sender_str = if let Some(name) = &config.sender_name {
format!("{} <{}>", name, config.sender_email)
} else {
config.sender_email.clone()
};
let mut builder = Message::builder()
.from(sender_str.parse().map_err(|e: lettre::address::AddressError| e.to_string())?)
.subject(message.subject);
for recipient in message.to {
builder = builder.to(recipient.parse().map_err(|e: lettre::address::AddressError| e.to_string())?);
}
let email = builder
.header(lettre::message::header::ContentType::TEXT_HTML)
.body(message.body_html)
.map_err(|e| e.to_string())?;
// 2. Build Transport
println!("SMTP: Connecting to {}:{}...", config.host, config.port);
let creds = Credentials::new(config.username, config.password);
// TLS Configuration
// Proton Mail and others on 465 require Implicit TLS (Wrapper).
// Others on 587 use STARTTLS (Opportunistic).
use lettre::transport::smtp::client::{Tls, TlsParameters};
let tls_params = TlsParameters::builder(config.host.clone())
.build()
.map_err(|e| format!("TLS Params error: {}", e))?;
let builder = AsyncSmtpTransport::<Tokio1Executor>::relay(&config.host)
.map_err(|e| e.to_string())?
.port(config.port)
.credentials(creds);
let mailer: AsyncSmtpTransport<Tokio1Executor> = if config.port == 465 {
println!("SMTP: Using Implicit TLS (Wrapper) for port 465");
builder.tls(Tls::Wrapper(tls_params)).build()
} else {
println!("SMTP: Using Opportunistic TLS (STARTTLS) for port {}", config.port);
builder.tls(Tls::Opportunistic(tls_params)).build()
};
// 3. Send with Timeout
println!("SMTP: Sending...");
let send_task = mailer.send(email);
match tokio::time::timeout(std::time::Duration::from_secs(15), send_task).await {
Ok(result) => match result {
Ok(_) => {
println!("SMTP: Success!");
Ok("Email sent successfully".to_string())
},
Err(e) => {
println!("SMTP: Failed: {}", e);
// Hint at common issues
Err(format!("Failed to send: {}. (Check Host/Port/Password)", e))
}
},
Err(_) => {
println!("SMTP: Timeout");
Err("Connection timed out after 15s. Try changing Port (465 vs 587) or check VPN/Firewall.".to_string())
}
}
}

View File

@@ -1,9 +1,20 @@
use tauri::{AppHandle, Manager, State, Emitter};
use tauri::{
AppHandle, Manager, State, Emitter,
menu::{Menu, MenuItem},
tray::{TrayIconBuilder, TrayIconEvent},
WindowEvent
};
use std::sync::{Arc, Mutex};
use std::process::Command;
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use std::time::Duration;
use tokio::time::sleep;
use base64::Engine;
mod audio_processor;
use audio_processor::AudioProcessor;
mod auth;
mod email;
// State to hold the active recording stream
struct AppState {
@@ -60,7 +71,7 @@ fn get_input_devices() -> Result<Vec<AudioDevice>, String> {
#[tauri::command]
fn start_recording(app: AppHandle, state: State<'_, AppState>, device_id: String, save_path: Option<String>) -> Result<(), String> {
fn start_recording(app: AppHandle, state: State<'_, AppState>, device_id: String, save_path: Option<String>, custom_filename: Option<String>, wait_for_speech: Option<bool>) -> Result<(), String> {
emit_log(&app, "INFO", &format!("Starting recording on device: {}", device_id));
let host = cpal::default_host();
@@ -72,7 +83,17 @@ fn start_recording(app: AppHandle, state: State<'_, AppState>, device_id: String
.or_else(|| host.default_input_device())
.ok_or("No input device found")?;
let config = device.default_input_config().map_err(|e| e.to_string())?;
// Select the configuration with the MAXIMUM number of channels
// This is crucial for "Hearbit Audio" (Aggregate) which lists 3 channels but might default to 2.
// We want the raw 3 channels to separate Mic (Ch0) from System (Ch1+2).
let supported_configs = device.supported_input_configs().map_err(|e| e.to_string())?;
let config = supported_configs
.max_by_key(|c| c.channels())
.map(|c| c.with_max_sample_rate())
.ok_or("No supported input configurations found")?;
emit_log(&app, "INFO", &format!("Selected Audio Config: {} Channels, {} Hz", config.channels(), config.sample_rate()));
let spec = hound::WavSpec {
channels: config.channels(),
sample_rate: config.sample_rate(),
@@ -81,16 +102,22 @@ fn start_recording(app: AppHandle, state: State<'_, AppState>, device_id: String
};
// Determine file path: User provided or Temp
let filename = if let Some(name) = custom_filename {
// Sanitize filename
let safe_name: String = name.chars().map(|x| if x.is_alphanumeric() || x == ' ' || x == '-' || x == '_' { x } else { '_' }).collect();
format!("{}.wav", safe_name)
} else {
format!("recording_{}.wav", std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs())
};
let file_path = if let Some(path) = save_path {
if path.trim().is_empty() {
std::env::temp_dir().join(format!("recording_{}.wav", std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs()))
std::env::temp_dir().join(&filename)
} else {
// Check if directory exists, if not try to create it or error out?
// For now, assume user gives a valid directory. We'll append filename.
std::path::PathBuf::from(path).join(format!("recording_{}.wav", std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs()))
std::path::PathBuf::from(path).join(&filename)
}
} else {
std::env::temp_dir().join(format!("recording_{}.wav", std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs()))
std::env::temp_dir().join(&filename)
};
let file_path_str = file_path.to_string_lossy().to_string();
@@ -99,6 +126,24 @@ fn start_recording(app: AppHandle, state: State<'_, AppState>, device_id: String
let writer = hound::WavWriter::create(&file_path, spec).map_err(|e| e.to_string())?;
let writer = Arc::new(Mutex::new(writer));
let writer_clone = writer.clone();
// Initialize AudioProcessor (VAD)
// We pass the writer to it.
let should_wait = wait_for_speech.unwrap_or(false);
if should_wait {
emit_log(&app, "INFO", "Recording started in WAITING mode (buffer-only until speech).");
}
let processor = AudioProcessor::new(config.sample_rate(), config.channels(), writer.clone(), app.clone(), should_wait)
.map_err(|e| format!("Failed to create AudioProcessor: {}", e))?;
// Wrap processor in Arc<Mutex> so we can share/move it into callback
// Actually, cpal callback takes ownership of its closure state usually if 'move'.
// Since stream is on another thread, we need Send. AudioProcessor should be Send.
// However, the callback is called repeatedly. We need to keep state.
// The workaround is to wrap it in a Mutex.
let processor = Arc::new(Mutex::new(processor));
let processor_clone = processor.clone();
let app_handle = app.clone();
let err_fn = move |err| {
@@ -110,21 +155,21 @@ fn start_recording(app: AppHandle, state: State<'_, AppState>, device_id: String
cpal::SampleFormat::F32 => device.build_input_stream(
&config.into(),
move |data: &[f32], _: &_| {
let mut guard = writer_clone.lock().unwrap();
for &sample in data {
let amplitude = i16::MAX as f32;
guard.write_sample((sample * amplitude) as i16).ok();
if let Ok(mut p) = processor_clone.lock() {
p.process(data);
}
},
err_fn,
None
),
// For I16 and U16 we need to convert to F32 for our processor
cpal::SampleFormat::I16 => device.build_input_stream(
&config.into(),
move |data: &[i16], _: &_| {
let mut guard = writer_clone.lock().unwrap();
for &sample in data {
guard.write_sample(sample).ok();
// Convert i16 to f32
let f32_data: Vec<f32> = data.iter().map(|&s| s as f32 / i16::MAX as f32).collect();
if let Ok(mut p) = processor_clone.lock() {
p.process(&f32_data);
}
},
err_fn,
@@ -133,9 +178,10 @@ fn start_recording(app: AppHandle, state: State<'_, AppState>, device_id: String
cpal::SampleFormat::U16 => device.build_input_stream(
&config.into(),
move |data: &[u16], _: &_| {
let mut guard = writer_clone.lock().unwrap();
for &sample in data {
guard.write_sample((sample as i32 - 32768) as i16).ok();
// Convert u16 to f32
let f32_data: Vec<f32> = data.iter().map(|&s| (s as i32 - 32768) as f32 / 32768.0).collect();
if let Ok(mut p) = processor_clone.lock() {
p.process(&f32_data);
}
},
err_fn,
@@ -526,6 +572,189 @@ async fn summarize_text(app: AppHandle, text: String, api_key: String, product_i
}
}
#[derive(serde::Serialize)]
struct AudioMetadata {
duration: f64,
size: u64,
format: String,
}
// Helper to find ffmpeg/ffprobe in common paths
fn resolve_binary_path(binary_name: &str) -> String {
let common_paths = [
format!("/opt/homebrew/bin/{}", binary_name),
format!("/usr/local/bin/{}", binary_name),
format!("/usr/bin/{}", binary_name),
];
for path in common_paths.iter() {
if std::path::Path::new(path).exists() {
return path.clone();
}
}
// Fallback to expecting it in PATH
binary_name.to_string()
}
#[tauri::command]
fn get_audio_metadata(app: AppHandle, file_path: String) -> Result<AudioMetadata, String> {
emit_log(&app, "INFO", &format!("Getting metadata for: {}", file_path));
let path = std::path::Path::new(&file_path);
if !path.exists() {
return Err(format!("File not found: {}", file_path));
}
let size = std::fs::metadata(&file_path)
.map_err(|e| e.to_string())?
.len();
// Use ffprobe to get duration
// Try resolved path first
let ffprobe_cmd = resolve_binary_path("ffprobe");
let output = Command::new(&ffprobe_cmd)
.args([
"-v", "error",
"-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1",
&file_path
])
.output()
.map_err(|e| format!("Failed to execute ffprobe at '{}': {}", ffprobe_cmd, e))?;
let duration_str = String::from_utf8_lossy(&output.stdout);
let duration: f64 = duration_str.trim().parse().unwrap_or(0.0);
// Extension as format
let format = path.extension()
.and_then(|e| e.to_str())
.unwrap_or("unknown")
.to_string();
Ok(AudioMetadata {
duration,
size,
format,
})
}
#[tauri::command]
fn convert_to_mp3(app: AppHandle, wav_path: String) -> Result<String, String> {
emit_log(&app, "INFO", &format!("Converting to MP3: {}", wav_path));
let mp3_path = wav_path.replace(".wav", ".mp3");
let ffmpeg_cmd = resolve_binary_path("ffmpeg");
let output = Command::new(&ffmpeg_cmd)
.args([
"-i", &wav_path,
"-codec:a", "libmp3lame",
"-b:a", "64k",
"-y", // overwrite
&mp3_path
])
.output()
.map_err(|e| format!("Failed to execute ffmpeg at '{}': {}", ffmpeg_cmd, e))?;
if output.status.success() {
emit_log(&app, "SUCCESS", &format!("MP3 created: {}", mp3_path));
Ok(mp3_path)
} else {
let error = String::from_utf8_lossy(&output.stderr);
emit_log(&app, "ERROR", &format!("MP3 conversion failed: {}", error));
Err(format!("MP3 conversion failed: {}", error))
}
}
#[tauri::command]
fn chunk_audio(app: AppHandle, file_path: String, chunk_minutes: u32) -> Result<Vec<String>, String> {
emit_log(&app, "INFO", &format!("Chunking audio: {} ({}min chunks)", file_path, chunk_minutes));
let chunk_seconds = chunk_minutes * 60;
let ffprobe_cmd = resolve_binary_path("ffprobe");
let ffmpeg_cmd = resolve_binary_path("ffmpeg");
// Get total duration using ffprobe
let duration_output = Command::new(&ffprobe_cmd)
.args([
"-v", "error",
"-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1",
&file_path
])
.output()
.map_err(|e| format!("Failed to get duration with '{}': {}", ffprobe_cmd, e))?;
let duration_str = String::from_utf8_lossy(&duration_output.stdout);
let duration: f64 = duration_str.trim().parse()
.map_err(|_| "Failed to parse duration".to_string())?;
let num_chunks = (duration / chunk_seconds as f64).ceil() as usize;
emit_log(&app, "INFO", &format!("Total duration: {}s, creating {} chunks", duration, num_chunks));
let mut chunk_paths = Vec::new();
let base_path = file_path.replace(".mp3", "");
for i in 0..num_chunks {
let start_time = i as u32 * chunk_seconds;
let chunk_path = format!("{}_chunk_{}.mp3", base_path, i);
let output = Command::new(&ffmpeg_cmd)
.args([
"-i", &file_path,
"-ss", &start_time.to_string(),
"-t", &chunk_seconds.to_string(),
"-c", "copy",
"-y",
&chunk_path
])
.output()
.map_err(|e| format!("Failed to create chunk {} with '{}': {}", i, ffmpeg_cmd, e))?;
if !output.status.success() {
let error = String::from_utf8_lossy(&output.stderr);
return Err(format!("Chunk {} failed: {}", i, error));
}
chunk_paths.push(chunk_path);
}
emit_log(&app, "SUCCESS", &format!("Created {} chunks", chunk_paths.len()));
Ok(chunk_paths)
}
#[tauri::command]
fn read_image_as_base64(app: AppHandle, file_path: String) -> Result<String, String> {
emit_log(&app, "INFO", &format!("Reading image as base64: {}", file_path));
let bytes = std::fs::read(&file_path)
.map_err(|e| format!("Failed to read file: {}", e))?;
// Detect image type from extension
let extension = std::path::Path::new(&file_path)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("png")
.to_lowercase();
let mime_type = match extension.as_str() {
"jpg" | "jpeg" => "image/jpeg",
"png" => "image/png",
"svg" => "image/svg+xml",
"gif" => "image/gif",
_ => "image/png"
};
// Use base64 encoding
let base64_str = base64::prelude::BASE64_STANDARD.encode(&bytes);
let data_url = format!("data:{};base64,{}", mime_type, base64_str);
emit_log(&app, "SUCCESS", &format!("Image converted to base64 ({} bytes)", base64_str.len()));
Ok(data_url)
}
#[tauri::command]
fn open_audio_midi_setup() -> Result<(), String> {
Command::new("open")
@@ -536,13 +765,129 @@ fn open_audio_midi_setup() -> Result<(), String> {
Ok(())
}
#[tauri::command]
fn create_hearbit_audio_device(app: AppHandle) -> Result<String, String> {
emit_log(&app, "INFO", "Attempting to create Hearbit Audio device...");
// Resolve resource path
let resource_path = app.path().resource_dir()
.map_err(|e| e.to_string())?
.join("resources/create_hearbit_audio.swift");
if !resource_path.exists() {
// Fallback for dev environment where resources might not be bundled yet or different path
emit_log(&app, "WARN", &format!("Resource script not found at {:?}. Trying local src-tauri path.", resource_path));
}
// For now, in dev mode, we might need to point to the source location if bundle isn't active
// But let's try running it.
let output = Command::new("swift")
.arg(resource_path)
.output()
.map_err(|e| e.to_string())?;
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
emit_log(&app, "DEBUG", &format!("Script Output: {}", stdout));
if !stderr.is_empty() {
emit_log(&app, "WARN", &format!("Script Stderr: {}", stderr));
}
if output.status.success() {
emit_log(&app, "SUCCESS", "Hearbit Audio device created successfully.");
Ok("Device created successfully".to_string())
} else {
emit_log(&app, "ERROR", "Failed to create device.");
Err(format!("Failed to create device: {} {}", stdout, stderr))
}
}
#[tauri::command]
async fn save_text_file(app: AppHandle, path: String, content: String) -> Result<(), String> {
emit_log(&app, "INFO", &format!("Saving text file to: {}", path));
match std::fs::write(&path, content) {
Ok(_) => {
emit_log(&app, "SUCCESS", "File saved successfully.");
Ok(())
},
Err(e) => {
emit_log(&app, "ERROR", &format!("Failed to save file: {}", e));
Err(e.to_string())
}
}
}
#[tauri::command]
async fn read_log_file(app: AppHandle) -> Result<String, String> {
let log_path = app.path().app_log_dir().map_err(|e| e.to_string())?.join("hearbit-ai.log");
if log_path.exists() {
let content = std::fs::read_to_string(&log_path).map_err(|e| e.to_string())?;
Ok(content)
} else {
Ok("No log file found yet.".to_string())
}
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.setup(|app| {
// Setup Tray Icon
let quit_i = MenuItem::with_id(app, "quit", "Quit Hearbit AI", true, None::<&str>).unwrap();
let show_i = MenuItem::with_id(app, "show", "Show Window", true, None::<&str>).unwrap();
let menu = Menu::with_items(app, &[&show_i, &quit_i]).unwrap();
let _tray = TrayIconBuilder::new()
.icon(app.default_window_icon().unwrap().clone())
.menu(&menu)
.show_menu_on_left_click(true)
.on_menu_event(|app, event| {
match event.id.as_ref() {
"quit" => app.exit(0),
"show" => {
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.set_focus();
}
}
_ => {}
}
})
.on_tray_icon_event(|tray, event| {
if let TrayIconEvent::Click { .. } = event {
let app = tray.app_handle();
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.set_focus();
}
}
})
.build(app)?;
Ok(())
})
.on_window_event(|window, event| {
if let WindowEvent::CloseRequested { api, .. } = event {
// Prevent window from closing, just hide it
window.hide().unwrap();
api.prevent_close();
}
})
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_log::Builder::default()
.targets([
tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::Stdout),
tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::LogDir { file_name: Some("hearbit-ai.log".to_string()) }),
])
.build())
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_oauth::init())
.manage(AppState {
recording_stream: Mutex::new(None),
recording_file_path: Mutex::new(None),
@@ -557,7 +902,17 @@ pub fn run() {
transcribe_audio,
summarize_text,
get_available_models,
open_audio_midi_setup
open_audio_midi_setup,
create_hearbit_audio_device,
auth::start_auth_flow,
auth::get_calendar_events,
save_text_file,
read_log_file,
get_audio_metadata,
convert_to_mp3,
chunk_audio,
read_image_as_base64,
email::send_smtp_email
])
.run(tauri::generate_context!())
.expect("error while running tauri application");

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Hearbit AI",
"version": "0.1.0",
"version": "1.1.1",
"identifier": "com.hearbit-ai.desktop",
"build": {
"beforeDevCommand": "npm run dev",
@@ -32,7 +32,7 @@
"icons/icon.ico"
],
"resources": [
"resources/BlackHole2ch.v0.6.1.pkg"
"resources/*"
]
}
}

View File

@@ -1,26 +1,69 @@
import { useState, useEffect } from 'react';
import { listen } from "@tauri-apps/api/event";
import { useState } from 'react';
import { Settings as SettingsIcon } from "lucide-react";
import Settings from "./components/Settings";
import Settings, { SmtpConfig, AzureConfig } from "./components/Settings";
import Recorder from "./components/Recorder";
import LogViewer, { LogEntry } from "./components/LogViewer";
import TranscriptionView from "./components/TranscriptionView";
import Tabs from "./components/Tabs";
import MeetingsView from "./components/MeetingsView";
import HistoryView from "./components/HistoryView";
import Import from "./components/Import";
import ToastContainer, { ToastMessage, ToastType } from "./components/ui/Toast";
export interface PromptTemplate {
id: string;
name: string;
content: string;
keywords?: string[];
}
export interface EmailTemplate {
id: string;
name: string;
subject: string;
body: string;
}
function App() {
const [view, setView] = useState<'recorder' | 'logs' | 'settings' | 'transcription'>('recorder');
// Keep track of the *previous* tab to return to from settings
const [lastTab, setLastTab] = useState<'recorder' | 'logs' | 'transcription'>('recorder');
const [view, setView] = useState<'recorder' | 'settings' | 'transcription' | 'meetings' | 'history' | 'import'>('recorder');
const [lastTab, setLastTab] = useState<'recorder' | 'transcription' | 'meetings' | 'history' | 'import'>('recorder');
// Auto-start recording state to handle "Join & Record" transition
const [autoStartRecording, setAutoStartRecording] = useState(false);
const [recordingSubject, setRecordingSubject] = useState<string>('');
// Toast State
const [toasts, setToasts] = useState<ToastMessage[]>([]);
const addToast = (message: string, type: ToastType = 'info', duration = 3000) => {
const id = Date.now().toString() + Math.random().toString();
setToasts(prev => [...prev, { id, message, type, duration }]);
};
const removeToast = (id: string) => {
setToasts(prev => prev.filter(t => t.id !== id));
};
const [apiKey, setApiKey] = useState(localStorage.getItem('infomaniak_api_key') || '');
const [productId, setProductId] = useState(localStorage.getItem('infomaniak_product_id') || '');
const [savePath, setSavePath] = useState(localStorage.getItem('infomaniak_save_path') || '');
const [smtpConfig, setSmtpConfig] = useState<SmtpConfig>(() => {
const saved = localStorage.getItem('hearbit_smtp_config');
return saved ? JSON.parse(saved) : { host: '', port: '587', user: '', pass: '', sender: '', senderName: '' };
});
const [azureConfig, setAzureConfig] = useState<AzureConfig>(() => {
const saved = localStorage.getItem('hearbit_azure_config');
return saved ? JSON.parse(saved) : { clientId: '', tenantId: '' };
});
const [selectedModel, setSelectedModel] = useState<string>(() => {
return localStorage.getItem('hearbit_selected_model') || 'mixtral';
});
const handleModelChange = (model: string) => {
setSelectedModel(model);
localStorage.setItem('hearbit_selected_model', model);
};
// Default prompts if none exist
/* eslint-disable no-useless-escape */ // Escape quotes in prompts
@@ -61,7 +104,8 @@ Kurze Stichpunkte zu Themen, die besprochen, aber noch nicht final geklärt wurd
| [Aufgabe 2] | [Name] | [Datum] |
## 5. Nächste Schritte / Nächstes Meeting
Kurze Info zum weiteren Vorgehen.`
Kurze Info zum weiteren Vorgehen.`,
keywords: ['protokoll', 'meeting', 'team', 'daily', 'weekly']
},
{
id: '2',
@@ -96,7 +140,8 @@ Thema B: [Kurze Zusammenfassung]
| Wer? | Was ist zu tun / zu beachten? | Bis wann? |
| :--- | :--- | :--- |
| [Name] | [Aufgabe] | [Datum] |
| [Name] | [Aufgabe] | [Datum] |`
| [Name] | [Aufgabe] | [Datum] |`,
keywords: ['personal', 'privat', 'vertraulich', 'entwicklungsgespräch', 'feedback', 'unter vier augen']
},
{
id: '3',
@@ -138,7 +183,37 @@ Teilnehmer: [Namen Kunden] & [Namen Intern]
[ ] [Aufgabe, z.B. Zugangdaten senden, Design freigeben] (bis [Datum])
## 5. Nächster Termin / Timeline
Wann findet das nächste Meeting statt oder was ist der nächste Meilenstein?`
Wann findet das nächste Meeting statt oder was ist der nächste Meilenstein?`,
keywords: ['beratung', 'kunde', 'client', 'angebot', 'projekt', 'extern']
}
];
// Default Email Templates
const defaultEmailTemplates: EmailTemplate[] = [
{
id: '1',
name: 'Meeting Summary (Standard)',
subject: 'Meeting Summary: {{subject}}',
body: `Hi everyone,
Here is the summary of our meeting "{{subject}}" from {{date}}.
{{summary}}
Best regards,
Hearbit Assistant`
},
{
id: '2',
name: 'Action Items Only',
subject: 'Action Items: {{subject}}',
body: `Hi Team,
Please find below the action items from our call on {{date}}:
{{summary}}
Thanks!`
}
];
@@ -147,15 +222,36 @@ Wann findet das nächste Meeting statt oder was ist der nächste Meilenstein?`
return saved ? JSON.parse(saved) : defaultPrompts;
});
const handleSaveSettings = (newApiKey: string, newProductId: string, newPrompts: PromptTemplate[], newSavePath: string) => {
const [emailTemplates, setEmailTemplates] = useState<EmailTemplate[]>(() => {
const saved = localStorage.getItem('hearbit_email_templates');
return saved ? JSON.parse(saved) : defaultEmailTemplates;
});
const handleSaveSettings = (
newApiKey: string,
newProductId: string,
newPrompts: PromptTemplate[],
newSavePath: string,
newSmtp: SmtpConfig,
newAzure: AzureConfig,
newEmailTemplates: EmailTemplate[]
) => {
setApiKey(newApiKey);
setProductId(newProductId);
setPrompts(newPrompts);
setSavePath(newSavePath);
setSmtpConfig(newSmtp);
setAzureConfig(newAzure);
setEmailTemplates(newEmailTemplates);
localStorage.setItem('infomaniak_api_key', newApiKey);
localStorage.setItem('infomaniak_product_id', newProductId);
localStorage.setItem('infomaniak_prompts', JSON.stringify(newPrompts));
localStorage.setItem('infomaniak_save_path', newSavePath);
localStorage.setItem('hearbit_smtp_config', JSON.stringify(newSmtp));
localStorage.setItem('hearbit_azure_config', JSON.stringify(newAzure));
localStorage.setItem('hearbit_email_templates', JSON.stringify(newEmailTemplates));
setView(lastTab);
};
@@ -168,6 +264,8 @@ Wann findet das nächste Meeting statt oder was ist der nächste Meilenstein?`
date: string;
transcription: string;
summary: string;
subject?: string;
filename?: string;
}
const [history, setHistory] = useState<HistoryItem[]>(() => {
@@ -179,16 +277,47 @@ Wann findet das nächste Meeting statt oder was ist der nächste Meilenstein?`
const transToSave = t !== undefined ? t : transcription;
const sumToSave = s !== undefined ? s : summary;
// Sanitize subject for filename
const safeSubject = recordingSubject
? recordingSubject.replace(/[^a-zA-Z0-9_-]/g, '_')
: `Meeting_${Date.now()}`;
const filename = `${safeSubject}.md`;
if (!transToSave && !sumToSave) return;
const newItem: HistoryItem = {
id: Date.now().toString(),
date: new Date().toLocaleString(),
transcription: transToSave,
summary: sumToSave
summary: sumToSave,
subject: recordingSubject || "Untitled Recording",
filename: filename
};
const newHistory = [newItem, ...history];
setHistory(newHistory);
localStorage.setItem('infomaniak_history', JSON.stringify(newHistory));
// Persist to Disk (Markdown)
const content = `# ${newItem.subject}\nDate: ${newItem.date}\n\n## Summary\n${sumToSave}\n\n## Transcription\n${transToSave}`;
// If savePath is set, we use it. If not, backend defaults to temp. Here we want to save text.
// Let's assume savePath is set or we default to Documents/Hearbit (if we could).
// For now, if savePath is set, use it.
if (savePath) {
// We need invoke to save text
import("@tauri-apps/api/core").then(({ invoke }) => {
invoke('save_text_file', { path: `${savePath}/${filename}`, content })
.then(() => addToast('Transcript saved to file', 'success'))
.catch(e => addToast(`Failed to save file: ${e}`, 'error'));
});
}
};
const handleRenameHistory = (id: string, newSubject: string) => {
const newHistory = history.map(item =>
item.id === id ? { ...item, subject: newSubject } : item
);
setHistory(newHistory);
localStorage.setItem('infomaniak_history', JSON.stringify(newHistory));
};
const handleDeleteHistory = (id: string) => {
@@ -200,21 +329,10 @@ Wann findet das nächste Meeting statt oder was ist der nächste Meilenstein?`
const handleLoadHistory = (item: HistoryItem) => {
setTranscription(item.transcription);
setSummary(item.summary);
setView('recorder'); // Ensure we go back to recorder to see it
setView('transcription'); // Switch to Transcription view to see content
};
// Logs State
const [logs, setLogs] = useState<LogEntry[]>([]);
useEffect(() => {
const unlisten = listen<LogEntry>('log-event', (event) => {
setLogs((prevLogs) => [...prevLogs, event.payload]);
});
return () => {
unlisten.then(f => f());
};
}, []);
return (
<div className="min-h-screen bg-background text-foreground flex flex-col select-none overflow-hidden">
@@ -224,7 +342,7 @@ Wann findet das nächste Meeting statt oder was ist der nächste Meilenstein?`
<div className="absolute right-4 top-4">
<button
onClick={() => {
setLastTab(view === 'logs' ? 'logs' : 'recorder');
setLastTab(view === 'history' ? view : 'recorder');
setView('settings');
}}
className="p-2 text-muted-foreground hover:text-foreground hover:bg-secondary rounded-full transition-colors"
@@ -234,53 +352,132 @@ Wann findet das nächste Meeting statt oder was ist der nächste Meilenstein?`
</div>
<Tabs
currentTab={view as 'recorder' | 'logs' | 'transcription'}
currentTab={view as 'recorder' | 'transcription' | 'meetings' | 'history' | 'import'}
onTabChange={(t) => setView(t)}
/>
</div>
)}
<div className="flex-1 flex flex-col h-full overflow-hidden relative">
{view === 'recorder' && (
<Recorder
apiKey={apiKey}
productId={productId}
prompts={prompts}
onOpenSettings={() => {
setLastTab('recorder');
setView('settings');
}}
transcription={transcription}
setTranscription={setTranscription}
summary={summary}
setSummary={setSummary}
history={history}
onSaveToHistory={handleSaveToHistory}
onDeleteHistory={handleDeleteHistory}
onLoadHistory={handleLoadHistory}
savePath={savePath}
onRecordingComplete={() => setView('transcription')}
/>
)}
<div className="flex-1 flex h-full overflow-hidden relative">
<div className="flex-1 flex flex-col h-full overflow-hidden relative">
{/* Recorder - Persistent (Hidden via CSS to keep recording alive) */}
<div className="flex-1 flex flex-col h-full overflow-hidden" style={{ display: view === 'recorder' ? 'flex' : 'none' }}>
<Recorder
apiKey={apiKey}
productId={productId}
prompts={prompts}
onOpenSettings={() => {
setLastTab('recorder');
setView('settings');
}}
transcription={transcription}
setTranscription={setTranscription}
summary={summary}
setSummary={setSummary}
history={history}
onSaveToHistory={handleSaveToHistory}
onDeleteHistory={handleDeleteHistory}
onLoadHistory={handleLoadHistory}
savePath={savePath}
{view === 'transcription' && (
<TranscriptionView transcription={transcription} summary={summary} />
)}
onRecordingComplete={() => setView('transcription')}
autoStart={autoStartRecording}
recordingSubject={recordingSubject}
onAutoStartHandled={() => setAutoStartRecording(false)}
addToast={addToast}
selectedModel={selectedModel}
onModelChange={handleModelChange}
isVisible={view === 'recorder'}
/>
</div>
{view === 'logs' && (
<LogViewer logs={logs} />
)}
{view === 'transcription' && (
<TranscriptionView
transcription={transcription}
summary={summary}
smtpConfig={smtpConfig}
apiKey={apiKey}
productId={productId}
prompts={prompts}
emailTemplates={emailTemplates}
onUpdateSummary={(newSummary) => {
setSummary(newSummary); // Update view
// Also update history item if it exists
// We identify by transcription content match (simple heuristic) or we should track currentId
const histIdx = history.findIndex(h => h.transcription === transcription);
if (histIdx >= 0) {
const newHist = [...history];
newHist[histIdx] = { ...newHist[histIdx], summary: newSummary };
setHistory(newHist);
localStorage.setItem('infomaniak_history', JSON.stringify(newHist));
}
}}
addToast={addToast}
/>
)}
{view === 'settings' && (
<Settings
onSave={handleSaveSettings}
onClose={() => setView(lastTab)}
apiKey={apiKey}
productId={productId}
prompts={prompts}
savePath={savePath}
/>
)}
{view === 'history' && (
<HistoryView
history={history}
onLoad={handleLoadHistory}
onDelete={handleDeleteHistory}
onRename={handleRenameHistory}
smtpConfig={smtpConfig}
emailTemplates={emailTemplates}
addToast={addToast}
/>
)}
{view === 'meetings' && (
<MeetingsView
azureClientId={azureConfig.clientId}
onStartRecording={(subject) => {
setView('recorder');
setRecordingSubject(subject || '');
setAutoStartRecording(true);
}}
apiKey={apiKey}
productId={productId}
selectedModel={selectedModel}
onModelChange={handleModelChange}
/>
)}
{view === 'import' && (
<Import
apiKey={apiKey}
productId={productId}
prompts={prompts}
selectedModel={selectedModel}
onSaveToHistory={handleSaveToHistory}
onComplete={() => setView('transcription')}
addToast={addToast}
setTranscription={setTranscription}
setSummary={setSummary}
/>
)}
{view === 'settings' && (
<Settings
onSave={handleSaveSettings}
onClose={() => setView(lastTab)}
apiKey={apiKey}
productId={productId}
prompts={prompts}
savePath={savePath}
smtpConfig={smtpConfig}
azureConfig={azureConfig}
emailTemplates={emailTemplates}
/>
)}
</div>
<ToastContainer toasts={toasts} removeToast={removeToast} />
</div>
</div>
);

View File

@@ -0,0 +1,288 @@
import React, { useState, useEffect } from 'react';
import { Mail, X, Send } from 'lucide-react';
import { invoke } from '@tauri-apps/api/core';
import { EmailTemplate } from '../App';
interface EmailPreviewModalProps {
isOpen: boolean;
onClose: () => void;
initialRecipients: string[];
initialSubject: string;
initialBody: string;
emailTemplates: EmailTemplate[]; // New prop
smtpConfig: {
host: string;
port: number;
user: string;
pass: string;
sender: string;
senderName?: string;
} | null;
addToast: (msg: string, type: 'success' | 'error' | 'info') => void;
}
// Basic Markdown to HTML converter for email body
const formatMarkdownToHtml = (markdown: string): string => {
let html = markdown
// Headers
.replace(/^# (.*$)/gim, '<h1 style="color: #1a1a1a; font-size: 24px; margin-top: 20px; margin-bottom: 10px; border-bottom: 2px solid #eaeaea; padding-bottom: 10px;">$1</h1>')
.replace(/^## (.*$)/gim, '<h2 style="color: #2d2d2d; font-size: 20px; margin-top: 25px; margin-bottom: 10px;">$1</h2>')
.replace(/^### (.*$)/gim, '<h3 style="color: #404040; font-size: 18px; margin-top: 20px; margin-bottom: 8px;">$1</h3>')
// Bold
.replace(/\*\*(.*?)\*\*/gim, '<strong>$1</strong>')
// Lists
.replace(/^\s*-\s+(.*$)/gim, '<li style="margin-bottom: 5px;">$1</li>')
// Wrap lists (simple heuristic)
.replace(/(<li.*<\/li>)/gim, '<ul>$1</ul>')
.replace(/<\/ul>\s*<ul>/gim, '') // Merge adjacent lists
// Tables (Basic support for the format used in prompts)
.replace(/\| (.*?) \| (.*?) \| (.*?) \|/gim, '<tr><td style="border: 1px solid #ddd; padding: 8px;">$1</td><td style="border: 1px solid #ddd; padding: 8px;">$2</td><td style="border: 1px solid #ddd; padding: 8px;">$3</td></tr>')
.replace(/\| :--- \| :--- \| :--- \|/gim, '') // Remove separator row
// Tables wrapping (heuristic)
.replace(/(<tr>.*<\/tr>)/gim, '<table style="width: 100%; border-collapse: collapse; margin-top: 10px; margin-bottom: 20px;">$1</table>')
.replace(/<\/table>\s*<table.*?>/gim, '') // Merge adjacent tables
// Paragraphs (double newlines)
.replace(/\n\n/gim, '<br><br>');
return html;
};
const EmailPreviewModal: React.FC<EmailPreviewModalProps> = ({
isOpen, onClose, initialRecipients, initialSubject, initialBody, smtpConfig, addToast, emailTemplates
}) => {
const [recipients, setRecipients] = useState<string>(initialRecipients.join(', '));
const [subject, setSubject] = useState(initialSubject);
const [body, setBody] = useState(initialBody);
const [sending, setSending] = useState(false);
const [selectedTemplateId, setSelectedTemplateId] = useState<string>('');
const [activeTab, setActiveTab] = useState<'preview' | 'source'>('preview');
const generateHtmlBody = (content: string, title: string) => {
// Check if it's a full HTML document
if (/^\s*<!DOCTYPE html/i.test(content) || /^\s*<html/i.test(content)) {
return content;
}
// Simple heuristic: if it looks like HTML fragment (div, p, table), treat as HTML. Otherwise, markdown.
const isHtmlFragment = /^\s*<(div|p|table|section|header|footer)/i.test(content);
const formattedBody = isHtmlFragment ? content : formatMarkdownToHtml(content);
return `
<!DOCTYPE html>
<html>
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; background-color: #f4f4f5; color: #333;">
<div style="max-width: 640px; margin: 40px auto; background-color: #ffffff; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);">
<!-- Header -->
<div style="background-color: #000000; padding: 30px 40px;">
<h1 style="color: #ffffff; margin: 0; font-size: 24px; font-weight: 600; letter-spacing: -0.5px;">${title}</h1>
</div>
<!-- Content -->
<div style="padding: 40px; line-height: 1.6;">
${formattedBody}
</div>
<!-- Footer -->
<div style="background-color: #f9fafb; padding: 20px 40px; text-align: center; border-top: 1px solid #e5e7eb;">
<p style="margin: 0; font-size: 12px; color: #6b7280;">
Generated by <strong>Hearbit AI</strong> • Secure & Local Meeting Intelligence
</p>
</div>
</div>
</body>
</html>`;
};
useEffect(() => {
if (isOpen) {
setRecipients(initialRecipients.join(', '));
setSubject(initialSubject);
// Default: Wrap initialBody in our HTML template
setBody(generateHtmlBody(initialBody, initialSubject));
setSelectedTemplateId('');
}
}, [isOpen, initialRecipients, initialSubject, initialBody]);
const handleTemplateChange = (tmplId: string) => {
setSelectedTemplateId(tmplId);
if (!tmplId) return;
const tmpl = emailTemplates.find(t => t.id === tmplId);
if (tmpl) {
// Replace placeholders
const dateStr = new Date().toLocaleDateString();
let newSub = tmpl.subject.replace(/{{date}}/g, dateStr).replace(/{{subject}}/g, "Meeting");
// Clean up JSON if necessary (e.g. remove markdown code blocks ```json ... ```)
let cleanSummary = initialBody;
if (initialBody.trim().startsWith('```')) {
cleanSummary = initialBody.replace(/^```(json)?/i, '').replace(/```$/, '').trim();
}
let newBody = tmpl.body
.replace(/{{date}}/g, dateStr)
.replace(/{{subject}}/g, "the meeting")
.replace(/{{summary}}/g, cleanSummary);
setSubject(newSub);
setBody(generateHtmlBody(newBody, newSub));
}
};
if (!isOpen) return null;
const handleSend = async () => {
if (!smtpConfig || !smtpConfig.host) {
addToast("SMTP Settings not configured. Please go to Settings.", "error");
return;
}
setSending(true);
try {
// Split recipients by comma/semicolon and clean
const toList = recipients.split(/[,;]/).map(s => s.trim()).filter(s => s.length > 0);
await invoke('send_smtp_email', {
config: {
host: smtpConfig.host,
port: Number(smtpConfig.port),
username: smtpConfig.user,
password: smtpConfig.pass,
sender_email: smtpConfig.sender,
sender_name: smtpConfig.senderName
},
message: {
to: toList,
subject: subject,
body_html: body
}
});
addToast("Email sent successfully!", "success");
onClose();
} catch (e) {
console.error(e);
addToast(`Failed to send email: ${e}`, "error");
} finally {
setSending(false);
}
};
return (
<div className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4">
<div className="bg-background border border-border rounded-xl shadow-2xl w-full max-w-3xl flex flex-col h-[90vh]">
<div className="p-4 border-b border-border flex justify-between items-center shrink-0">
<h3 className="font-semibold flex items-center gap-2">
<Mail size={18} /> Send Summary via Email
</h3>
<button onClick={onClose} className="text-muted-foreground hover:text-foreground">
<X size={20} />
</button>
</div>
<div className="p-4 space-y-4 overflow-y-auto flex-1 flex flex-col">
{/* Template Selector */}
{emailTemplates.length > 0 && (
<div className="bg-secondary/20 p-3 rounded-lg border border-border/50">
<label className="block text-xs font-semibold text-muted-foreground mb-1 uppercase tracking-wide">
Load Template
</label>
<select
value={selectedTemplateId}
onChange={(e) => handleTemplateChange(e.target.value)}
className="w-full text-sm p-2 rounded border border-border bg-background outline-none focus:ring-2 focus:ring-primary"
>
<option value="">-- Select a Template --</option>
{emailTemplates.map(t => (
<option key={t.id} value={t.id}>{t.name}</option>
))}
</select>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-muted-foreground mb-1">To</label>
<input
type="text"
value={recipients}
onChange={(e) => setRecipients(e.target.value)}
className="w-full p-2 rounded border border-border bg-secondary text-sm focus:ring-2 focus:ring-primary outline-none"
placeholder="email@example.com, other@example.com"
/>
</div>
<div>
<label className="block text-xs font-semibold text-muted-foreground mb-1">Subject</label>
<input
type="text"
value={subject}
onChange={(e) => setSubject(e.target.value)}
className="w-full p-2 rounded border border-border bg-secondary text-sm focus:ring-2 focus:ring-primary outline-none"
/>
</div>
</div>
<div className="flex-1 flex flex-col min-h-0 border border-border rounded-lg overflow-hidden">
{/* Tabs */}
<div className="flex border-b border-border bg-secondary/30">
<button
onClick={() => setActiveTab('preview')}
className={`px-4 py-2 text-sm font-medium transition-colors ${activeTab === 'preview' ? 'bg-background text-primary border-r border-border' : 'text-muted-foreground hover:text-foreground'}`}
>
Preview
</button>
<button
onClick={() => setActiveTab('source')}
className={`px-4 py-2 text-sm font-medium transition-colors ${activeTab === 'source' ? 'bg-background text-primary border-l border-r border-border' : 'text-muted-foreground hover:text-foreground'}`}
>
HTML Source
</button>
</div>
{/* Content */}
<div className="flex-1 bg-background overflow-hidden relative">
{activeTab === 'preview' ? (
<div className="w-full h-full bg-white text-black">
<iframe
srcDoc={body}
className="w-full h-full border-none"
title="Email Preview"
sandbox="allow-same-origin allow-scripts"
/>
</div>
) : (
<textarea
value={body}
onChange={(e) => setBody(e.target.value)}
className="w-full h-full p-4 text-sm font-mono resize-none focus:outline-none bg-background text-foreground"
placeholder="<html>...</html>"
/>
)}
</div>
</div>
</div>
<div className="p-4 border-t border-border flex justify-end gap-2 bg-secondary/10 rounded-b-xl">
<button
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
>
Cancel
</button>
<button
onClick={handleSend}
disabled={sending}
className="px-4 py-2 text-sm font-medium bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-all flex items-center gap-2 disabled:opacity-50"
>
{sending ? 'Sending...' : <><Send size={16} /> Send Email</>}
</button>
</div>
</div>
</div>
);
};
export default EmailPreviewModal;

View File

@@ -0,0 +1,216 @@
import React, { useState } from 'react';
import { X, Eye, Send, Code } from 'lucide-react';
import { EmailTemplate } from '../App';
import { SmtpConfig } from './Settings';
import { invoke } from '@tauri-apps/api/core';
interface EmailTemplateEditorProps {
isOpen: boolean;
onClose: () => void;
template: EmailTemplate | null;
onSave: (template: EmailTemplate) => void;
smtpConfig: SmtpConfig;
addToast: (msg: string, type: 'success' | 'error' | 'info') => void;
}
const EmailTemplateEditor: React.FC<EmailTemplateEditorProps> = ({
isOpen, onClose, template, onSave, smtpConfig, addToast
}) => {
const [name, setName] = useState('');
const [subject, setSubject] = useState('');
const [body, setBody] = useState('');
const [activeTab, setActiveTab] = useState<'edit' | 'preview'>('edit');
const [testEmail, setTestEmail] = useState('');
const [sendingTest, setSendingTest] = useState(false);
// Load template data when it changes or opens
React.useEffect(() => {
if (template) {
setName(template.name);
setSubject(template.subject);
setBody(template.body);
} else {
// New template defaults
setName('New Template');
setSubject('Subject: {{subject}}');
setBody('{{summary}}');
}
}, [template, isOpen]);
if (!isOpen) return null;
const handleSave = () => {
if (!template) {
// Create new
onSave({
id: Date.now().toString(),
name,
subject,
body
});
} else {
// Update existing
onSave({
...template,
name,
subject,
body
});
}
onClose();
};
const handleSendTest = async () => {
if (!testEmail) {
addToast('Please enter a test email address', 'error');
return;
}
if (!smtpConfig.host) {
addToast('SMTP settings missing in main settings', 'error');
return;
}
setSendingTest(true);
try {
// Basic substitution for preview
// Basic substitution for preview
// If markdown (simple check), convert? For now, we assume user is writing HTML or we leave it raw if they want.
// But the request is to "use HTML format".
// If it looks like HTML, use it. If not, maybe wrap in <p>?
// For the test email, we pass it as body_html.
await invoke('send_smtp_email', {
config: {
host: smtpConfig.host,
port: Number(smtpConfig.port),
username: smtpConfig.user,
password: smtpConfig.pass,
sender_email: smtpConfig.sender,
sender_name: smtpConfig.senderName
},
message: {
to: [testEmail],
subject: `[TEST] ${subject}`,
body_html: body // Send raw body (HTML)
}
});
addToast('Test email sent!', 'success');
} catch (e) {
console.error(e);
addToast(`Failed to send test: ${e}`, 'error');
} finally {
setSendingTest(false);
}
};
return (
<div className="fixed inset-0 z-[60] bg-black/50 flex items-center justify-center p-4">
<div className="bg-background border border-border rounded-lg shadow-xl w-full max-w-4xl flex flex-col h-[85vh]">
{/* Header */}
<div className="p-4 border-b border-border flex justify-between items-center bg-secondary/20">
<h3 className="font-semibold text-lg flex items-center gap-2">
{template ? 'Edit Email Template' : 'Create Email Template'}
</h3>
<button onClick={onClose} className="text-muted-foreground hover:text-foreground">
<X size={20} />
</button>
</div>
{/* Content */}
<div className="flex-1 flex flex-col min-h-0 container mx-auto p-4 gap-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-muted-foreground mb-1">Template Name</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full p-2 rounded border border-border bg-background focus:ring-2 focus:ring-primary outline-none"
/>
</div>
<div>
<label className="block text-xs font-semibold text-muted-foreground mb-1">Subject Pattern</label>
<input
type="text"
value={subject}
onChange={(e) => setSubject(e.target.value)}
className="w-full p-2 rounded border border-border bg-background focus:ring-2 focus:ring-primary outline-none"
/>
</div>
</div>
<div className="flex bg-secondary/30 border border-border rounded-t-lg mt-2">
<button
onClick={() => setActiveTab('edit')}
className={`px-4 py-2 text-sm font-medium flex items-center gap-2 ${activeTab === 'edit' ? 'bg-background border-b-0 text-primary' : 'text-muted-foreground hover:text-foreground'}`}
>
<Code size={16} /> Edit HTML
</button>
<button
onClick={() => setActiveTab('preview')}
className={`px-4 py-2 text-sm font-medium flex items-center gap-2 ${activeTab === 'preview' ? 'bg-background border-b-0 text-primary' : 'text-muted-foreground hover:text-foreground'}`}
>
<Eye size={16} /> Preview
</button>
</div>
<div className="flex-1 border border-t-0 border-border rounded-b-lg bg-background overflow-hidden relative">
{activeTab === 'edit' ? (
<textarea
value={body}
onChange={(e) => setBody(e.target.value)}
className="w-full h-full p-4 font-mono text-sm resize-none focus:outline-none bg-background text-foreground"
placeholder="<html><body>...</body></html> or Markdown"
/>
) : (
<div className="w-full h-full bg-white text-black">
<iframe
srcDoc={body}
className="w-full h-full border-none"
title="Email Preview"
sandbox="allow-same-origin"
/>
</div>
)}
</div>
{/* Test Email Section */}
<div className="flex items-center gap-2 pt-2 border-t border-border/50">
<input
type="email"
value={testEmail}
onChange={(e) => setTestEmail(e.target.value)}
placeholder="test@example.com"
className="flex-1 p-2 rounded border border-border bg-secondary/50 text-sm focus:ring-2 focus:ring-primary outline-none"
/>
<button
onClick={handleSendTest}
disabled={sendingTest || !testEmail}
className="px-4 py-2 text-sm font-medium bg-secondary hover:bg-secondary/80 text-foreground rounded border border-border transition-colors disabled:opacity-50 flex items-center gap-2"
>
{sendingTest ? 'Sending...' : <><Send size={14} /> Send Test</>}
</button>
</div>
</div>
{/* Footer */}
<div className="p-4 border-t border-border flex justify-end gap-2 bg-secondary/10">
<button
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
>
Cancel
</button>
<button
onClick={handleSave}
className="px-6 py-2 text-sm font-medium bg-primary text-primary-foreground rounded hover:bg-primary/90 transition-colors"
>
Save Template
</button>
</div>
</div>
</div>
);
};
export default EmailTemplateEditor;

View File

@@ -0,0 +1,149 @@
import { FileText, Trash2, Calendar, Pencil, Check, X, Mail } from 'lucide-react';
import { useState } from 'react';
import EmailPreviewModal from './EmailPreviewModal';
import { SmtpConfig } from './Settings';
import { EmailTemplate } from '../App';
import { ToastType } from './ui/Toast';
interface HistoryItem {
id: string;
date: string;
transcription: string; // This might be raw text or path?
summary: string;
subject?: string;
filename?: string;
}
interface HistoryViewProps {
history: HistoryItem[];
onLoad: (item: HistoryItem) => void;
onDelete: (id: string) => void;
onRename: (id: string, newSubject: string) => void;
smtpConfig: SmtpConfig;
emailTemplates: EmailTemplate[];
addToast: (message: string, type: ToastType, duration?: number) => void;
}
export default function HistoryView({ history, onLoad, onDelete, onRename, smtpConfig, emailTemplates, addToast }: HistoryViewProps) {
const [editingId, setEditingId] = useState<string | null>(null);
const [editValue, setEditValue] = useState("");
const [emailModalItem, setEmailModalItem] = useState<HistoryItem | null>(null);
const startEditing = (item: HistoryItem) => {
setEditingId(item.id);
setEditValue(item.subject || "Untitled Recording");
};
const saveEdit = () => {
if (editingId && editValue.trim()) {
onRename(editingId, editValue.trim());
setEditingId(null);
}
};
const cancelEdit = () => {
setEditingId(null);
setEditValue("");
};
return (
<div className="flex flex-col w-full h-full bg-background p-6">
<h1 className="text-2xl font-bold mb-6 flex items-center gap-2">
<FileText className="w-8 h-8" />
Recording History
</h1>
{history.length === 0 ? (
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground">
<FileText size={48} className="mb-4 opacity-20" />
<p>No history found.</p>
</div>
) : (
<div className="flex-1 overflow-y-auto space-y-3 pr-2">
{history.map(item => (
<div key={item.id} className="bg-card border border-border rounded-xl p-4 hover:shadow-md transition-all group">
<div className="flex justify-between items-start">
<div className="flex-1">
{editingId === item.id ? (
<div className="flex items-center gap-2 mb-2" onClick={(e) => e.stopPropagation()}>
<input
autoFocus
type="text"
className="flex-1 bg-background border border-input rounded px-2 py-1 text-sm font-semibold focus:outline-none focus:ring-1 focus:ring-ring"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') saveEdit();
if (e.key === 'Escape') cancelEdit();
}}
/>
<button onClick={saveEdit} className="p-1 text-green-500 hover:bg-green-500/10 rounded">
<Check size={16} />
</button>
<button onClick={cancelEdit} className="p-1 text-muted-foreground hover:bg-muted rounded">
<X size={16} />
</button>
</div>
) : (
<div
className="cursor-pointer"
onClick={() => onLoad(item)}
>
<h3 className="text-lg font-semibold group-hover:text-primary transition-colors mb-1 flex items-center gap-2">
{item.subject || "Untitled Recording"}
<button
onClick={(e) => { e.stopPropagation(); startEditing(item); }}
className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-foreground p-1 rounded hover:bg-muted transition-all"
title="Rename"
>
<Pencil size={14} />
</button>
</h3>
</div>
)}
<div className="flex items-center gap-2 text-xs text-muted-foreground mb-2" onClick={() => !editingId && onLoad(item)}>
<Calendar size={12} />
{item.date}
{item.filename && <span className="bg-secondary px-1.5 py-0.5 rounded text-[10px] font-mono">{item.filename}</span>}
</div>
<p className="text-sm text-foreground/70 line-clamp-2 cursor-pointer" onClick={() => !editingId && onLoad(item)}>
{item.summary ? item.summary.substring(0, 150) + "..." : "No summary available."}
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={(e) => { e.stopPropagation(); setEmailModalItem(item); }}
className="text-muted-foreground hover:text-primary p-2 rounded-lg hover:bg-primary/10 transition-colors opacity-0 group-hover:opacity-100"
title="Send Email"
>
<Mail size={18} />
</button>
<button
onClick={(e) => { e.stopPropagation(); onDelete(item.id); }}
className="text-muted-foreground hover:text-destructive p-2 rounded-lg hover:bg-destructive/10 transition-colors opacity-0 group-hover:opacity-100"
title="Delete"
>
<Trash2 size={18} />
</button>
</div>
</div>
</div>
))}
</div>
)}
<EmailPreviewModal
isOpen={emailModalItem !== null}
onClose={() => setEmailModalItem(null)}
initialRecipients={[]}
initialSubject={emailModalItem?.subject || "Meeting Summary"}
initialBody={emailModalItem?.summary || ""}
emailTemplates={emailTemplates}
smtpConfig={smtpConfig ? { ...smtpConfig, port: Number(smtpConfig.port) } : null}
addToast={addToast}
/>
</div>
);
}

411
src/components/Import.tsx Normal file
View File

@@ -0,0 +1,411 @@
import React, { useState } from 'react';
import { Upload, FileAudio, X, Check, Loader2 } from 'lucide-react';
import { invoke } from "@tauri-apps/api/core";
import { open } from '@tauri-apps/plugin-dialog';
import logo from '../assets/logo.png';
interface PromptTemplate {
id: string;
name: string;
content: string;
keywords?: string[];
}
interface ImportProps {
apiKey: string;
productId: string;
prompts: PromptTemplate[];
selectedModel: string;
onSaveToHistory: (transcription: string, summary: string) => void;
onComplete: () => void; // Navigate to Transcription view
addToast: (msg: string, type: 'success' | 'error' | 'info', duration?: number) => void;
setTranscription: (text: string) => void;
setSummary: (text: string) => void;
}
interface AudioMetadata {
duration: number;
size: number;
format: string;
}
type ProcessingStage = 'idle' | 'validating' | 'transcribing' | 'summarizing' | 'complete';
const SUPPORTED_FORMATS = ['mp3', 'mp4', 'm4a', 'wav', 'flac', 'ogg', 'aac', 'wma'];
const Import: React.FC<ImportProps> = ({
apiKey,
productId,
prompts,
selectedModel,
onSaveToHistory,
onComplete,
addToast,
setTranscription,
setSummary
}) => {
const [selectedFile, setSelectedFile] = useState<string | null>(null);
const [metadata, setMetadata] = useState<AudioMetadata | null>(null);
const [meetingTitle, setMeetingTitle] = useState('');
const [stage, setStage] = useState<ProcessingStage>('idle');
const [selectedPromptId, setSelectedPromptId] = useState<string>('');
// Set default prompt
React.useEffect(() => {
if (prompts.length > 0 && !selectedPromptId) {
setSelectedPromptId(prompts[0].id);
}
}, [prompts, selectedPromptId]);
const validateFile = (filePath: string): boolean => {
const extension = filePath.split('.').pop()?.toLowerCase();
if (!extension || !SUPPORTED_FORMATS.includes(extension)) {
addToast(`Unsupported format. Supported: ${SUPPORTED_FORMATS.join(', ').toUpperCase()}`, 'error', 5000);
return false;
}
return true;
};
const extractFilename = (path: string): string => {
const parts = path.split(/[/\\]/);
const filename = parts[parts.length - 1];
return filename.replace(/\.[^/.]+$/, ''); // Remove extension
};
const formatDuration = (seconds: number): string => {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
const formatSize = (bytes: number): string => {
if (bytes < 1024 * 1024) {
return `${(bytes / 1024).toFixed(1)} KB`;
}
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
const handleFileSelect = async (filePath: string) => {
if (!validateFile(filePath)) return;
setStage('validating');
setSelectedFile(filePath);
setMeetingTitle(extractFilename(filePath));
try {
const meta = await invoke<AudioMetadata>('get_audio_metadata', { filePath });
setMetadata(meta);
setStage('idle');
addToast('File loaded successfully', 'success', 2000);
} catch (e) {
console.error('Metadata error:', e);
setMetadata(null);
setStage('idle');
}
};
const handleManualSelect = async () => {
try {
const selected = await open({
multiple: false,
filters: [{
name: 'Audio/Video',
extensions: SUPPORTED_FORMATS
}]
});
if (selected && typeof selected === 'string') {
handleFileSelect(selected);
}
} catch (e) {
console.error('File picker error:', e);
addToast('Failed to open file picker', 'error');
}
};
const handleProcess = async () => {
if (!selectedFile) return;
if (!apiKey || !productId) {
addToast('Please configure API key in Settings', 'error');
return;
}
try {
// Check file extension
const isWav = selectedFile.toLowerCase().endsWith('.wav');
let processFile = selectedFile;
// Convert WAV to MP3 if needed
if (isWav) {
setStage('validating');
addToast('Converting WAV to MP3...', 'info', 2000);
processFile = await invoke<string>('convert_to_mp3', { wavPath: selectedFile });
}
// Get file size to check if chunking needed
const metadata = await invoke<AudioMetadata>('get_audio_metadata', { filePath: processFile });
const sizeMB = metadata.size / (1024 * 1024);
let transText = '';
// Check if chunking needed for large files
if (sizeMB >= 18) {
// CHUNKING PATH for large files
setStage('validating');
addToast(`Large file (${sizeMB.toFixed(1)}MB). Splitting into chunks...`, 'info', 4000);
const chunks = await invoke<string[]>('chunk_audio', {
filePath: processFile,
chunkMinutes: 10
});
addToast(`Processing ${chunks.length} chunks...`, 'info', 4000);
let allTranscriptions: string[] = [];
for (let i = 0; i < chunks.length; i++) {
setStage('transcribing');
addToast(`Transcribing chunk ${i + 1}/${chunks.length}...`, 'info', 2000);
const chunkText = await invoke<string>('transcribe_audio', {
filePath: chunks[i],
apiKey,
productId
});
allTranscriptions.push(chunkText);
}
// Merge transcriptions
transText = allTranscriptions.join('\n\n--- Next Segment ---\n\n');
addToast('All chunks transcribed successfully!', 'success', 3000);
} else {
// NORMAL PATH for small files
setStage('transcribing');
transText = await invoke<string>('transcribe_audio', {
filePath: processFile,
apiKey,
productId
});
}
setTranscription(transText);
if (!transText || transText.trim().length === 0) {
addToast('No speech detected in file', 'error');
setStage('idle');
return;
}
// Smart prompt selection (copied from Recorder.tsx)
let activePrompt = prompts.find(p => p.id === selectedPromptId);
const lowerText = transText.toLowerCase();
let bestMatchId = selectedPromptId;
let maxMatches = 0;
for (const p of prompts) {
if (!p.keywords) continue;
let matches = 0;
for (const kw of p.keywords) {
if (lowerText.includes(kw.toLowerCase())) {
matches++;
}
}
if (matches > maxMatches) {
maxMatches = matches;
bestMatchId = p.id;
}
}
if (bestMatchId !== selectedPromptId) {
const newPrompt = prompts.find(p => p.id === bestMatchId);
if (newPrompt) {
addToast(`Smart Select: Switched to "${newPrompt.name}"`, 'info', 3000);
activePrompt = newPrompt;
}
}
const promptContent = activePrompt ? activePrompt.content : "Summarize this.";
setStage('summarizing');
const sumText = await invoke<string>('summarize_text', {
text: transText,
apiKey,
productId,
prompt: promptContent,
model: selectedModel
});
setSummary(sumText);
// Save to history
onSaveToHistory(transText, sumText);
setStage('complete');
addToast('Import complete!', 'success', 3000);
// Navigate to Transcription view
setTimeout(() => {
onComplete();
}, 1000);
} catch (e) {
console.error('Processing error:', e);
addToast(`Error: ${e}`, 'error');
setStage('idle');
}
};
const handleReset = () => {
setSelectedFile(null);
setMetadata(null);
setMeetingTitle('');
setStage('idle');
};
const getStageInfo = () => {
switch (stage) {
case 'validating': return { icon: Loader2, text: 'Validating file...', color: 'text-blue-500' };
case 'transcribing': return { icon: Loader2, text: 'Transcribing audio...', color: 'text-purple-500' };
case 'summarizing': return { icon: Loader2, text: 'Generating summary...', color: 'text-green-500' };
case 'complete': return { icon: Check, text: 'Complete!', color: 'text-green-500' };
default: return null;
}
};
const stageInfo = getStageInfo();
const isProcessing = stage !== 'idle' && stage !== 'complete';
return (
<div className="flex flex-col w-full h-full bg-background relative">
{/* Header */}
<div className="w-full flex justify-center items-center p-4 shrink-0">
<img src={localStorage.getItem('customLogo') || logo} alt="Logo" className="h-10 object-contain" />
</div>
{/* Main Content */}
<div className="flex-1 overflow-y-auto px-6 pb-6 flex flex-col items-center">
<h1 className="text-xl font-bold mb-2 text-foreground">Import Audio File</h1>
<p className="text-muted-foreground mb-6 text-center text-sm">
Select an audio file for transcription and summarization
</p>
{/* File Selection Zone */}
<div
className={`w-full max-w-md border-2 border-dashed rounded-lg p-8 mb-6 transition-all ${selectedFile
? 'border-green-500 bg-green-500/5'
: 'border-border bg-secondary/30'
}`}
>
<div className="flex flex-col items-center justify-center gap-4">
{selectedFile ? (
<>
<FileAudio size={48} className="text-green-500" />
<div className="text-center">
<p className="font-semibold text-foreground">{meetingTitle}</p>
{metadata && (
<p className="text-xs text-muted-foreground mt-1">
{formatDuration(metadata.duration)} {formatSize(metadata.size)} {metadata.format.toUpperCase()}
</p>
)}
</div>
<button
onClick={handleReset}
className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-1"
>
<X size={14} /> Change file
</button>
</>
) : (
<>
<Upload size={48} className="text-muted-foreground" />
<div className="text-center">
<p className="font-semibold text-foreground">Select Audio File</p>
<p className="text-xs text-muted-foreground mt-1">
Click below to browse your files
</p>
</div>
<button
onClick={handleManualSelect}
disabled={isProcessing}
className="px-6 py-3 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-50 text-base font-semibold transition-all shadow-md hover:shadow-lg"
>
Browse Files
</button>
<p className="text-xs text-muted-foreground">
Supported: MP3, MP4, WAV, M4A, FLAC, OGG, AAC, WMA
</p>
</>
)}
</div>
</div>
{/* Configuration Section */}
{selectedFile && (
<div className="w-full max-w-md space-y-4">
{/* Meeting Title */}
<div>
<label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider block mb-1">
Meeting Title
</label>
<input
type="text"
value={meetingTitle}
onChange={(e) => setMeetingTitle(e.target.value)}
disabled={isProcessing}
className="w-full p-2 text-sm bg-secondary rounded border border-border outline-none focus:ring-2 focus:ring-primary disabled:opacity-50"
placeholder="Enter meeting title..."
/>
</div>
{/* AI Template */}
<div>
<label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider block mb-1">
AI Template
</label>
<select
value={selectedPromptId}
onChange={(e) => setSelectedPromptId(e.target.value)}
disabled={isProcessing || prompts.length === 0}
className="w-full p-2 text-sm bg-secondary rounded border border-border outline-none focus:ring-2 focus:ring-primary disabled:opacity-50"
>
{prompts.map(p => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
{prompts.length === 0 && <option value="">No templates</option>}
</select>
</div>
{/* Process Button */}
<button
onClick={handleProcess}
disabled={!selectedFile || isProcessing || !apiKey}
className="w-full py-3 text-base font-semibold bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-md hover:shadow-lg flex items-center justify-center gap-2"
>
{isProcessing ? (
<>
<Loader2 size={20} className="animate-spin" />
Processing...
</>
) : (
<>
<Upload size={20} />
Transcribe & Summarize
</>
)}
</button>
{/* Progress Indicator */}
{stageInfo && (
<div className="flex items-center justify-center gap-2 p-3 bg-secondary/50 rounded-lg border border-border">
<stageInfo.icon size={16} className={`${stageInfo.color} ${stage !== 'complete' ? 'animate-spin' : ''}`} />
<span className={`text-sm font-medium ${stageInfo.color}`}>
{stageInfo.text}
</span>
</div>
)}
</div>
)}
</div>
</div>
);
};
export default Import;

View File

@@ -0,0 +1,351 @@
import { useState, useEffect } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { Calendar, RefreshCw, LogIn, Video } from 'lucide-react';
import { openUrl } from '@tauri-apps/plugin-opener';
interface CalendarEvent {
id: string;
subject: string;
start: { dateTime: string, timeZone: string };
end: { dateTime: string, timeZone: string };
onlineMeeting?: { joinUrl: string };
location?: { displayName: string };
bodyPreview?: string; // Text preview
body?: { content: string, contentType: string }; // Full HTML/Text
attendees?: { emailAddress: { name: string, address: string }, type: string, status: { response: string } }[];
}
interface MeetingsViewProps {
onStartRecording: (subject?: string) => void;
azureClientId: string;
apiKey: string;
productId: string;
selectedModel: string;
onModelChange: (model: string) => void;
}
export default function MeetingsView({ onStartRecording, azureClientId, apiKey, productId, selectedModel, onModelChange }: MeetingsViewProps) {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [token, setToken] = useState(localStorage.getItem('m365_token') || '');
// const [clientId, setClientId] = useState(azureClientId); // Use prop directly
// Sync prop to state if needed, or just use prop.
// Let's us prop directly in startAuthFlow
const [events, setEvents] = useState<CalendarEvent[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
const [availableModels, setAvailableModels] = useState<Array<{ id: string, name: string }>>([]);
const toggleExpand = (id: string) => {
const newSet = new Set(expandedIds);
if (newSet.has(id)) {
newSet.delete(id);
} else {
newSet.add(id);
}
setExpandedIds(newSet);
};
useEffect(() => {
if (apiKey && productId) {
loadModels();
}
}, [apiKey, productId]);
const loadModels = async () => {
try {
const models = await invoke<Array<{ id: string, name: string }>>('get_available_models', { apiKey, productId });
if (models && models.length > 0) {
models.sort((a, b) => a.name.localeCompare(b.name));
setAvailableModels(models);
}
} catch (e) {
console.error("Failed to load models:", e);
}
};
useEffect(() => {
if (token) {
setIsAuthenticated(true);
fetchEvents(token);
}
}, [token]);
const handleLogin = async () => {
if (!azureClientId) {
setError("Please configure Client ID in Settings");
return;
}
localStorage.setItem('m365_client_id', azureClientId);
setLoading(true);
setError('');
try {
const accessToken = await invoke<string>('start_auth_flow', { clientId: azureClientId });
setToken(accessToken);
localStorage.setItem('m365_token', accessToken);
setIsAuthenticated(true);
fetchEvents(accessToken);
} catch (err) {
console.error("Auth failed", err);
setError(String(err)); // Use String() to safely convert error object
} finally {
setLoading(false);
}
};
const fetchEvents = async (authToken: string) => {
setLoading(true);
setError('');
try {
const data = await invoke<CalendarEvent[]>('get_calendar_events', { token: authToken });
// Sort by start time
const sorted = data.sort((a, b) => new Date(a.start.dateTime).getTime() - new Date(b.start.dateTime).getTime());
setEvents(sorted);
} catch (err) {
console.error("Fetch failed", err);
setError(`Fetch failed: ${err}`);
// If error is 401, logout
if (String(err).includes('401')) {
logout();
}
} finally {
setLoading(false);
}
};
const logout = () => {
setToken('');
localStorage.removeItem('m365_token');
setIsAuthenticated(false);
setEvents([]);
};
const handleJoin = async (joinUrl?: string, subject?: string) => {
if (!joinUrl) return;
try {
// 1. Open URL
await openUrl(joinUrl);
// 2. Start Recording (wait a sec for app focus switch?)
// Actually user might want to confirm recording? Protocol says "one-click".
onStartRecording(subject);
} catch (e) {
console.error("Failed to join", e);
}
};
const formatTime = (isoString: string) => {
return new Date(isoString).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
};
const formatDate = (isoString: string) => {
const date = new Date(isoString);
const today = new Date();
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
if (date.toDateString() === today.toDateString()) return "Today";
if (date.toDateString() === tomorrow.toDateString()) return "Tomorrow";
return date.toLocaleDateString([], { weekday: 'long', month: 'long', day: 'numeric' });
};
// Group events by date
const groupedEvents = events.reduce((groups, event) => {
const dateKey = formatDate(event.start.dateTime);
if (!groups[dateKey]) {
groups[dateKey] = [];
}
groups[dateKey].push(event);
return groups;
}, {} as Record<string, CalendarEvent[]>);
return (
<div className="flex flex-col w-full h-full bg-background p-6">
<h1 className="text-2xl font-bold mb-6 flex items-center gap-2">
<Calendar className="w-8 h-8" />
Upcoming Meetings
</h1>
{/* Auth Section */}
{!isAuthenticated ? (
<div className="flex flex-col items-center justify-center flex-1 gap-6 text-center max-w-md mx-auto">
<div className="bg-secondary/30 p-8 rounded-xl border border-border">
<Calendar className="w-16 h-16 text-muted-foreground mx-auto mb-4" />
<h2 className="text-lg font-semibold mb-2">Connect Microsoft 365</h2>
<p className="text-sm text-muted-foreground mb-6">
Connect your account to see upcoming Teams & Zoom meetings and join them with one click.
</p>
<div className="flex flex-col gap-3">
<div className="text-sm p-3 bg-secondary/50 rounded border border-border text-center">
{azureClientId ? (
<span className="text-green-600 font-medium">Client ID Configured</span>
) : (
<span className="text-destructive font-medium">Client ID Missing in Settings</span>
)}
</div>
<button
onClick={handleLogin}
disabled={loading || !azureClientId}
className="bg-primary text-primary-foreground px-4 py-2 rounded-md text-sm flex items-center justify-center gap-2 hover:opacity-90 disabled:opacity-50 w-full transition-all"
>
{loading ? <RefreshCw className="animate-spin" size={16} /> : <LogIn size={16} />}
Connect Account
</button>
</div>
{error && (
<div className="mt-4 p-3 bg-destructive/10 text-destructive text-xs rounded-md text-left break-all">
<strong>Error:</strong> {error}
</div>
)}
<p className="text-[10px] text-muted-foreground mt-4 px-2">
Note: Requires an Azure App Registration (Multitenant) with redirect URI: <br />
<code className="bg-secondary px-1 rounded">http://localhost:14200/auth/callback</code>
</p>
</div>
</div>
) : (
<div className="flex flex-col flex-1 overflow-hidden">
<div className="flex justify-between items-center mb-4 px-1">
<span className="text-sm text-muted-foreground font-medium">Next 7 Days</span>
<div className="flex items-center gap-3">
{/* Model Selector */}
<div className="flex items-center gap-2 bg-secondary/50 p-1 rounded-lg border border-border/50">
<span className="text-[10px] uppercase font-bold text-muted-foreground pl-2">Using:</span>
<select
value={selectedModel}
onChange={(e) => onModelChange(e.target.value)}
className="bg-transparent text-xs font-medium outline-none text-foreground cursor-pointer"
>
{availableModels.map(m => (
<option key={m.id} value={m.id}>{m.name}</option>
))}
</select>
</div>
<button onClick={() => fetchEvents(token)} disabled={loading} className="text-muted-foreground hover:text-foreground p-1 rounded hover:bg-secondary transition-colors" title="Refresh">
<RefreshCw size={16} className={loading ? 'animate-spin' : ''} />
</button>
<button onClick={logout} className="text-muted-foreground hover:text-destructive p-1 rounded hover:bg-destructive/10 transition-colors" title="Logout">
<LogIn size={16} className="rotate-180" />
</button>
</div>
</div>
{events.length === 0 && !loading && (
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground">
{/* No meetings empty state (only if no error) */}
<Calendar size={48} className="mb-4 opacity-20" />
<p>No upcoming meetings found for the next 7 days.</p>
</div>
)}
{error && (
<div className="m-4 p-3 bg-destructive/10 text-destructive text-sm rounded-md flex items-center justify-between">
<span>{error}</span>
<button onClick={() => fetchEvents(token)} className="underline hover:no-underline ml-2">Retry</button>
</div>
)}
<div className="flex-1 overflow-y-auto pr-2 space-y-6 pb-20">
{Object.entries(groupedEvents).map(([dateLabel, dateEvents]) => (
<div key={dateLabel} className="space-y-3">
<div className="sticky top-0 z-10 bg-background/95 backdrop-blur py-2 border-b border-border/50 text-xs font-bold uppercase tracking-wider text-muted-foreground">
{dateLabel}
</div>
{dateEvents.map(event => (
<div key={event.id} className="bg-card border border-border rounded-xl p-4 hover:shadow-md transition-all group">
<div className="flex justify-between items-start">
<div className="flex flex-col gap-1">
<div className="flex items-baseline gap-2">
<span className="text-lg font-mono font-medium text-foreground">
{formatTime(event.start.dateTime)}
</span>
<span className="text-xs text-muted-foreground">
- {formatTime(event.end.dateTime)}
</span>
</div>
<h3 className="text-base font-semibold group-hover:text-primary transition-colors leading-tight">
{event.subject}
</h3>
{event.location?.displayName && (
<div className="text-xs text-muted-foreground flex items-center gap-1">
📍 {event.location.displayName}
</div>
)}
</div>
{event.onlineMeeting?.joinUrl ? (
<button
onClick={() => handleJoin(event.onlineMeeting?.joinUrl, event.subject)}
className="shrink-0 bg-green-600 hover:bg-green-700 text-white p-2 rounded-lg shadow-sm hover:shadow transition-all flex flex-col items-center justify-center gap-0.5 min-w-[70px]"
title={`Join & Summarize with ${selectedModel}`}
>
<Video size={16} />
<span className="text-[10px] font-bold">JOIN</span>
</button>
) : (
<div className="px-2 py-1 bg-secondary text-muted-foreground text-[10px] rounded italic">
No Link
</div>
)}
</div>
{/* Expand/Collapse Button */}
<button
onClick={() => toggleExpand(event.id)}
className="text-[10px] text-muted-foreground hover:text-primary mt-2 flex items-center gap-1 transition-colors w-full justify-center py-0.5 bg-secondary/30 hover:bg-secondary/50 rounded"
>
{expandedIds.has(event.id) ? "Hide Details" : "Show Details"}
</button>
{/* Expanded Content */}
{expandedIds.has(event.id) && (
<div className="mt-2 text-xs text-foreground/80 bg-background/50 p-3 rounded border border-border/50 animate-in fade-in slide-in-from-top-1">
{event.body?.content ? (
<div
className="prose prose-xs dark:prose-invert max-w-none break-words"
dangerouslySetInnerHTML={{ __html: event.body.content }}
/>
) : (
<p className="whitespace-pre-wrap">{event.bodyPreview || "No details available."}</p>
)}
{event.attendees && event.attendees.length > 0 && (
<div className="mt-3 pt-3 border-t border-border/50">
<h4 className="text-xs font-semibold text-foreground mb-2 flex items-center gap-2">
👥 Attendees ({event.attendees.length})
</h4>
<div className="flex flex-wrap gap-1.5">
{event.attendees.map((att, i) => (
<div key={i} className="flex items-center gap-1.5 bg-secondary/50 border border-border/50 px-2 py-1 rounded text-xs transition-colors">
<div className={`w-1.5 h-1.5 rounded-full shrink-0 ${att.status.response === 'accepted' ? 'bg-green-500 shadow-[0_0_4px_rgba(34,197,94,0.4)]' :
att.status.response === 'declined' ? 'bg-red-500' : 'bg-yellow-500'
}`} title={`Status: ${att.status.response}`} />
<span className="truncate max-w-[150px]" title={att.emailAddress.address}>
{att.emailAddress.name || att.emailAddress.address}
</span>
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
))}
</div>
))}
</div>
</div>
)}
</div>
);
}

View File

@@ -1,12 +1,14 @@
import React, { useState, useEffect } from 'react';
import { Mic, Square } from 'lucide-react';
import React, { useState, useEffect, useRef } from 'react';
import { Mic, Square, Users, Headphones } from 'lucide-react';
import { invoke } from "@tauri-apps/api/core";
import { listen } from '@tauri-apps/api/event';
import logo from '../assets/logo.png'; // Import logo
interface PromptTemplate {
id: string;
name: string;
content: string;
keywords?: string[];
}
interface HistoryItem {
@@ -32,7 +34,15 @@ interface RecorderProps {
onDeleteHistory: (id: string) => void;
onLoadHistory: (item: HistoryItem) => void;
savePath: string | null;
onRecordingComplete: () => void;
autoStart?: boolean;
recordingSubject?: string;
onAutoStartHandled?: () => void;
addToast: (msg: string, type: 'success' | 'error' | 'info', duration?: number) => void;
selectedModel: string;
onModelChange: (model: string) => void;
isVisible: boolean;
}
interface AudioDevice {
@@ -43,16 +53,31 @@ interface AudioDevice {
const Recorder: React.FC<RecorderProps> = ({
apiKey, productId, prompts,
setTranscription, setSummary,
onSaveToHistory, savePath, onRecordingComplete
onSaveToHistory, savePath, onRecordingComplete,
onOpenSettings, addToast, selectedModel, onModelChange, ...props
}) => {
const [isRecording, setIsRecording] = useState(false);
const [isStopping, setIsStopping] = useState(false); // New lock state
const [isPaused, setIsPaused] = useState(false);
const [isWaiting, setIsWaiting] = useState(false); // New state for Auto-Start
const [autoStartEnabled, setAutoStartEnabled] = useState(false); // Toggle state
const [status, setStatus] = useState<string>('Ready to record');
const [selectedDevice, setSelectedDevice] = useState<string>('');
const [selectedPromptId, setSelectedPromptId] = useState<string>('');
const [selectedModel, setSelectedModel] = useState<string>('mixtral');
// selectedModel is now a prop
const [recordingMode, setRecordingMode] = useState<'voice' | 'meeting'>('voice');
const [devices, setDevices] = useState<AudioDevice[]>([]);
const [availableModels, setAvailableModels] = useState<Array<{ id: string, name: string }>>([]);
const [lastSpeechTime, setLastSpeechTime] = useState<number>(Date.now());
const [silenceDuration, setSilenceDuration] = useState(0);
// Filtered devices based on mode
const filteredDevices = devices.filter(d => {
const isVirtual = d.name.toLowerCase().includes('hearbit') || d.name.toLowerCase().includes('blackhole');
return recordingMode === 'meeting' ? isVirtual : !isVirtual;
});
useEffect(() => {
loadDevices();
@@ -95,12 +120,21 @@ const Recorder: React.FC<RecorderProps> = ({
setDevices(aliasedDevs);
// Select Hearbit mic by default if available and no selection made
// Smart Auto-select based on mode
if (!selectedDevice) {
const vb = aliasedDevs.find(d => d.name.includes('Hearbit Virtual Mic'));
if (vb) {
setSelectedDevice(vb.id);
} else if (aliasedDevs.length > 0) {
setSelectedDevice(aliasedDevs[0].id);
// Prioritize "Hearbit Audio" (Aggregate) over "Hearbit Virtual Mic" (BlackHole)
const aggregateDev = aliasedDevs.find(d => d.name === 'Hearbit Audio');
const virtualDev = aliasedDevs.find(d => d.name.includes('Hearbit Virtual'));
if (aggregateDev) {
setRecordingMode('meeting');
setSelectedDevice(aggregateDev.id);
} else if (virtualDev) {
setRecordingMode('meeting');
setSelectedDevice(virtualDev.id);
} else {
setRecordingMode('voice');
if (aliasedDevs.length > 0) setSelectedDevice(aliasedDevs[0].id);
}
}
} catch (e) {
@@ -113,26 +147,176 @@ const Recorder: React.FC<RecorderProps> = ({
await invoke('open_audio_midi_setup');
} catch (e) {
console.error(e);
addToast('Failed to open Audio Setup', 'error');
setStatus('Failed to open Audio Setup');
}
};
const startRecording = async () => {
const startRecording = async (deviceIdOverride?: string) => {
try {
setStatus('Starting...');
await invoke('start_recording', { deviceId: selectedDevice, savePath: savePath || null });
// Check override or state
const targetDeviceId = deviceIdOverride || selectedDevice;
// Pass customFilename (camelCase key maps to snake_case in Rust automatically or we need to check Tauri mapping, usually it maps camel to camel? Rust expects snake. Let's use snake_case in invoke args to be safe)
await invoke('start_recording', {
deviceId: targetDeviceId,
savePath: savePath || null,
customFilename: props.recordingSubject || null,
waitForSpeech: autoStartEnabled // Pass the toggle state
});
setIsRecording(true);
setIsPaused(false);
setTranscription('');
setSummary('');
setStatus('Recording...');
if (autoStartEnabled) {
setIsWaiting(true);
setStatus('Waiting for audio...');
addToast('Standing by for audio...', 'info', 3000);
} else {
setIsWaiting(false);
setStatus('Recording...');
addToast('Recording started', 'success', 2000);
}
} catch (e) {
console.error(e);
setStatus(`Error: ${e}`);
addToast(`Error starting recording: ${e}`, 'error');
setIsRecording(false);
}
};
// Refs for interval access to avoid dependency cycles
const lastSpeechTimeRef = useRef<number>(Date.now());
const isStoppingRef = useRef(false);
// Update refs when state changes
useEffect(() => {
lastSpeechTimeRef.current = lastSpeechTime;
}, [lastSpeechTime]);
useEffect(() => {
isStoppingRef.current = isStopping;
}, [isStopping]);
// 1. Event Listeners Effect (Run ONCE when recording starts)
useEffect(() => {
let unlistenVAD: () => void;
let unlistenTrigger: () => void;
const setupListeners = async () => {
if (!isRecording) return;
console.log("Setting up VAD listeners...");
// VAD Event Listener
unlistenVAD = await listen<{ is_speech: boolean, probability: number }>('vad-event', (event) => {
if (event.payload.is_speech) {
setLastSpeechTime(Date.now());
lastSpeechTimeRef.current = Date.now(); // Update ref immediately
setSilenceDuration(0);
}
});
// Auto-Start Trigger Listener
unlistenTrigger = await listen('auto-recording-triggered', () => {
console.log("Auto-Start Triggered from Backend!");
// Only trigger if we are actually waiting
setIsWaiting((prev) => {
if (prev) {
addToast("Audio detected! Recording started.", 'success', 4000);
return false;
}
return prev;
});
setStatus('Recording (Auto-Started)...');
setLastSpeechTime(Date.now());
});
};
if (isRecording) {
setupListeners();
}
return () => {
// Cleanup listeners
if (unlistenVAD) unlistenVAD();
if (unlistenTrigger) unlistenTrigger();
};
}, [isRecording, addToast]); // Dependencies for listener setup
// Ref for visibility to avoid closure staleness in interval
const isVisibleRef = useRef(props.isVisible);
useEffect(() => {
isVisibleRef.current = props.isVisible;
}, [props.isVisible]);
// Auto-Stop Interval Effect
useEffect(() => {
if (!isRecording || isPaused || isWaiting) return;
const interval = setInterval(() => {
const now = Date.now();
const timeSinceSpeech = (now - lastSpeechTimeRef.current) / 1000;
setSilenceDuration(timeSinceSpeech);
// AUTO STOP Logic
// Use Ref to get LATEST visibility instantly
if (isVisibleRef.current && timeSinceSpeech > 20 && !isStoppingRef.current) {
console.log("Auto-stopping due to silence...");
isStoppingRef.current = true;
addToast('Auto-stopped due to silence', 'info');
stopRecording();
}
}, 1000);
return () => clearInterval(interval);
}, [isRecording, isPaused, isWaiting, recordingMode, addToast]); // Removed props.isVisible dependency (using Ref)
// Handle Auto Start Prop
useEffect(() => {
if (props.autoStart && !isRecording && devices.length > 0) {
// Force meeting mode for auto-joins
if (recordingMode !== 'meeting') {
setRecordingMode('meeting');
}
// Find best device (Race condition fix: we can't rely on selectedDevice state update being instant)
const aggregateDev = devices.find(d => d.name === 'Hearbit Audio');
const virtualDev = devices.find(d => d.name.includes('Hearbit Virtual'));
const bestDevice = aggregateDev || virtualDev;
if (bestDevice) {
setSelectedDevice(bestDevice.id); // Update UI state for consistency
console.log("Auto-starting with device:", bestDevice.name);
startRecording(bestDevice.id); // Pass ID directly
} else {
console.warn("Auto-start: No meeting device found, trying default.");
startRecording();
}
if (props.onAutoStartHandled) {
props.onAutoStartHandled();
}
}
}, [props.autoStart, devices]);
// Handle Custom Event (Legacy/Fallback)
useEffect(() => {
const handleStartReq = () => {
if (!isRecording) {
if (recordingMode !== 'meeting') {
setRecordingMode('meeting');
}
startRecording();
}
};
window.addEventListener('start-recording-req', handleStartReq);
return () => window.removeEventListener('start-recording-req', handleStartReq);
}, [isRecording, recordingMode]);
const togglePause = async () => {
try {
if (isPaused) {
@@ -150,18 +334,72 @@ const Recorder: React.FC<RecorderProps> = ({
};
const stopRecording = async () => {
if (isStopping) return;
setIsStopping(true);
try {
setIsRecording(false);
setIsPaused(false);
setStatus('Processing...');
setIsWaiting(false); // Reset waiting state
setStatus('Saving recording...');
const filePath = await invoke<string>('stop_recording');
setStatus('Transcribing (Infomaniak Whisper)...');
const transText = await invoke<string>('transcribe_audio', {
filePath,
apiKey,
productId
});
// Wait a moment for file flush (safety)
await new Promise(r => setTimeout(r, 500));
// Confirm recording saved
addToast(`Recording saved locally: ${filePath.split('/').pop()}`, 'success', 3000);
setStatus('Converting to MP3...');
// Small delay to show the "saved" message
await new Promise(r => setTimeout(r, 500));
// Convert WAV to MP3 for smaller size
const mp3Path = await invoke<string>('convert_to_mp3', { wavPath: filePath });
// Get file size to check if chunking needed
interface AudioMetadata { duration: number; size: number; format: string; }
const metadata = await invoke<AudioMetadata>('get_audio_metadata', { filePath: mp3Path });
const sizeMB = metadata.size / (1024 * 1024);
let transText = '';
// Check if chunking needed (only for Meeting mode and large files)
if (recordingMode === 'meeting' && sizeMB >= 18) {
// CHUNKING PATH for large meetings
setStatus(`Large file (${sizeMB.toFixed(1)}MB). Splitting into chunks...`);
const chunks = await invoke<string[]>('chunk_audio', {
filePath: mp3Path,
chunkMinutes: 10
});
addToast(`Processing ${chunks.length} chunks...`, 'info', 4000);
let allTranscriptions: string[] = [];
for (let i = 0; i < chunks.length; i++) {
setStatus(`Transcribing chunk ${i + 1}/${chunks.length}...`);
const chunkText = await invoke<string>('transcribe_audio', {
filePath: chunks[i],
apiKey,
productId
});
allTranscriptions.push(chunkText);
}
// Merge transcriptions
transText = allTranscriptions.join('\n\n--- Next Segment ---\n\n');
addToast('All chunks transcribed successfully!', 'success', 3000);
} else {
// NORMAL PATH for small files
setStatus('Transcribing (Infomaniak Whisper)...');
transText = await invoke<string>('transcribe_audio', {
filePath: mp3Path,
apiKey,
productId
});
}
setTranscription(transText);
// Check if transcription is empty or just whitespace
@@ -172,8 +410,40 @@ const Recorder: React.FC<RecorderProps> = ({
return;
}
// Find selected prompt content
const activePrompt = prompts.find(p => p.id === selectedPromptId);
// Find selected prompt content - SMART SELECTION
let activePrompt = prompts.find(p => p.id === selectedPromptId);
// Smart Auto-Select based on keywords
const lowerText = transText.toLowerCase();
let bestMatchId = selectedPromptId;
let maxMatches = 0;
for (const p of prompts) {
if (!p.keywords) continue;
let matches = 0;
for (const kw of p.keywords) {
if (lowerText.includes(kw.toLowerCase())) {
matches++;
}
}
if (matches > maxMatches) {
maxMatches = matches;
bestMatchId = p.id;
}
}
if (bestMatchId !== selectedPromptId) {
const newPrompt = prompts.find(p => p.id === bestMatchId);
if (newPrompt) {
console.log(`Smart Select: Switched to '${newPrompt.name}' with ${maxMatches} matches.`);
setStatus(`Smart Select: Using "${newPrompt.name}"...`);
addToast(`Smart Select: Switched to "${newPrompt.name}"`, 'success', 4000);
activePrompt = newPrompt;
// Optional: Update UI selection? setSelectedPromptId(bestMatchId);
// Let's verify with user preference? For now, we override as "Magic".
}
}
const promptContent = activePrompt ? activePrompt.content : "Summarize this.";
setStatus(`Summarizing (${selectedModel})...`);
@@ -190,101 +460,153 @@ const Recorder: React.FC<RecorderProps> = ({
onSaveToHistory(transText, sumText);
setStatus('Done!');
addToast('Transcription & Summary complete!', 'success', 4000);
onRecordingComplete(); // Auto-switch tab
setTimeout(() => setStatus('Ready to record'), 3000);
} catch (e) {
console.error(e);
setStatus(`Error: ${e}`);
addToast(`Error processing: ${e}`, 'error');
} finally {
setIsStopping(false);
}
};
return (
<div className="flex flex-col w-full h-full bg-background relative">
{/* Fixed Header */}
<div className="w-full flex justify-center items-center p-6 shrink-0">
<img src={logo} alt="Logo" className="h-12 object-contain" />
{/* Fixed Header - Reduced padding */}
<div className="w-full flex justify-center items-center p-4 shrink-0">
<img src={localStorage.getItem('customLogo') || logo} alt="Logo" className="h-10 object-contain" />
</div>
{/* Scrollable Content */}
<div className="flex-1 overflow-y-auto p-6 flex flex-col items-center pb-20">
<div className="mb-6 relative shrink-0">
<div className={`w-32 h-32 rounded-full flex items-center justify-center transition-all duration-300 ${isRecording ? (isPaused ? 'bg-yellow-500/10' : 'bg-red-500/10 animate-pulse') : 'bg-secondary'}`}>
{/* Scrollable Content - Reduced spacing */}
<div className="flex-1 overflow-y-auto px-6 pb-6 flex flex-col items-center">
<div className="mb-4 relative shrink-0">
<div className={`w-24 h-24 rounded-full flex items-center justify-center transition-all duration-300 ${isRecording ? (isWaiting ? 'bg-blue-500/20' : isPaused ? 'bg-yellow-500/10' : 'bg-red-500/10 animate-pulse') : 'bg-secondary'}`}>
{isRecording ? (
<div className={`w-24 h-24 rounded-full flex items-center justify-center shadow-[0_0_20px_rgba(239,68,68,0.5)] ${isPaused ? 'bg-yellow-500' : 'bg-red-500'}`}>
<Mic size={40} className="text-white animate-bounce" />
<div className={`w-16 h-16 rounded-full flex items-center justify-center shadow-[0_0_20px_rgba(239,68,68,0.5)] ${isWaiting ? 'bg-blue-500 animate-pulse' : isPaused ? 'bg-yellow-500' : 'bg-red-500'}`}>
<Mic size={32} className="text-white animate-bounce" />
</div>
) : (
<div className="w-24 h-24 rounded-full bg-primary flex items-center justify-center">
<Mic size={40} className="text-primary-foreground" />
<div className="w-16 h-16 rounded-full bg-primary flex items-center justify-center">
<Mic size={32} className="text-primary-foreground" />
</div>
)}
</div>
</div>
<h1 className="text-2xl font-bold mb-2 text-foreground">
{isRecording ? (isPaused ? 'Paused' : 'Listening...') : 'Ready to Record'}
<h1 className="text-xl font-bold mb-1 text-foreground">
{isRecording ? (isWaiting ? 'Waiting for Audio...' : isPaused ? 'Paused' : 'Listening...') : 'Ready to Record'}
</h1>
<p className="text-muted-foreground mb-6 text-center text-sm h-6">
<p className="text-muted-foreground mb-4 text-center text-xs h-5">
{status}
{isRecording && !isPaused && !isWaiting && silenceDuration > 10 && (
<span className="block text-xs text-yellow-500 mt-0.5 opacity-80">
Silence detected: {Math.floor(silenceDuration)}s
</span>
)}
</p>
<div className="w-full max-w-sm space-y-4 mb-6 shrink-0">
<div className="w-full max-w-sm space-y-3 mb-4 shrink-0">
{!isRecording ? (
<button
onClick={startRecording}
disabled={!apiKey || !productId}
className="w-full py-4 text-lg font-semibold bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-md hover:shadow-lg"
>
{!apiKey ? 'Configure API Key First' : 'Start Recording'}
</button>
<>
<button
onClick={() => startRecording()}
disabled={!apiKey || !productId}
className="w-full py-3 text-base font-semibold bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-md hover:shadow-lg"
>
{!apiKey ? 'Configure API Key First' : (autoStartEnabled ? 'Standby (Auto-Start)' : 'Start Recording')}
</button>
<div className="flex items-center justify-center gap-2 mt-2">
<label className="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
checked={autoStartEnabled}
onChange={(e) => setAutoStartEnabled(e.target.checked)}
className="w-4 h-4 accent-primary rounded cursor-pointer"
/>
<span className="text-xs text-muted-foreground font-medium">Auto-start when audio detected</span>
</label>
</div>
</>
) : (
<div className="flex gap-2 w-full">
<button
onClick={togglePause}
className={`flex-1 py-4 text-lg font-semibold rounded-lg transition-all shadow-md hover:shadow-lg flex items-center justify-center gap-2 ${isPaused
? 'bg-blue-600 text-white hover:bg-blue-700'
: 'bg-yellow-500 text-white hover:bg-yellow-600'
}`}
>
{isPaused ? 'Resume' : 'Pause'}
</button>
{/* In Waiting mode, we can only Stop (Cancel) */}
{!isWaiting && (
<button
onClick={togglePause}
className={`flex-1 py-4 text-lg font-semibold rounded-lg transition-all shadow-md hover:shadow-lg flex items-center justify-center gap-2 ${isPaused
? 'bg-blue-600 text-white hover:bg-blue-700'
: 'bg-yellow-500 text-white hover:bg-yellow-600'
}`}
>
{isPaused ? 'Resume' : 'Pause'}
</button>
)}
<button
onClick={stopRecording}
className="flex-1 py-4 text-lg font-semibold bg-destructive text-destructive-foreground rounded-lg hover:bg-destructive/90 transition-all shadow-md hover:shadow-lg flex items-center justify-center gap-2"
>
<Square size={20} fill="currentColor" />
Stop
{isWaiting ? 'Cancel' : 'Stop'}
</button>
</div>
)}
<div className="grid grid-cols-2 gap-4 pt-2">
<div>
<label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider block mb-1">
Input Device
</label>
<select
value={selectedDevice}
onChange={(e) => setSelectedDevice(e.target.value)}
className="w-full p-2 text-sm bg-secondary rounded border border-border outline-none focus:ring-2 focus:ring-primary"
disabled={isRecording}
</div>
{/* INPUT DEVICE SECTION */}
<div className="col-span-2">
<div className="flex bg-secondary p-1 rounded-lg mb-2">
<button
onClick={() => { setRecordingMode('voice'); setSelectedDevice(''); }}
className={`flex-1 flex items-center justify-center gap-2 py-1.5 text-xs font-semibold rounded-md transition-all ${recordingMode === 'voice' ? 'bg-background shadow text-foreground' : 'text-muted-foreground hover:text-foreground'}`}
>
{devices.map(d => (
<option key={d.id} value={d.id}>{d.name}</option>
))}
{devices.length === 0 && <option value="">Loading devices...</option>}
</select>
<Headphones size={14} /> Voice Memo
</button>
<button
onClick={() => { setRecordingMode('meeting'); setSelectedDevice(''); }}
className={`flex-1 flex items-center justify-center gap-2 py-1.5 text-xs font-semibold rounded-md transition-all ${recordingMode === 'meeting' ? 'bg-background shadow text-foreground' : 'text-muted-foreground hover:text-foreground'}`}
>
<Users size={14} /> Meeting
</button>
</div>
<select
value={selectedDevice}
onChange={(e) => setSelectedDevice(e.target.value)}
className="w-full p-2 text-sm bg-secondary rounded border border-border outline-none focus:ring-2 focus:ring-primary"
disabled={isRecording}
>
{filteredDevices.map(d => (
<option key={d.id} value={d.id}>{d.name}</option>
))}
{filteredDevices.length === 0 && (
<option value="">
{recordingMode === 'meeting' ? 'No Meeting Device (Create in Settings)' : 'No Microphone Found'}
</option>
)}
</select>
</div>
<div className="col-span-2 grid grid-cols-2 gap-4">
<div>
<label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider block mb-1">
LLM Model
</label>
<select
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)}
onChange={(e) => {
onModelChange(e.target.value);
// localStorage handled in App.tsx
}}
className="w-full p-2 text-sm bg-secondary rounded border border-border outline-none focus:ring-2 focus:ring-primary"
disabled={isRecording}
// Allow changing model while recording (since it's used for summary after)
disabled={false}
>
{availableModels.map(m => (
<option key={m.id} value={m.id}>{m.name}</option>
@@ -301,7 +623,8 @@ const Recorder: React.FC<RecorderProps> = ({
value={selectedPromptId}
onChange={(e) => setSelectedPromptId(e.target.value)}
className="w-full p-2 text-sm bg-secondary rounded border border-border outline-none focus:ring-2 focus:ring-primary"
disabled={isRecording || prompts.length === 0}
// Allow changing template while recording
disabled={prompts.length === 0}
>
{prompts.map(p => (
<option key={p.id} value={p.id}>{p.name}</option>
@@ -311,11 +634,19 @@ const Recorder: React.FC<RecorderProps> = ({
</div>
<div className="flex flex-col gap-2 mt-2 w-full">
{recordingMode === 'meeting' && filteredDevices.length === 0 && (
<button
onClick={onOpenSettings}
className="text-xs bg-primary/10 text-primary hover:bg-primary/20 w-full text-center border border-primary/20 rounded p-2 mb-2 font-semibold"
>
🪄 Create "Hearbit Audio" Device
</button>
)}
<button
onClick={openAudioSetup}
className="text-xs text-muted-foreground hover:text-foreground w-full text-center border border-dashed border-border/50 rounded p-1"
>
Open Audio MIDI Setup (Configure Multi-Output)
Open Audio MIDI Setup
</button>
</div>
</div>

View File

@@ -1,23 +1,57 @@
import React, { useState } from 'react';
import { Save, FolderOpen, Lock, Upload, Download, Eye, EyeOff } from 'lucide-react';
import { open } from '@tauri-apps/plugin-dialog';
import React, { useState, useEffect } from 'react';
import { Save, FolderOpen, Lock, Upload, Download, Eye, EyeOff, Mail, FileText, ScrollText } from 'lucide-react';
import { save, open } from '@tauri-apps/plugin-dialog';
// Removed writeTextFile as we use invoke 'save_text_file'
import { invoke } from '@tauri-apps/api/core';
import { encryptData, decryptData } from '../utils/backup';
import { PromptTemplate } from '../App';
import EmailTemplateEditor from './EmailTemplateEditor';
import logo from '../assets/logo.png';
import { PromptTemplate, EmailTemplate } from '../App';
interface SettingsProps {
apiKey: string;
productId: string;
savePath: string;
prompts: PromptTemplate[];
onSave: (apiKey: string, productId: string, prompts: PromptTemplate[], savePath: string) => void;
emailTemplates: EmailTemplate[];
smtpConfig: SmtpConfig;
azureConfig: AzureConfig;
onSave: (
apiKey: string,
productId: string,
prompts: PromptTemplate[],
savePath: string,
smtp: SmtpConfig,
azure: AzureConfig,
emailTemplates: EmailTemplate[]
) => void;
onClose: () => void;
}
const Settings: React.FC<SettingsProps> = ({ apiKey, productId, prompts, savePath, onSave, onClose }) => {
export interface SmtpConfig {
host: string;
port: string;
user: string;
pass: string;
sender: string;
senderName: string;
}
export interface AzureConfig {
clientId: string;
tenantId: string;
}
const Settings: React.FC<SettingsProps> = ({ apiKey, productId, prompts, savePath, onSave, onClose, ...props }) => {
const [localApiKey, setLocalApiKey] = useState(apiKey);
const [localProductId, setLocalProductId] = useState(productId);
const [localSavePath, setLocalSavePath] = useState(savePath);
const [localPrompts, setLocalPrompts] = useState<PromptTemplate[]>(prompts);
const [localEmailTemplates, setLocalEmailTemplates] = useState<EmailTemplate[]>(props.emailTemplates); // New state
const [localSmtp, setLocalSmtp] = useState<SmtpConfig>(props.smtpConfig);
const [localAzure, setLocalAzure] = useState<AzureConfig>(props.azureConfig);
const [statusIdx, setStatusIdx] = useState<string | null>(null);
// Backup & Restore State
@@ -26,6 +60,48 @@ const Settings: React.FC<SettingsProps> = ({ apiKey, productId, prompts, savePat
const [isImportModalOpen, setIsImportModalOpen] = useState(false);
const [importFileContent, setImportFileContent] = useState<string | null>(null);
// Email Template Editor State
const [editingTemplate, setEditingTemplate] = useState<EmailTemplate | null>(null);
const [isEmailEditorOpen, setIsEmailEditorOpen] = useState(false);
const [activeTab, setActiveTab] = useState<'general' | 'prompts' | 'email' | 'backup' | 'logs'>('general');
const [logs, setLogs] = useState<string>('Loading logs...');
useEffect(() => {
if (activeTab === 'logs') {
loadLogs();
}
}, [activeTab]);
const loadLogs = async () => {
try {
const content = await invoke<string>('read_log_file');
setLogs(content);
} catch (e) {
setLogs(`Failed to load logs: ${e}`);
}
};
const handleSaveLogs = async () => {
try {
const filePath = await save({
defaultPath: `hearbit_logs_${new Date().toISOString().slice(0, 10)}.log`,
filters: [{
name: 'Log File',
extensions: ['log', 'txt']
}]
});
if (filePath) {
await invoke('save_text_file', { path: filePath, content: logs });
setStatusIdx(`Logs exported to: ${filePath}`);
}
} catch (e) {
console.error(e);
setStatusIdx(`Failed to export logs: ${e}`);
}
};
const handlePromptChange = (id: string, field: 'name' | 'content', value: string) => {
setLocalPrompts(localPrompts.map(p => p.id === id ? { ...p, [field]: value } : p));
};
@@ -38,8 +114,26 @@ const Settings: React.FC<SettingsProps> = ({ apiKey, productId, prompts, savePat
setLocalPrompts(localPrompts.filter(p => p.id !== id));
};
const handleSaveEmailTemplate = (template: EmailTemplate) => {
const exists = localEmailTemplates.find(t => t.id === template.id);
if (exists) {
setLocalEmailTemplates(localEmailTemplates.map(t => t.id === template.id ? template : t));
} else {
setLocalEmailTemplates([...localEmailTemplates, template]);
}
};
const openEmailEditor = (template: EmailTemplate | null) => {
setEditingTemplate(template);
setIsEmailEditorOpen(true);
};
const removeEmailTemplate = (id: string) => {
setLocalEmailTemplates(localEmailTemplates.filter(t => t.id !== id));
};
const handleSave = () => {
onSave(localApiKey, localProductId, localPrompts, localSavePath);
onSave(localApiKey, localProductId, localPrompts, localSavePath, localSmtp, localAzure, localEmailTemplates);
onClose();
};
@@ -69,22 +163,28 @@ const Settings: React.FC<SettingsProps> = ({ apiKey, productId, prompts, savePat
apiKey: localApiKey,
productId: localProductId,
prompts: localPrompts,
savePath: localSavePath
savePath: localSavePath,
smtp: localSmtp,
azure: localAzure
};
const encrypted = await encryptData(data, backupPassword);
const blob = new Blob([encrypted], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `hearbit_backup_${new Date().toISOString().slice(0, 10)}.conf`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
setStatusIdx('Configuration exported successfully!');
const filePath = await save({
defaultPath: `hearbit_backup_${new Date().toISOString().slice(0, 10)}.conf`,
filters: [{
name: 'Hearbit Config',
extensions: ['conf']
}]
});
if (filePath) {
// Use backend invoke to write file (bypasses fs scope issues)
await invoke('save_text_file', { path: filePath, content: encrypted });
setStatusIdx(`Configuration exported to: ${filePath}`);
}
} catch (e) {
console.error(e);
setStatusIdx('Export failed.');
setStatusIdx(`Export failed: ${e}`);
}
};
@@ -119,8 +219,12 @@ const Settings: React.FC<SettingsProps> = ({ apiKey, productId, prompts, savePat
const data = await decryptData(importFileContent, backupPassword);
if (data.apiKey) setLocalApiKey(data.apiKey);
if (data.productId) setLocalProductId(data.productId);
if (data.prompts) setLocalPrompts(data.prompts);
if (data.emailTemplates) setLocalEmailTemplates(data.emailTemplates);
if (data.savePath) setLocalSavePath(data.savePath);
if (data.smtp) setLocalSmtp(data.smtp);
if (data.azure) setLocalAzure(data.azure);
setStatusIdx('Configuration imported! Click Save to apply.');
setIsImportModalOpen(false);
@@ -131,6 +235,25 @@ const Settings: React.FC<SettingsProps> = ({ apiKey, productId, prompts, savePat
}
};
const handleCreateDevice = async () => {
try {
setStatusIdx('Creating Hearbit Audio device...');
await invoke('create_hearbit_audio_device');
setStatusIdx('Success! "Hearbit Audio" device created.');
} catch (e) {
console.error(e);
setStatusIdx(`Error: ${e}`);
}
};
const tabs = [
{ id: 'general', label: 'General', icon: <Save size={14} /> },
{ id: 'prompts', label: 'Prompts', icon: <FileText size={14} /> },
{ id: 'email', label: 'Email', icon: <Mail size={14} /> },
{ id: 'backup', label: 'Backup', icon: <Lock size={14} /> },
{ id: 'logs', label: 'Logs', icon: <ScrollText size={14} /> },
] as const;
return (
<div className="flex flex-col h-full bg-background font-mono text-sm relative">
{/* Import Password Modal */}
@@ -177,144 +300,409 @@ const Settings: React.FC<SettingsProps> = ({ apiKey, productId, prompts, savePat
</div>
)}
<div className="p-4 border-b border-border/40 bg-secondary/20 flex justify-between items-center">
<span className="text-xs uppercase tracking-wider text-muted-foreground font-semibold">Settings</span>
<button onClick={handleSave} className="flex items-center gap-1 text-primary hover:text-primary/80 transition-colors font-semibold">
<Save size={16} /> Save
</button>
</div>
{/* Email Template Editor Modal */}
<EmailTemplateEditor
isOpen={isEmailEditorOpen}
onClose={() => setIsEmailEditorOpen(false)}
template={editingTemplate}
onSave={handleSaveEmailTemplate}
smtpConfig={localSmtp}
addToast={(msg, type) => setStatusIdx(`${type === 'error' ? 'Error' : 'Success'}: ${msg}`)}
/>
<div className="flex-1 overflow-y-auto p-4 space-y-6">
<div className="space-y-4 border rounded p-4 border-border/50">
<h3 className="text-foreground font-semibold flex items-center gap-2">General</h3>
<div>
<label htmlFor="apiKey" className="block text-sm font-medium mb-1 text-foreground">API Key</label>
<input
id="apiKey"
type="password"
value={localApiKey}
onChange={(e) => setLocalApiKey(e.target.value)}
className="w-full p-2 rounded border border-border bg-secondary text-foreground focus:ring-2 focus:ring-primary outline-none font-mono text-sm"
/>
</div>
<div>
<label htmlFor="productId" className="block text-sm font-medium mb-1 text-foreground">Product ID</label>
<input
id="productId"
type="text"
value={localProductId}
onChange={(e) => setLocalProductId(e.target.value)}
className="w-full p-2 rounded border border-border bg-secondary text-foreground focus:ring-2 focus:ring-primary outline-none font-mono text-sm"
/>
</div>
<div>
<label htmlFor="savePath" className="block text-sm font-medium mb-1 text-foreground">Custom Recordings Folder</label>
<div className="flex gap-2">
<input
id="savePath"
type="text"
value={localSavePath}
onChange={(e) => setLocalSavePath(e.target.value)}
placeholder="/Users/username/Desktop/Recordings"
className="flex-1 p-2 rounded border border-border bg-secondary text-foreground focus:ring-2 focus:ring-primary outline-none font-mono text-sm"
/>
<button
onClick={handleSelectFolder}
className="p-2 aspect-square flex items-center justify-center bg-secondary hover:bg-secondary/80 border border-border rounded text-foreground transition-colors"
title="Pick Folder"
>
<FolderOpen size={16} />
</button>
</div>
</div>
<div className="flex flex-col border-b border-border/40 bg-secondary/10">
<div className="p-4 flex justify-between items-center">
<h2 className="text-lg font-semibold tracking-tight">Settings</h2>
<button onClick={handleSave} className="flex items-center gap-2 bg-primary text-primary-foreground px-4 py-2 rounded font-semibold hover:bg-primary/90 transition-all active:scale-95 text-xs">
<Save size={16} /> Save Changes
</button>
</div>
<div className="space-y-4 border rounded p-4 border-border/50">
<h3 className="text-foreground font-semibold flex items-center gap-2">
<Lock size={16} /> Backup & Restore
</h3>
<p className="text-xs text-muted-foreground">
Export your settings (keys, prompts, path) to an encrypted file.
</p>
<div className="relative">
<input
type={showPassword ? "text" : "password"}
value={backupPassword}
onChange={(e) => setBackupPassword(e.target.value)}
placeholder="Encryption Password"
className="w-full p-2 pr-8 rounded border border-border bg-secondary text-foreground focus:ring-2 focus:ring-primary outline-none text-sm"
/>
{/* Tabs */}
<div className="flex px-4 gap-2">
{tabs.map(tab => (
<button
onClick={() => setShowPassword(!showPassword)}
className="absolute right-2 top-2.5 text-muted-foreground hover:text-foreground"
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`px-4 py-2 text-xs font-medium border-b-2 transition-colors flex items-center gap-2 ${activeTab === tab.id ? 'border-primary text-primary' : 'border-transparent text-muted-foreground hover:text-foreground'}`}
>
{showPassword ? <EyeOff size={14} /> : <Eye size={14} />}
{tab.icon} {tab.label}
</button>
</div>
<div className="flex gap-2 pt-2">
<button
onClick={handleExport}
className="flex-1 flex items-center justify-center gap-2 py-2 px-4 rounded bg-secondary hover:bg-secondary/80 border border-border text-foreground transition-all text-xs font-semibold"
>
<Download size={14} /> Export Config
</button>
<button
onClick={triggerImport}
className="flex-1 flex items-center justify-center gap-2 py-2 px-4 rounded bg-secondary hover:bg-secondary/80 border border-border text-foreground transition-all text-xs font-semibold"
>
<Upload size={14} /> Import Config
</button>
<input
type="file"
id="import-file-input"
accept=".conf"
className="hidden"
onChange={handleFileSelect}
/>
</div>
</div>
<div className="space-y-4 border rounded p-4 border-border/50">
<div className="flex justify-between items-center">
<h3 className="text-foreground font-semibold">Prompts</h3>
<button onClick={addPrompt} className="text-xs bg-primary text-primary-foreground px-2 py-1 rounded hover:bg-primary/90">
+ Add Prompt
</button>
</div>
{localPrompts.map((prompt) => (
<div key={prompt.id} className="space-y-2 p-3 bg-secondary/30 rounded border border-border/50 relative group">
<button
onClick={() => removePrompt(prompt.id)}
className="absolute top-2 right-2 text-muted-foreground hover:text-destructive opacity-0 group-hover:opacity-100 transition-opacity text-xs"
>
<EyeOff size={12} className="inline mr-1" /> Remove
</button>
<input
type="text"
value={prompt.name}
onChange={(e) => handlePromptChange(prompt.id, 'name', e.target.value)}
className="w-full p-1 bg-transparent border-b border-border/50 focus:border-primary outline-none font-semibold text-sm"
placeholder="Prompt Name"
/>
<textarea
value={prompt.content}
onChange={(e) => handlePromptChange(prompt.id, 'content', e.target.value)}
className="w-full p-2 bg-secondary/50 rounded border border-border/30 focus:border-primary outline-none text-xs resize-y min-h-[60px]"
placeholder="Prompt Content"
/>
</div>
))}
</div>
</div>
{statusIdx && (
<div className={`p-2 text-xs rounded border ${statusIdx.includes('Error') || statusIdx.includes('failed') ? 'bg-destructive/10 border-destructive text-destructive' : 'bg-green-500/10 border-green-500 text-green-500'}`}>
{statusIdx}
<div className="flex-1 overflow-y-auto p-6">
{activeTab === 'general' && (
<div className="space-y-6 max-w-2xl">
<div className="space-y-4">
<h3 className="text-foreground font-semibold border-b border-border pb-2">Application Keys</h3>
<div>
<label htmlFor="apiKey" className="block text-sm font-medium mb-1 text-foreground">API Key</label>
<input
id="apiKey"
type="password"
value={localApiKey}
onChange={(e) => setLocalApiKey(e.target.value)}
className="w-full p-2 rounded border border-border bg-secondary text-foreground focus:ring-2 focus:ring-primary outline-none font-mono text-sm"
/>
</div>
<div>
<label htmlFor="productId" className="block text-sm font-medium mb-1 text-foreground">Product ID</label>
<input
id="productId"
type="text"
value={localProductId}
onChange={(e) => setLocalProductId(e.target.value)}
className="w-full p-2 rounded border border-border bg-secondary text-foreground focus:ring-2 focus:ring-primary outline-none font-mono text-sm"
/>
</div>
</div>
<div className="space-y-4">
<h3 className="text-foreground font-semibold border-b border-border pb-2">Storage</h3>
<div>
<label htmlFor="savePath" className="block text-sm font-medium mb-1 text-foreground">Custom Recordings Folder</label>
<div className="flex gap-2">
<input
id="savePath"
type="text"
value={localSavePath}
onChange={(e) => setLocalSavePath(e.target.value)}
placeholder="/Users/username/Desktop/Recordings"
className="flex-1 p-2 rounded border border-border bg-secondary text-foreground focus:ring-2 focus:ring-primary outline-none font-mono text-sm"
/>
<button
onClick={handleSelectFolder}
className="p-2 aspect-square flex items-center justify-center bg-secondary hover:bg-secondary/80 border border-border rounded text-foreground transition-all active:scale-95"
title="Pick Folder"
>
<FolderOpen size={16} />
</button>
</div>
</div>
</div>
<div className="space-y-4">
<h3 className="text-foreground font-semibold border-b border-border pb-2">📸 Branding</h3>
<div className="p-4 bg-secondary/20 rounded border border-border/50">
<div className="mb-3">
<div className="font-medium text-sm mb-2">Custom Logo</div>
<div className="text-xs text-muted-foreground mb-3">Upload your company logo to replace the default Livtec branding throughout the app.</div>
</div>
{/* Logo Preview */}
<div className="flex items-center gap-4 mb-3">
<div className="w-20 h-20 bg-background border border-border rounded flex items-center justify-center overflow-hidden">
<img
src={localStorage.getItem('customLogo') || logo}
alt="Logo Preview"
className="max-w-full max-h-full object-contain"
/>
</div>
<div className="flex-1">
<button
onClick={async () => {
try {
const selected = await open({
filters: [{ name: 'Images', extensions: ['png', 'jpg', 'jpeg', 'svg'] }]
});
if (selected && typeof selected === 'string') {
const dataUrl = await invoke<string>('read_image_as_base64', { filePath: selected });
localStorage.setItem('customLogo', dataUrl);
setStatusIdx('Logo uploaded! Save settings to apply.');
// Force re-render
window.dispatchEvent(new Event('storage'));
}
} catch (e) {
setStatusIdx(`Logo upload failed: ${e}`);
}
}}
className="bg-secondary hover:bg-secondary/80 text-xs px-3 py-2 rounded border border-border transition-all flex items-center gap-2"
>
<Upload size={14} /> Upload Logo
</button>
<button
onClick={() => {
localStorage.removeItem('customLogo');
setStatusIdx('Logo reset to default. Save to apply.');
window.dispatchEvent(new Event('storage'));
}}
className="mt-2 bg-secondary hover:bg-secondary/80 text-xs px-3 py-2 rounded border border-border transition-all text-muted-foreground"
>
Reset to Default
</button>
</div>
</div>
<p className="text-[10px] text-muted-foreground">Supported: PNG, JPG, SVG. Recommended: Square format, transparent background.</p>
</div>
</div>
<div className="space-y-4">
<h3 className="text-foreground font-semibold border-b border-border pb-2">System Intergration</h3>
<div className="flex items-center justify-between p-4 bg-secondary/20 rounded border border-border/50">
<div>
<div className="font-medium text-sm">Hearbit Audio Device</div>
<div className="text-xs text-muted-foreground">Required for recording system audio (Teams, Zoom, etc.)</div>
</div>
<button
onClick={handleCreateDevice}
className="bg-secondary hover:bg-secondary/80 text-xs px-3 py-2 rounded border border-border transition-all active:scale-95 flex items-center gap-2"
>
<span>🪄</span> Create / Repair
</button>
</div>
</div>
</div>
)}
{activeTab === 'prompts' && (
<div className="space-y-4 max-w-3xl">
<div className="flex justify-between items-center border-b border-border pb-2">
<h3 className="text-foreground font-semibold">AI Prompts</h3>
<button onClick={addPrompt} className="text-xs bg-primary text-primary-foreground px-3 py-1.5 rounded hover:bg-primary/90 transition-all active:scale-95">
+ Add Prompt
</button>
</div>
<div className="grid gap-4">
{localPrompts.map((prompt) => (
<div key={prompt.id} className="space-y-2 p-4 bg-secondary/10 rounded border border-border/50 relative group">
<button
onClick={() => removePrompt(prompt.id)}
className="absolute top-2 right-2 text-muted-foreground hover:text-destructive opacity-0 group-hover:opacity-100 transition-opacity text-xs flex items-center gap-1"
>
<EyeOff size={14} /> Remove
</button>
<input
type="text"
value={prompt.name}
onChange={(e) => handlePromptChange(prompt.id, 'name', e.target.value)}
className="w-full p-1 bg-transparent border-b border-border/50 focus:border-primary outline-none font-semibold text-sm"
placeholder="Prompt Name"
/>
<textarea
value={prompt.content}
onChange={(e) => handlePromptChange(prompt.id, 'content', e.target.value)}
className="w-full p-2 bg-secondary/50 rounded border border-border/30 focus:border-primary outline-none text-xs resize-y min-h-[100px] font-mono"
placeholder="Prompt Content"
/>
</div>
))}
</div>
</div>
)}
{activeTab === 'email' && (
<div className="space-y-8 max-w-2xl">
<div className="space-y-4">
<div className="flex justify-between items-center border-b border-border pb-2">
<h3 className="text-foreground font-semibold">Email Templates</h3>
<button
onClick={() => openEmailEditor(null)}
className="text-xs bg-primary text-primary-foreground px-3 py-1.5 rounded hover:bg-primary/90 transition-all active:scale-95"
>
+ Add Template
</button>
</div>
<div className="space-y-2">
{localEmailTemplates.map((template) => (
<div key={template.id} className="flex justify-between items-center p-4 bg-secondary/10 rounded border border-border/50 group hover:border-border/80 transition-colors">
<div className="flex-1 min-w-0 pr-4">
<div className="font-semibold text-sm truncate">{template.name}</div>
<div className="text-xs text-muted-foreground truncate">{template.subject}</div>
</div>
<div className="flex gap-2 shrink-0">
<button
onClick={() => openEmailEditor(template)}
className="px-2 py-1 text-xs font-medium text-primary hover:bg-primary/10 rounded transition-colors"
>
Edit
</button>
<button
onClick={() => removeEmailTemplate(template.id)}
className="px-2 py-1 text-xs font-medium text-muted-foreground hover:text-destructive hover:bg-destructive/10 rounded transition-colors"
>
Remove
</button>
</div>
</div>
))}
{localEmailTemplates.length === 0 && (
<div className="text-center p-8 text-muted-foreground text-xs italic border border-dashed border-border rounded">
No templates created yet.
</div>
)}
</div>
</div>
<div className="space-y-4">
<h3 className="text-foreground font-semibold border-b border-border pb-2">SMTP Configuration</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1 text-foreground">SMTP Host</label>
<input
type="text"
value={localSmtp.host}
onChange={(e) => setLocalSmtp({ ...localSmtp, host: e.target.value })}
className="w-full p-2 rounded border border-border bg-secondary text-foreground focus:ring-2 focus:ring-primary outline-none font-mono text-sm"
placeholder="smtp.office365.com"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-foreground">Port</label>
<input
type="text"
value={localSmtp.port}
onChange={(e) => setLocalSmtp({ ...localSmtp, port: e.target.value })}
className="w-full p-2 rounded border border-border bg-secondary text-foreground focus:ring-2 focus:ring-primary outline-none font-mono text-sm"
placeholder="587"
/>
</div>
<div className="col-span-2">
<label className="block text-sm font-medium mb-1 text-foreground">Sender Email</label>
<input
type="text"
value={localSmtp.sender}
onChange={(e) => setLocalSmtp({ ...localSmtp, sender: e.target.value })}
className="w-full p-2 rounded border border-border bg-secondary text-foreground focus:ring-2 focus:ring-primary outline-none font-mono text-sm"
placeholder="you@company.com"
/>
</div>
<div className="col-span-2">
<label className="block text-sm font-medium mb-1 text-foreground">Sender Name (Optional)</label>
<input
type="text"
value={localSmtp.senderName}
onChange={(e) => setLocalSmtp({ ...localSmtp, senderName: e.target.value })}
className="w-full p-2 rounded border border-border bg-secondary text-foreground focus:ring-2 focus:ring-primary outline-none font-mono text-sm"
placeholder="Hearbit Assistant"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-foreground">Username</label>
<input
type="text"
value={localSmtp.user}
onChange={(e) => setLocalSmtp({ ...localSmtp, user: e.target.value })}
className="w-full p-2 rounded border border-border bg-secondary text-foreground focus:ring-2 focus:ring-primary outline-none font-mono text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-foreground">Password</label>
<input
type="password"
value={localSmtp.pass}
onChange={(e) => setLocalSmtp({ ...localSmtp, pass: e.target.value })}
className="w-full p-2 rounded border border-border bg-secondary text-foreground focus:ring-2 focus:ring-primary outline-none font-mono text-sm"
/>
</div>
</div>
</div>
<div className="space-y-4">
<h3 className="text-foreground font-semibold border-b border-border pb-2">Microsoft 365 (Azure AD)</h3>
<p className="text-xs text-muted-foreground">Optional configuration for advanced MS Graph integrations.</p>
<div>
<label className="block text-sm font-medium mb-1 text-foreground">Client ID</label>
<input
type="text"
value={localAzure.clientId}
onChange={(e) => setLocalAzure({ ...localAzure, clientId: e.target.value })}
className="w-full p-2 rounded border border-border bg-secondary text-foreground focus:ring-2 focus:ring-primary outline-none font-mono text-sm"
placeholder="Application (client) ID"
/>
</div>
</div>
</div>
)}
{activeTab === 'backup' && (
<div className="space-y-6 max-w-xl">
<div className="space-y-4">
<h3 className="text-foreground font-semibold border-b border-border pb-2">Configuration Backup</h3>
<p className="text-xs text-muted-foreground">
Securely export your settings, including API keys and prompts. You must set a password to encrypt the backup file.
</p>
<div className="relative">
<label className="block text-xs font-semibold text-muted-foreground mb-1 uppercase tracking-wide">
Encryption Password
</label>
<input
type={showPassword ? "text" : "password"}
value={backupPassword}
onChange={(e) => setBackupPassword(e.target.value)}
placeholder="Enter a strong password"
className="w-full p-2 pr-8 rounded border border-border bg-secondary text-foreground focus:ring-2 focus:ring-primary outline-none text-sm"
/>
<button
onClick={() => setShowPassword(!showPassword)}
className="absolute right-2 top-8 text-muted-foreground hover:text-foreground"
>
{showPassword ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
</div>
<div className="flex gap-4 pt-2">
<button
onClick={handleExport}
className="flex-1 flex items-center justify-center gap-2 py-3 px-4 rounded-lg bg-secondary hover:bg-secondary/80 border border-border text-foreground transition-all font-semibold active:scale-95"
>
<Download size={18} /> Export Config
</button>
<button
onClick={triggerImport}
className="flex-1 flex items-center justify-center gap-2 py-3 px-4 rounded-lg bg-secondary hover:bg-secondary/80 border border-border text-foreground transition-all font-semibold active:scale-95"
>
<Upload size={18} /> Import Config
</button>
<input
type="file"
id="import-file-input"
accept=".conf"
className="hidden"
onChange={handleFileSelect}
/>
</div>
</div>
</div>
)}
{activeTab === 'logs' && (
<div className="space-y-4 max-w-4xl h-full flex flex-col">
<div className="flex justify-between items-center border-b border-border pb-2">
<h3 className="text-foreground font-semibold">Application Logs</h3>
<div className="flex gap-2">
<button
onClick={handleSaveLogs}
className="text-xs bg-secondary hover:bg-secondary/80 px-2 py-1 rounded border border-border transition-all active:scale-95 flex items-center gap-1"
>
<Download size={12} /> Export Logs
</button>
<button
onClick={loadLogs}
className="text-xs bg-secondary hover:bg-secondary/80 px-2 py-1 rounded border border-border transition-all active:scale-95"
>
Refresh Logs
</button>
</div>
</div>
<div className="flex-1 bg-black text-white p-4 rounded font-mono text-xs overflow-auto whitespace-pre-wrap leading-relaxed shadow-inner">
{logs}
</div>
</div>
)}
</div>
</div>
{
statusIdx && (
<div className={`p-2 text-xs text-center border-t ${statusIdx.includes('Error') || statusIdx.includes('failed') ? 'bg-destructive/10 border-destructive text-destructive' : 'bg-green-500/10 border-green-500 text-green-500'}`}>
{statusIdx}
</div>
)
}
</div >
);
};

View File

@@ -1,8 +1,9 @@
import React from 'react';
import { Mic, Terminal, FileText } from 'lucide-react';
import { Mic, FileText, Calendar, Upload } from 'lucide-react';
interface TabsProps {
currentTab: 'recorder' | 'logs' | 'transcription' | 'settings';
onTabChange: (tab: 'recorder' | 'logs' | 'transcription' | 'settings') => void;
currentTab: 'recorder' | 'transcription' | 'settings' | 'meetings' | 'history' | 'import';
onTabChange: (tab: 'recorder' | 'transcription' | 'settings' | 'meetings' | 'history' | 'import') => void;
}
const Tabs: React.FC<TabsProps> = ({ currentTab, onTabChange }) => {
@@ -15,6 +16,13 @@ const Tabs: React.FC<TabsProps> = ({ currentTab, onTabChange }) => {
<Mic size={16} />
Recording
</button>
<button
onClick={() => onTabChange('import')}
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${currentTab === 'import' ? 'bg-secondary text-foreground' : 'text-muted-foreground hover:text-foreground hover:bg-secondary/50'}`}
>
<Upload size={16} />
Import
</button>
<button
onClick={() => onTabChange('transcription')}
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${currentTab === 'transcription' ? 'bg-secondary text-foreground' : 'text-muted-foreground hover:text-foreground hover:bg-secondary/50'}`}
@@ -23,15 +31,20 @@ const Tabs: React.FC<TabsProps> = ({ currentTab, onTabChange }) => {
Transcription
</button>
<button
onClick={() => onTabChange('logs')}
className={`flex items-center gap-2 px-4 py-1.5 rounded-full text-sm font-medium transition-all duration-200 ${currentTab === 'logs'
? 'bg-background shadow-sm text-foreground ring-1 ring-border/50'
: 'text-muted-foreground hover:text-foreground hover:bg-background/50'
}`}
onClick={() => onTabChange('meetings')}
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${currentTab === 'meetings' ? 'bg-secondary text-foreground' : 'text-muted-foreground hover:text-foreground hover:bg-secondary/50'}`}
>
<Terminal size={14} />
Logs
<Calendar size={16} />
Meetings
</button>
<button
onClick={() => onTabChange('history')}
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${currentTab === 'history' ? 'bg-secondary text-foreground' : 'text-muted-foreground hover:text-foreground hover:bg-secondary/50'}`}
>
<FileText size={16} />
History
</button>
{/* Settings could be a tab, but often better as an icon elsewhere, however sticking to the 'tab' request */}
{/* The user didn't explicitly ask for settings tab, but we need a way to get there. Let's keep it here for now or maybe just an icon?
The prompt showed "Recording | Summary | Meetings". We are doing "Recording | Logs".

View File

@@ -1,15 +1,84 @@
import React, { useState } from 'react';
import ReactMarkdown from 'react-markdown';
import { Copy, Check } from 'lucide-react';
import { Copy, Check, Mail, RefreshCw, Wand2 } from 'lucide-react';
import EmailPreviewModal from './EmailPreviewModal';
import { SmtpConfig } from './Settings';
import { ToastType } from './ui/Toast';
import { PromptTemplate, EmailTemplate } from '../App';
import { invoke } from '@tauri-apps/api/core';
interface TranscriptionViewProps {
transcription: string;
summary: string;
smtpConfig: SmtpConfig;
apiKey: string;
productId: string;
prompts: PromptTemplate[];
emailTemplates: EmailTemplate[];
onUpdateSummary: (newSummary: string) => void;
addToast: (message: string, type: ToastType, duration?: number) => void;
}
const TranscriptionView: React.FC<TranscriptionViewProps> = ({ transcription, summary }) => {
const TranscriptionView: React.FC<TranscriptionViewProps> = ({
transcription, summary, smtpConfig, apiKey, productId, prompts, emailTemplates, onUpdateSummary, addToast
}) => {
const [copiedTrans, setCopiedTrans] = useState(false);
const [copiedSum, setCopiedSum] = useState(false);
const [isEmailModalOpen, setIsEmailModalOpen] = useState(false);
// Regenerate State
const [isRegenerating, setIsRegenerating] = useState(false);
const [showRegenOptions, setShowRegenOptions] = useState(false);
const [regenModel, setRegenModel] = useState<string>('mixtral');
const [regenPromptId, setRegenPromptId] = useState<string>('');
const [availableModels, setAvailableModels] = useState<Array<{ id: string, name: string }>>([]);
React.useEffect(() => {
if (showRegenOptions && availableModels.length === 0) {
loadModels();
}
if (prompts.length > 0 && !regenPromptId) {
setRegenPromptId(prompts[0].id);
}
}, [showRegenOptions]);
const loadModels = async () => {
try {
const models = await invoke<Array<{ id: string, name: string }>>('get_available_models', { apiKey, productId });
if (models && models.length > 0) {
models.sort((a, b) => a.name.localeCompare(b.name));
setAvailableModels(models);
}
} catch (e) {
console.error("Failed to load models:", e);
}
};
const handleRegenerate = async () => {
if (!transcription || !apiKey || !productId) return;
setIsRegenerating(true);
try {
const prompt = prompts.find(p => p.id === regenPromptId)?.content || "Summarize this.";
const newSummary = await invoke<string>('summarize_text', {
text: transcription,
apiKey,
productId,
prompt,
model: regenModel
});
onUpdateSummary(newSummary);
addToast('Summary regenerated!', 'success');
setShowRegenOptions(false);
} catch (e) {
console.error(e);
addToast(`Regeneration failed: ${e}`, 'error');
} finally {
setIsRegenerating(false);
}
};
const handleCopy = async (text: string, isSummary: boolean) => {
if (!text) return;
@@ -56,15 +125,74 @@ const TranscriptionView: React.FC<TranscriptionViewProps> = ({ transcription, su
<div className="flex-1 flex flex-col min-h-0">
<div className="p-3 border-b border-border/40 bg-secondary/20 flex justify-between items-center shrink-0">
<span className="text-xs uppercase tracking-wider text-muted-foreground font-semibold">AI Summary</span>
<button
onClick={() => handleCopy(summary, true)}
className="text-xs flex items-center gap-1 hover:text-primary transition-colors disabled:opacity-50"
disabled={!summary}
>
{copiedSum ? <Check size={14} className="text-green-500" /> : <Copy size={14} />}
{copiedSum ? 'Copied' : 'Copy'}
</button>
<div className="flex items-center gap-2">
{/* Regenerate Trigger */}
<button
onClick={() => setShowRegenOptions(!showRegenOptions)}
disabled={!transcription || !apiKey}
className={`text-xs flex items-center gap-1 transition-colors px-2 py-1 rounded ${showRegenOptions ? 'bg-primary text-primary-foreground' : 'hover:bg-secondary text-muted-foreground hover:text-foreground'}`}
title="Regenerate Summary"
>
<RefreshCw size={12} className={isRegenerating ? "animate-spin" : ""} />
{showRegenOptions ? 'Close' : 'Redo'}
</button>
<div className="h-4 w-px bg-border/50 mx-1"></div>
<div className="flex items-center gap-3">
<button
onClick={() => setIsEmailModalOpen(true)}
disabled={!summary}
className="text-xs flex items-center gap-1 hover:text-primary transition-colors disabled:opacity-50"
title="Send via Email"
>
<Mail size={14} /> Email
</button>
<button
onClick={() => handleCopy(summary, true)}
className="text-xs flex items-center gap-1 hover:text-primary transition-colors disabled:opacity-50"
disabled={!summary}
>
{copiedSum ? <Check size={14} className="text-green-500" /> : <Copy size={14} />}
{copiedSum ? 'Copied' : 'Copy'}
</button>
</div>
</div>
</div>
{/* Regenerate Panel */}
{showRegenOptions && (
<div className="p-3 bg-secondary/30 border-b border-border/40 grid grid-cols-1 md:grid-cols-[1fr_1fr_auto] gap-2 items-end animate-in slide-in-from-top-2 duration-200">
<div>
<label className="text-[10px] uppercase font-bold text-muted-foreground block mb-1">Model</label>
<select
value={regenModel}
onChange={(e) => setRegenModel(e.target.value)}
className="w-full text-xs p-1.5 rounded border border-border bg-background"
>
{availableModels.length === 0 && <option value="mixtral">Loading...</option>}
{availableModels.map(m => <option key={m.id} value={m.id}>{m.name}</option>)}
</select>
</div>
<div>
<label className="text-[10px] uppercase font-bold text-muted-foreground block mb-1">Template</label>
<select
value={regenPromptId}
onChange={(e) => setRegenPromptId(e.target.value)}
className="w-full text-xs p-1.5 rounded border border-border bg-background"
>
{prompts.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
</div>
<button
onClick={handleRegenerate}
disabled={isRegenerating}
className="text-xs bg-primary text-primary-foreground px-3 py-1.5 rounded font-semibold hover:bg-primary/90 disabled:opacity-50 flex items-center gap-2 h-[30px]"
>
<Wand2 size={12} /> {isRegenerating ? 'Running...' : 'Generate New'}
</button>
</div>
)}
<div className="flex-1 overflow-y-auto p-4 bg-secondary/10 prose prose-sm max-w-none prose-p:text-foreground/90 prose-headings:text-foreground prose-strong:text-foreground prose-ul:text-foreground/90">
{summary ? (
<ReactMarkdown>{summary}</ReactMarkdown>
@@ -74,7 +202,18 @@ const TranscriptionView: React.FC<TranscriptionViewProps> = ({ transcription, su
</div>
</div>
</div>
</div>
<EmailPreviewModal
isOpen={isEmailModalOpen}
onClose={() => setIsEmailModalOpen(false)}
initialRecipients={[]} // TODO: Pass attendees from meeting
initialSubject="Meeting Summary" // Default subject
initialBody={summary}
emailTemplates={emailTemplates} // Pass templates
smtpConfig={smtpConfig ? { ...smtpConfig, port: Number(smtpConfig.port) } : null}
addToast={addToast}
/>
</div >
);
};

View File

@@ -0,0 +1,81 @@
import React, { useEffect, useState } from 'react';
import { X, CheckCircle, AlertCircle, Info } from 'lucide-react';
export type ToastType = 'success' | 'error' | 'info';
export interface ToastMessage {
id: string;
message: string;
type: ToastType;
duration?: number;
}
interface ToastProps {
toast: ToastMessage;
onClose: (id: string) => void;
}
const Toast: React.FC<ToastProps> = ({ toast, onClose }) => {
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
// Determine background color based on type
// Animate in
requestAnimationFrame(() => setIsVisible(true));
const timer = setTimeout(() => {
setIsVisible(false);
setTimeout(() => onClose(toast.id), 300); // Wait for animation
}, toast.duration || 3000);
return () => clearTimeout(timer);
}, [toast, onClose]);
const bgColors = {
success: 'bg-green-600',
error: 'bg-destructive',
info: 'bg-blue-600'
};
const icon = {
success: <CheckCircle size={18} />,
error: <AlertCircle size={18} />,
info: <Info size={18} />
};
return (
<div
className={`
flex items-center gap-3 px-4 py-3 rounded-lg shadow-lg text-white mb-2 transition-all duration-300 transform
${bgColors[toast.type]}
${isVisible ? 'translate-x-0 opacity-100' : 'translate-x-full opacity-0'}
`}
style={{ minWidth: '300px', maxWidth: '400px' }}
>
<div className="shrink-0">
{icon[toast.type]}
</div>
<p className="text-sm font-medium flex-1">{toast.message}</p>
<button
onClick={() => { setIsVisible(false); setTimeout(() => onClose(toast.id), 300); }}
className="text-white/80 hover:text-white"
>
<X size={16} />
</button>
</div>
);
};
export const ToastContainer: React.FC<{ toasts: ToastMessage[], removeToast: (id: string) => void }> = ({ toasts, removeToast }) => {
return (
<div className="fixed bottom-4 right-4 z-50 flex flex-col items-end pointer-events-none">
<div className="pointer-events-auto">
{toasts.map(t => (
<Toast key={t.id} toast={t} onClose={removeToast} />
))}
</div>
</div>
);
};
export default ToastContainer;