feat: release 1.0 - rename to Hearbit AI, fix timestamps, update UI

This commit is contained in:
michael.borak
2026-01-20 10:14:07 +01:00
parent 768574709f
commit cd08e1c144
69 changed files with 1369 additions and 545 deletions

7
.gitignore vendored
View File

@@ -12,6 +12,13 @@ dist
dist-ssr
*.local
# Tauri build output
src-tauri/target/
src-tauri/target_tmp/
src-tauri/gen/
**/*.dmg
**/*.app
# Editor directories and files
.vscode/*
!.vscode/extensions.json

112
README.md
View File

@@ -1,74 +1,74 @@
# Hearbit AI 🦉🎙️
Hearbit AI is a powerful macOS desktop application designed to record system and microphone audio, transcribe it using Infomaniak's Whisper API, and generate intelligent AI summaries.
**Hearbit AI** is your professional meeting assistant for macOS. It records both your microphone and system audio (e.g., Teams, Zoom), transcribes it with high precision using Infomaniak's Whisper API, and generates intelligent, structured summaries.
## Features
![App Icon](src-tauri/icons/128x128@2x.png)
* **Dual-Channel Recording**: Capture both your microphone (e.g., for voice notes) and system audio (e.g., for meetings) simultaneously.
* **Powered by Infomaniak AI**:
* **Transcription**: High-accuracy speech-to-text using Infomaniak's Whisper integration.
* **Summarization**: Generate concise summaries, action items, or meeting notes using models like Mixtral or Llama 3 via Infomaniak's LLM API.
* **Auto-Save**: Recordings and summaries are automatically saved to a persistent history.
* **Customizable Prompts**: Define your own AI templates (e.g., "Summarize", "Extract Action Items", "Translate").
* **Privacy-Focused**: Processed securily via your own Infomaniak API keys.
## ✨ Features
## prerequisites
* **🎙️ Dual-Channel Recording**: seamlessly capture your voice and meeting audio from apps like Microsoft Teams, Zoom, or Google Meet.
* **🧠 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.
* **🔒 Privacy-First**: Data is processed securely via your own Infomaniak API keys.
* **macOS** (Apple Silicon or Intel)
* **Infomaniak AI Account**: You need an API Key and Product ID from [Infomaniak's Developer Portal](https://manager.infomaniak.com/).
---
## Installation
## 🚀 Getting Started
1. Download the latest release (`.dmg` or `.app`).
## Recording System Audio (Teams, Zoom, etc.)
### 1. Prerequisites
* **macOS** (Apple Silicon or Intel).
* **Infomaniak AI Account**: You need an API Key and Product ID from the [Infomaniak Developer Portal](https://manager.infomaniak.com/).
To record meetings from apps like Microsoft Teams, Zoom, or Google Meet, you need to route the computer's audio into Hearbit AI. This requires **BlackHole 2ch**.
### 2. Installation
1. Download the latest `.dmg` file from the [Releases page](#).
2. Open the `.dmg` and drag **Hearbit AI** to your Applications folder.
3. Launch the app.
### 1. Install BlackHole
1. Download and install [BlackHole 2ch](https://existential.audio/blackhole/).
2. Restart your computer if required.
---
### 2. Create a Multi-Output Device (To hear audio while recording)
1. Open **Audio MIDI Setup** (search in Spotlight).
2. Click the `+` icon and select **Create Multi-Output Device**.
3. Check **BlackHole 2ch** AND **MacBook Pro Speakers** (or your headphones).
4. Set "Master Device" to **BlackHole 2ch**.
## 🎧 Recording System Audio (Teams, Zoom, etc.)
![Multi-Output Setup](docs/screenshots/multi_output_setup.png)
To record clear meeting audio from other applications, you need a "virtual cable". We recommend **BlackHole 2ch**.
### 3. Create an Aggregate Device (For Hearbit AI Input)
1. In Audio MIDI Setup, click `+` and select **Create Aggregate Device**.
2. Name it "Hearbit-AI" (or similar).
3. Check **BlackHole 2ch** AND **MacBook Pro Microphone**.
4. Ensure "Drift Correction" is enabled for the Microphone.
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.*
3. **Select Input in Hearbit AI**:
* In Hearbit AI, select **BlackHole 2ch** (or an Aggregate Device combining your Mic + BlackHole) as the **Input Device**.
![Aggregate Device Setup](docs/screenshots/aggregate_device_setup.png)
---
### 4. Setup in Hearbit AI
1. Open **Hearbit AI**.
2. In the **Input Device** dropdown, select your Aggregate Device (e.g., "Hearbit-AI").
3. Start Recording.
## 🛠️ Usage Guide
### 5. Setup in Teams/Zoom
* Set your **Speaker** output in Teams/Zoom to the **Multi-Output Device** you created in Step 2.
1. **Configure**:
* Click the **Settings** (gear icon) in the top right.
1. **Configuration**:
* Click the **Settings** (gear icon).
* Enter your **Infomaniak API Key** and **Product ID**.
* (Optional) Customize your AI prompts.
2. **Record**:
* Select your **Input Device** (e.g., "MacBook Pro Microphone" or an aggregate device for system audio).
* Select an **LLM Model** (e.g., Mixtral).
* (Optional) Customize where recordings are saved.
2. **Recording**:
* Choose your **Template** (e.g., "Meeting Protocol").
* Select your **Input Device**.
* Click **Start Recording**.
3. **Process**:
* Click **Stop Recording**.
* The app will automatically upload, transcribe, and summarize your audio.
4. **History**:
* Click **Records** to view past recordings.
## Development
3. **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.
This app is built with **Tauri**, **React**, and **TypeScript**.
---
## 👨‍💻 Development
Built with **Tauri**, **React**, and **TypeScript**.
### Setup
```bash
@@ -77,8 +77,6 @@ npm install
### Run Locally
```bash
npm run dev
# OR
npm run tauri dev
```
@@ -86,9 +84,9 @@ npm run tauri dev
```bash
npm run tauri build
```
*The build artifact will be located in `src-tauri/target/release/bundle/dmg/*`*
## Icons
The app uses a custom generated icon set located in `src-tauri/icons`.
---
## License
[License Name]
## 📄 License
Property of Livtec. All rights reserved.

14
package-lock.json generated
View File

@@ -1,15 +1,16 @@
{
"name": "infomaniak-recorder",
"name": "hearbit-ai",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "infomaniak-recorder",
"name": "hearbit-ai",
"version": "0.1.0",
"dependencies": {
"@tailwindcss/postcss": "^4.1.18",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-opener": "^2",
"jimp": "^1.6.0",
"lucide-react": "^0.562.0",
@@ -2076,6 +2077,15 @@
"node": ">= 10"
}
},
"node_modules/@tauri-apps/plugin-dialog": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.6.0.tgz",
"integrity": "sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==",
"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

@@ -12,6 +12,7 @@
"dependencies": {
"@tailwindcss/postcss": "^4.1.18",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-opener": "^2",
"jimp": "^1.6.0",
"lucide-react": "^0.562.0",

100
src-tauri/Cargo.lock generated
View File

@@ -488,8 +488,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118"
dependencies = [
"iana-time-zone",
"js-sys",
"num-traits",
"serde",
"wasm-bindgen",
"windows-link 0.2.1",
]
@@ -819,6 +821,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec"
dependencies = [
"bitflags 2.10.0",
"block2",
"libc",
"objc2",
]
@@ -1515,6 +1519,23 @@ version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
[[package]]
name = "hearbit-ai"
version = "0.1.0"
dependencies = [
"chrono",
"cpal",
"hound",
"reqwest 0.13.1",
"serde",
"serde_json",
"tauri",
"tauri-build",
"tauri-plugin-dialog",
"tauri-plugin-opener",
"tokio",
]
[[package]]
name = "heck"
version = "0.4.1"
@@ -1834,21 +1855,6 @@ dependencies = [
"cfb",
]
[[package]]
name = "infomaniak-recorder"
version = "0.1.0"
dependencies = [
"cpal",
"hound",
"reqwest 0.13.1",
"serde",
"serde_json",
"tauri",
"tauri-build",
"tauri-plugin-opener",
"tokio",
]
[[package]]
name = "ipnet"
version = "2.11.0"
@@ -3309,6 +3315,30 @@ dependencies = [
"web-sys",
]
[[package]]
name = "rfd"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
dependencies = [
"block2",
"dispatch2",
"glib-sys",
"gobject-sys",
"gtk-sys",
"js-sys",
"log",
"objc2",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-foundation",
"raw-window-handle",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"windows-sys 0.60.2",
]
[[package]]
name = "ring"
version = "0.17.14"
@@ -4167,6 +4197,46 @@ dependencies = [
"walkdir",
]
[[package]]
name = "tauri-plugin-dialog"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9204b425d9be8d12aa60c2a83a289cf7d1caae40f57f336ed1155b3a5c0e359b"
dependencies = [
"log",
"raw-window-handle",
"rfd",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"tauri-plugin-fs",
"thiserror 2.0.18",
"url",
]
[[package]]
name = "tauri-plugin-fs"
version = "2.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed390cc669f937afeb8b28032ce837bac8ea023d975a2e207375ec05afaf1804"
dependencies = [
"anyhow",
"dunce",
"glob",
"percent-encoding",
"schemars 0.8.22",
"serde",
"serde_json",
"serde_repr",
"tauri",
"tauri-plugin",
"tauri-utils",
"thiserror 2.0.18",
"toml 0.9.11+spec-1.1.0",
"url",
]
[[package]]
name = "tauri-plugin-opener"
version = "2.5.3"

View File

@@ -1,5 +1,5 @@
[package]
name = "infomaniak-recorder"
name = "hearbit-ai"
version = "0.1.0"
description = "A Tauri App"
authors = ["you"]
@@ -20,10 +20,11 @@ tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-opener = "2"
tauri-plugin-dialog = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_json = "1.0"
chrono = "0.4"
cpal = "0.17.1"
hound = "3.5.1"
reqwest = { version = "0.13.1", features = ["json", "multipart"] }
tokio = { version = "1.40.0", features = ["full"] }

View File

@@ -2,9 +2,12 @@
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main"],
"windows": [
"main"
],
"permissions": [
"core:default",
"opener:default"
"opener:default",
"dialog:default"
]
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 746 B

After

Width:  |  Height:  |  Size: 2.7 KiB

BIN
src-tauri/icons/64x64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 903 B

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background"/>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 280 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#fff</color>
</resources>

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 382 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

View File

@@ -1,4 +1,4 @@
use tauri::State;
use tauri::{AppHandle, Manager, State, Emitter};
use std::sync::{Arc, Mutex};
use std::process::Command;
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
@@ -17,6 +17,22 @@ struct AudioDevice {
name: String,
}
#[derive(serde::Serialize, Clone)]
struct LogEvent {
level: String,
message: String,
timestamp: String,
}
fn emit_log(app: &AppHandle, level: &str, message: &str) {
let log = LogEvent {
level: level.to_string(),
message: message.to_string(),
timestamp: chrono::Local::now().format("%H:%M:%S").to_string(),
};
let _ = app.emit("log-event", log);
}
#[tauri::command]
fn greet(name: &str) -> String {
format!("Hello, {}! You've been greeted from Rust!", name)
@@ -41,22 +57,11 @@ fn get_input_devices() -> Result<Vec<AudioDevice>, String> {
Ok(result)
}
#[tauri::command]
fn install_driver() -> Result<String, String> {
let output = Command::new("brew")
.args(["install", "blackhole-2ch"])
.output()
.map_err(|e| format!("Failed to execute command: {}", e))?;
if output.status.success() {
Ok(String::from_utf8_lossy(&output.stdout).to_string())
} else {
Err(String::from_utf8_lossy(&output.stderr).to_string())
}
}
#[tauri::command]
fn start_recording(state: State<'_, AppState>, device_id: String) -> Result<(), String> {
fn start_recording(app: AppHandle, state: State<'_, AppState>, device_id: String, save_path: Option<String>) -> Result<(), String> {
emit_log(&app, "INFO", &format!("Starting recording on device: {}", device_id));
let host = cpal::default_host();
// Find device by name (using name as ID)
@@ -75,16 +80,31 @@ fn start_recording(state: State<'_, AppState>, device_id: String) -> Result<(),
sample_format: hound::SampleFormat::Int,
};
// Create a temporary file
let temp_dir = std::env::temp_dir();
let file_path = temp_dir.join(format!("recording_{}.wav", std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs()));
// Determine file path: User provided or Temp
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()))
} 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()))
}
} else {
std::env::temp_dir().join(format!("recording_{}.wav", std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs()))
};
let file_path_str = file_path.to_string_lossy().to_string();
emit_log(&app, "INFO", &format!("Saving recording to: {}", file_path_str));
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();
let err_fn = |err| eprintln!("an error occurred on stream: {}", err);
let app_handle = app.clone();
let err_fn = move |err| {
eprintln!("an error occurred on stream: {}", err);
emit_log(&app_handle, "ERROR", &format!("Stream error: {}", err));
};
let stream = match config.sample_format() {
cpal::SampleFormat::F32 => device.build_input_stream(
@@ -128,13 +148,15 @@ fn start_recording(state: State<'_, AppState>, device_id: String) -> Result<(),
// Store state
*state.recording_stream.lock().unwrap() = Some(stream);
*state.recording_file_path.lock().unwrap() = Some(file_path_str);
*state.recording_file_path.lock().unwrap() = Some(file_path_str.clone());
emit_log(&app, "SUCCESS", &format!("Recording started. File: {}", file_path_str));
Ok(())
}
#[tauri::command]
fn stop_recording(state: State<'_, AppState>) -> Result<String, String> {
fn stop_recording(app: AppHandle, state: State<'_, AppState>) -> Result<String, String> {
emit_log(&app, "INFO", "Stopping recording...");
// Drop stream to stop recording
{
let mut stream_guard = state.recording_stream.lock().unwrap();
@@ -146,7 +168,35 @@ fn stop_recording(state: State<'_, AppState>) -> Result<String, String> {
// Return file path
let mut path_guard = state.recording_file_path.lock().unwrap();
path_guard.take().ok_or("No recording path found".to_string())
let path = path_guard.take().ok_or("No recording path found".to_string())?;
emit_log(&app, "SUCCESS", &format!("Recording stopped. Saved to: {}", path));
Ok(path)
}
#[tauri::command]
fn pause_recording(app: AppHandle, state: State<'_, AppState>) -> Result<(), String> {
emit_log(&app, "INFO", "Pausing recording...");
let stream_guard = state.recording_stream.lock().unwrap();
if let Some(stream) = stream_guard.as_ref() {
stream.pause().map_err(|e| e.to_string())?;
emit_log(&app, "SUCCESS", "Recording paused.");
Ok(())
} else {
Err("Not recording".to_string())
}
}
#[tauri::command]
fn resume_recording(app: AppHandle, state: State<'_, AppState>) -> Result<(), String> {
emit_log(&app, "INFO", "Resuming recording...");
let stream_guard = state.recording_stream.lock().unwrap();
if let Some(stream) = stream_guard.as_ref() {
stream.play().map_err(|e| e.to_string())?;
emit_log(&app, "SUCCESS", "Recording resumed.");
Ok(())
} else {
Err("Not recording".to_string())
}
}
#[derive(serde::Deserialize)]
@@ -157,6 +207,7 @@ struct ModelListResponse {
#[derive(serde::Deserialize)]
struct ModelData {
id: String,
#[allow(dead_code)]
owned_by: Option<String>,
}
@@ -177,6 +228,7 @@ struct Choice {
}
#[derive(serde::Deserialize)]
struct Message {
#[allow(dead_code)]
content: String,
}
@@ -187,20 +239,27 @@ struct ModelInfo {
}
#[tauri::command]
async fn get_available_models(api_key: String, product_id: String) -> Result<Vec<ModelInfo>, String> {
async fn get_available_models(app: AppHandle, api_key: String, product_id: String) -> Result<Vec<ModelInfo>, String> {
emit_log(&app, "INFO", "Fetching available models from Infomaniak...");
let client = reqwest::Client::new();
// Use the v2/openai compliant endpoint as per docs
let url = format!("https://api.infomaniak.com/2/ai/{}/openai/v1/models", product_id);
emit_log(&app, "DEBUG", &format!("GET {}", url));
let res = client.get(&url)
.header("Authorization", format!("Bearer {}", api_key))
.send()
.await
.map_err(|e| e.to_string())?;
.map_err(|e| {
let msg = format!("Network error fetching models: {}", e);
emit_log(&app, "ERROR", &msg);
msg
})?;
if res.status().is_success() {
let raw_body = res.text().await.map_err(|e| e.to_string())?;
println!("Models Raw Response: {}", raw_body);
// println!("Models Raw Response: {}", raw_body);
let list: ModelListResponse = serde_json::from_str(&raw_body)
.map_err(|e| format!("Failed to parse models: {}. Body: {}", e, raw_body))?;
@@ -209,20 +268,34 @@ async fn get_available_models(api_key: String, product_id: String) -> Result<Vec
.map(|m| ModelInfo {
id: m.id.clone(),
name: m.id, // Use ID as name for now, or fetch more details if available
}).collect();
}).collect::<Vec<ModelInfo>>();
emit_log(&app, "SUCCESS", &format!("Loaded {} models.", models.len()));
Ok(models)
} else {
// Fallback to v1 if v2 fails or try another common path?
// For now just error out
let err = res.text().await.unwrap_or_default();
emit_log(&app, "ERROR", &format!("Failed to fetch models: {}", err));
Err(format!("Failed to fetch models: {}", err))
}
}
#[derive(serde::Deserialize)]
struct WhisperVerboseResponse {
text: Option<String>,
segments: Option<Vec<Segment>>,
}
#[derive(serde::Deserialize)]
struct Segment {
start: f64,
end: f64,
text: String,
}
#[tauri::command]
async fn transcribe_audio(file_path: String, api_key: String, product_id: String) -> Result<String, String> {
async fn transcribe_audio(app: AppHandle, file_path: String, api_key: String, product_id: String) -> Result<String, String> {
emit_log(&app, "INFO", "Starting transcription with timestamps...");
let client = reqwest::Client::new();
// Prepare file part
@@ -235,44 +308,88 @@ async fn transcribe_audio(file_path: String, api_key: String, product_id: String
let form = reqwest::multipart::Form::new()
.part("file", file_part)
.text("model", "whisper");
.text("model", "whisper")
.text("response_format", "verbose_json")
.text("timestamp_granularities[]", "segment"); // Crucial for accurate segments
let url = format!("https://api.infomaniak.com/1/ai/{}/openai/audio/transcriptions", product_id);
emit_log(&app, "DEBUG", &format!("POST {}", url));
let res = client.post(&url)
.header("Authorization", format!("Bearer {}", api_key))
.multipart(form)
.send()
.await
.map_err(|e| e.to_string())?;
.map_err(|e| {
let msg = format!("Network error during transcription: {}", e);
emit_log(&app, "ERROR", &msg);
msg
})?;
if res.status().is_success() {
let raw_body = res.text().await.map_err(|e| e.to_string())?;
println!("Transcription Raw Response: {}", raw_body);
// Attempt to parse text or batch_id
// Attempt to parse text or batch_id
let response: WhisperResponse = serde_json::from_str(&raw_body)
// Check if we got a batch ID
#[derive(serde::Deserialize)]
struct BatchResponse {
batch_id: Option<String>,
}
// Try parsing as batch response first (Infomaniak specific behavior)
if let Ok(batch_res) = serde_json::from_str::<BatchResponse>(&raw_body) {
if let Some(batch_id) = batch_res.batch_id {
emit_log(&app, "INFO", &format!("Transcription queued. Batch ID: {}", batch_id));
return poll_transcription(&app, &client, &api_key, &product_id, &batch_id).await;
}
}
// If not batch, try parsing verbose response directly
// Log the raw body so we can see why it fails
emit_log(&app, "DEBUG", &format!("Direct Response (first 500 chars): {:.500}", raw_body));
let response: WhisperVerboseResponse = serde_json::from_str(&raw_body)
.map_err(|e| format!("Failed to decode JSON: {}. Body: {}", e, raw_body))?;
match (response.text, response.batch_id) {
(Some(text), _) => Ok(text),
(_, Some(batch_id)) => {
// Need to poll
poll_transcription(&client, &api_key, &product_id, &batch_id).await
},
_ => Err(format!("Response contained neither text nor batch_id. Body: {}", raw_body))
if let Some(segments) = response.segments {
emit_log(&app, "INFO", &format!("Found {} segments (Direct).", segments.len()));
for (i, seg) in segments.iter().take(3).enumerate() {
emit_log(&app, "DEBUG", &format!("Seg {}: start={}", i, seg.start));
}
// Format timestamps: [MM:SS] Text
let mut formatted_transcript = String::new();
for segment in segments {
let start_mins = (segment.start / 60.0).floor() as u64;
let start_secs = (segment.start % 60.0).floor() as u64;
formatted_transcript.push_str(&format!("[{:02}:{:02}] {}\n", start_mins, start_secs, segment.text.trim()));
}
// Fallback to raw text if segments empty
if formatted_transcript.trim().is_empty() {
if let Some(text) = response.text {
emit_log(&app, "SUCCESS", "Segments missing, using raw text.");
return Ok(text);
}
} else {
emit_log(&app, "SUCCESS", "Transcription received with timestamps.");
return Ok(formatted_transcript);
}
} else if let Some(text) = response.text {
emit_log(&app, "SUCCESS", "Segments missing, using raw text.");
return Ok(text);
}
emit_log(&app, "ERROR", "Response contained no recognized content.");
Err(format!("Response contained no recognized content. Body: {}", raw_body))
} else {
let error_text = res.text().await.unwrap_or_default();
emit_log(&app, "ERROR", &format!("Transcription failed: {}", error_text));
Err(format!("Transcription failed: {}", error_text))
}
}
async fn poll_transcription(client: &reqwest::Client, api_key: &str, product_id: &str, batch_id: &str) -> Result<String, String> {
// Polling URL: /1/ai/{product_id}/results/{batch_id} (or similar, verifying via trial)
// If that fails, we can try /openai/audio/transcriptions/{batch_id} but documentation suggests results endpoint.
// Let's assume the standard Infomaniak pattern for batches.
async fn poll_transcription(app: &AppHandle, client: &reqwest::Client, api_key: &str, product_id: &str, batch_id: &str) -> Result<String, String> {
let status_url = format!("https://api.infomaniak.com/1/ai/{}/results/{}", product_id, batch_id);
let mut attempts = 0;
@@ -280,6 +397,7 @@ async fn poll_transcription(client: &reqwest::Client, api_key: &str, product_id:
attempts += 1;
sleep(Duration::from_secs(2)).await;
emit_log(app, "DEBUG", &format!("Polling status... Attempt {}", attempts));
let res = client.get(&status_url)
.header("Authorization", format!("Bearer {}", api_key))
.send()
@@ -301,31 +419,63 @@ async fn poll_transcription(client: &reqwest::Client, api_key: &str, product_id:
if dl_res.status().is_success() {
let content = dl_res.text().await.map_err(|e| e.to_string())?;
emit_log(app, "DEBUG", &format!("Poll Raw Content (first 500 chars): {:.500}", content));
// Try to parse the content as JSON to see if it's { "text": "..." }
if let Ok(json_val) = serde_json::from_str::<serde_json::Value>(&content) {
if let Some(text_content) = json_val.get("text").and_then(|t| t.as_str()) {
return Ok(text_content.to_string());
}
// Try to parse as Verbose JSON to get timestamps
if let Ok(response) = serde_json::from_str::<WhisperVerboseResponse>(&content) {
if let Some(segments) = response.segments {
emit_log(app, "INFO", &format!("Found {} segments.", segments.len()));
// Log first 3 segments start times
for (i, seg) in segments.iter().take(3).enumerate() {
emit_log(app, "DEBUG", &format!("Seg {}: start={}", i, seg.start));
}
let mut formatted_transcript = String::new();
for segment in segments {
let start_mins = (segment.start / 60.0).floor() as u64;
let start_secs = (segment.start % 60.0).floor() as u64;
formatted_transcript.push_str(&format!("[{:02}:{:02}] {}\n", start_mins, start_secs, segment.text.trim()));
}
if !formatted_transcript.trim().is_empty() {
emit_log(app, "SUCCESS", "Transcription completed (async) with timestamps.");
return Ok(formatted_transcript);
} else {
emit_log(app, "WARN", "Segments found but empty content.");
}
} else {
emit_log(app, "WARN", "Verbose parsed but no segments found.");
}
if let Some(text) = response.text {
emit_log(app, "SUCCESS", "Transcription completed (async) - raw text (segments missing).");
return Ok(text);
}
} else {
emit_log(app, "WARN", "Failed to parse poll content as WhisperVerboseResponse");
}
emit_log(app, "SUCCESS", "Transcription completed - returning raw content.");
// If not JSON or no text field, return raw content
return Ok(content);
} else {
emit_log(app, "ERROR", "Failed to download transcription results.");
return Err(format!("Download failed: {}", dl_res.status()));
}
} else if status == "failed" || status == "error" {
emit_log(app, "ERROR", &format!("Batch processing failed: {:?}", json));
return Err(format!("Batch processing failed: {:?}", json));
}
// If 'processing' or 'pending', continue loop
}
}
}
emit_log(app, "ERROR", "Transcription timed out after 80s.");
Err("Transcription timed out".to_string())
}
#[tauri::command]
async fn summarize_text(text: String, api_key: String, product_id: String, prompt: String, model: String) -> Result<String, String> {
async fn summarize_text(app: AppHandle, text: String, api_key: String, product_id: String, prompt: String, model: String) -> Result<String, String> {
emit_log(&app, "INFO", "Starting summarization...");
let client = reqwest::Client::new();
let url = format!("https://api.infomaniak.com/2/ai/{}/openai/v1/chat/completions", product_id);
@@ -341,36 +491,58 @@ async fn summarize_text(text: String, api_key: String, product_id: String, promp
"messages": messages
});
emit_log(&app, "DEBUG", &format!("POST {}", url));
let res = client.post(&url)
.header("Authorization", format!("Bearer {}", api_key))
.header("Content-Type", "application/json")
.json(&body)
.send()
.await
.map_err(|e| e.to_string())?;
.map_err(|e| {
let msg = format!("Network error during summarization: {}", e);
emit_log(&app, "ERROR", &msg);
msg
})?;
if res.status().is_success() {
let raw_body = res.text().await.map_err(|e| e.to_string())?;
println!("Summarization Raw Response: {}", raw_body);
// println!("Summarization Raw Response: {}", raw_body);
let response_body: ChatCompletionResponse = serde_json::from_str(&raw_body)
.map_err(|e| format!("Failed to decode JSON: {}. Body: {}", e, raw_body))?;
if let Some(choice) = response_body.choices.first() {
emit_log(&app, "SUCCESS", "Summarization received.");
Ok(choice.message.content.clone())
} else {
emit_log(&app, "WARN", "No summary generated in response.");
Err("No summary generated".to_string())
}
} else {
let error_text = res.text().await.unwrap_or_default();
emit_log(&app, "ERROR", &format!("Summarization failed: {}", error_text));
Err(format!("Summarization failed: {}", error_text))
}
}
#[tauri::command]
fn open_audio_midi_setup() -> Result<(), String> {
Command::new("open")
.arg("-a")
.arg("Audio MIDI Setup")
.spawn()
.map_err(|e| e.to_string())?;
Ok(())
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init())
.manage(AppState {
recording_stream: Mutex::new(None),
recording_file_path: Mutex::new(None),
@@ -378,12 +550,14 @@ pub fn run() {
.invoke_handler(tauri::generate_handler![
greet,
get_input_devices,
install_driver,
start_recording,
stop_recording,
pause_recording,
resume_recording,
transcribe_audio,
summarize_text,
get_available_models
get_available_models,
open_audio_midi_setup
])
.run(tauri::generate_context!())
.expect("error while running tauri application");

View File

@@ -2,7 +2,7 @@
"$schema": "https://schema.tauri.app/config/2",
"productName": "Hearbit AI",
"version": "0.1.0",
"identifier": "com.hearbit-ai.app",
"identifier": "com.hearbit-ai.desktop",
"build": {
"beforeDevCommand": "npm run dev",
"devUrl": "http://localhost:1420",
@@ -13,8 +13,8 @@
"windows": [
{
"title": "Hearbit AI",
"width": 800,
"height": 600
"width": 1000,
"height": 800
}
],
"security": {
@@ -30,6 +30,9 @@
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"resources": [
"resources/BlackHole2ch.v0.6.1.pkg"
]
}
}

View File

@@ -1,23 +1,145 @@
import { useState } from "react";
import { useState, useEffect } from 'react';
import { listen } from "@tauri-apps/api/event";
import { Settings as SettingsIcon } from "lucide-react";
import Settings from "./components/Settings";
import Recorder from "./components/Recorder";
import LogViewer, { LogEntry } from "./components/LogViewer";
import TranscriptionView from "./components/TranscriptionView";
import Tabs from "./components/Tabs";
interface PromptTemplate {
export interface PromptTemplate {
id: string;
name: string;
content: string;
}
function App() {
const [view, setView] = useState<'recorder' | 'settings'>('recorder');
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 [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') || '');
// Default prompts if none exist
/* eslint-disable no-useless-escape */ // Escape quotes in prompts
const defaultPrompts: PromptTemplate[] = [
{ id: '1', name: 'General Summary', content: 'Summarize the following text into clear bullet points.' },
{ id: '2', name: 'Action Items', content: 'Extract all action items and tasks from this text.' },
{ id: '3', name: 'Email Draft', content: 'Draft a follow-up email based on this conversation.' }
{
id: '1',
name: 'Meeting Protokoll (General)',
content: `Rolle: Du bist ein hochprofessioneller, effizienter Protokollführer und persönlicher Assistent. Deine Aufgabe ist es, aus dem untenstehenden Roh-Transkript (oder den Notizen) ein strukturiertes, leicht lesbares und handlungsorientiertes Ergebnisprotokoll zu erstellen.
Anweisungen:
- Filterung: Ignoriere Smalltalk, Füllwörter, Begrüßungen und irrelevante Abschweifungen. Konzentriere dich auf Fakten, Entscheidungen und Aufgaben.
- Tonalität: Schreibe sachlich, objektiv und präzise (Business German).
- Klarheit: Formuliere vage Aussagen in klare Sätze um, ohne den Sinn zu verändern.
- Zuordnung: Ordne Aufgaben immer einer Person zu (wenn im Text genannt).
Gewünschte Struktur des Outputs:
# Meeting Protokoll: [Thema des Meetings]
Datum: [Datum einfügen, falls bekannt, sonst "N/A"]
Anwesende: [Liste der Namen]
## 1. Management Summary
Eine kurze Zusammenfassung der wichtigsten Punkte in 3-5 Sätzen. Worum ging es im Kern?
## 2. Wichtige Entscheidungen
[Entscheidung 1]
[Entscheidung 2]
(Liste hier nur Dinge auf, die explizit beschlossen wurden)
## 3. Offene Fragen / Diskussionspunkte
Kurze Stichpunkte zu Themen, die besprochen, aber noch nicht final geklärt wurden.
## 4. Action Items (To-Dos)
| Was ist zu tun? | Wer ist verantwortlich? | Bis wann? (falls genannt) |
| :--- | :--- | :--- |
| [Aufgabe 1] | [Name] | [Datum] |
| [Aufgabe 2] | [Name] | [Datum] |
## 5. Nächste Schritte / Nächstes Meeting
Kurze Info zum weiteren Vorgehen.`
},
{
id: '2',
name: '1:1 Gespräch / Jour Fixe',
content: `Rolle: Du bist ein diskreter und empathischer Executive Assistant. Deine Aufgabe ist es, ein 1:1 Gespräch zwischen einem Mitarbeiter und seinem Vorgesetzten zusammenzufassen. Das Gespräch beinhaltet sowohl geschäftliche als auch private/persönliche Themen.
Wichtige Anweisungen:
- Kategorisierung: Trenne strikt zwischen "Persönlichem/Atmosphäre" und "Operativem Business".
- Tonalität bei Privatem: Fasse private Themen (Wochenende, Hobbys, Familie, Wohlbefinden) als fließenden Text zusammen. Sei hier warmherzig, aber diskret. Vermeide Stichpunkte, das wirkt bei Privatem zu mechanisch.
- Tonalität bei Business: Sei hier gewohnt präzise, faktenbasiert und nutze Bulletpoints.
- Sensibilität: Wenn über Feedback, Karriereentwicklung oder Kritik gesprochen wurde, fasse dies in einem separaten Abschnitt neutral und konstruktiv zusammen.
Gewünschte Struktur des Outputs:
# 1:1 Gesprächszusammenfassung
Datum: [Datum]
Teilnehmer: [Namen]
## 1. Persönlicher Check-in & Atmosphäre
[Hier bitte einen kurzen Fließtext schreiben: Wie geht es den Teilnehmern? Was wurde an privaten Updates geteilt (z.B. Urlaub, Familie, Hobbys)? Wie war die Grundstimmung des Gesprächs?]
## 2. Operative Themen (Business Updates)
Thema A: [Kurze Zusammenfassung]
Thema B: [Kurze Zusammenfassung]
(Führe hier die konkreten Arbeitsthemen auf)
## 3. Feedback & Entwicklung
[Hier notieren, falls über Karriereziele, Gehalt, Weiterbildung oder gegenseitiges Feedback gesprochen wurde. Falls nicht besprochen: "Keine Themen in diesem Meeting".]
## 4. Vereinbarungen & Action Items
| Wer? | Was ist zu tun / zu beachten? | Bis wann? |
| :--- | :--- | :--- |
| [Name] | [Aufgabe] | [Datum] |
| [Name] | [Aufgabe] | [Datum] |`
},
{
id: '3',
name: 'Kundenmeeting (Official)',
content: `Rolle: Du bist ein professioneller Account Manager und Business Analyst. Erstelle ein Protokoll für ein Kundenmeeting. Deine Tonalität ist höflich, verbindlich und extrem präzise. Das Ergebnis soll so formuliert sein, dass es theoretisch direkt an den Kunden gesendet werden kann.
Wichtige Anweisungen:
- Fokus auf Vereinbarungen: Was wurde genau beschlossen? Wenn der Kunde eine Anforderung geändert hat, notiere das explizit.
- Verantwortlichkeiten trennen: Trenne bei den To-Dos strikt zwischen "Aufgaben für uns (Auftragnehmer)" und "Aufgaben für den Kunden" (z.B. Material liefern, Freigaben).
- Klarheit vor Länge: Vermeide interne Fachsprache, wenn möglich. Schreibe so, dass der Kunde es versteht.
- Diplomatie: Falls es Konflikte gab, formuliere diese lösungsorientiert und neutral (z.B. statt "Kunde hat sich beschwert" schreibe "Es wurde Feedback zu X besprochen, Lösungsweg ist Y").
Gewünschte Struktur des Outputs:
# Ergebnisprotokoll: [Projektname / Thema]
Datum: [Datum]
Teilnehmer: [Namen Kunden] & [Namen Intern]
## 1. Zusammenfassung (Executive Summary)
2-3 Sätze zum Ziel des Termins und dem aktuellen Status (z.B. "Wir haben den aktuellen Design-Sprint besprochen und die Anforderungen für Phase 2 finalisiert.")
## 2. Besprochene Punkte & Projektstatus
[Thema 1]: Kurze Zusammenfassung der Diskussion.
[Thema 2]: Kurze Zusammenfassung der Diskussion.
## 3. Wichtige Entscheidungen & Genehmigungen
(Dies ist der wichtigste Teil für die Absicherung!)
✅ Beschluss: [Was wurde final entschieden/freigegeben?]
🔄 Änderung: [Wurde der Projektumfang geändert? Neue Anforderungen?]
## 4. Action Items (Wer macht was?)
👉 Aufgaben für uns (Intern):
[ ] [Aufgabe] (bis [Datum])
[ ] [Aufgabe] (bis [Datum])
👉 Aufgaben für den Kunden (To-Dos / Lieferungen):
[ ] [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?`
}
];
const [prompts, setPrompts] = useState<PromptTemplate[]>(() => {
@@ -25,14 +147,16 @@ function App() {
return saved ? JSON.parse(saved) : defaultPrompts;
});
const handleSaveSettings = (newApiKey: string, newProductId: string, newPrompts: PromptTemplate[]) => {
const handleSaveSettings = (newApiKey: string, newProductId: string, newPrompts: PromptTemplate[], newSavePath: string) => {
setApiKey(newApiKey);
setProductId(newProductId);
setPrompts(newPrompts);
setSavePath(newSavePath);
localStorage.setItem('infomaniak_api_key', newApiKey);
localStorage.setItem('infomaniak_product_id', newProductId);
localStorage.setItem('infomaniak_prompts', JSON.stringify(newPrompts));
setView('recorder');
localStorage.setItem('infomaniak_save_path', newSavePath);
setView(lastTab);
};
// State for Recorder (lifted to persist across view changes)
@@ -76,19 +200,56 @@ function App() {
const handleLoadHistory = (item: HistoryItem) => {
setTranscription(item.transcription);
setSummary(item.summary);
setView('recorder'); // Ensure we go back to recorder to see it
};
// 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">
<div className="flex-1 flex flex-col justify-center h-full">
{view === 'recorder' ? (
<div className="min-h-screen bg-background text-foreground flex flex-col select-none overflow-hidden">
{/* Top Navigation Bar */}
{view !== 'settings' && (
<div className="w-full flex justify-center items-center pt-4 pb-2 z-20 relative bg-background/95 backdrop-blur">
<div className="absolute right-4 top-4">
<button
onClick={() => {
setLastTab(view === 'logs' ? 'logs' : 'recorder');
setView('settings');
}}
className="p-2 text-muted-foreground hover:text-foreground hover:bg-secondary rounded-full transition-colors"
>
<SettingsIcon size={20} />
</button>
</div>
<Tabs
currentTab={view as 'recorder' | 'logs' | 'transcription'}
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={() => setView('settings')}
onOpenSettings={() => {
setLastTab('recorder');
setView('settings');
}}
transcription={transcription}
setTranscription={setTranscription}
summary={summary}
@@ -97,23 +258,30 @@ function App() {
onSaveToHistory={handleSaveToHistory}
onDeleteHistory={handleDeleteHistory}
onLoadHistory={handleLoadHistory}
savePath={savePath}
onRecordingComplete={() => setView('transcription')}
/>
) : (
)}
{view === 'transcription' && (
<TranscriptionView transcription={transcription} summary={summary} />
)}
{view === 'logs' && (
<LogViewer logs={logs} />
)}
{view === 'settings' && (
<Settings
onSave={handleSaveSettings}
onBack={() => setView('recorder')}
initialApiKey={apiKey}
initialProductId={productId}
initialPrompts={prompts}
onClose={() => setView(lastTab)}
apiKey={apiKey}
productId={productId}
prompts={prompts}
savePath={savePath}
/>
)}
</div>
{view === 'settings' && (
<div className="fixed top-4 right-4 z-50">
{/* Close button handled inside Settings typically */}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,91 @@
import React, { useEffect, useRef } from 'react';
import { Terminal, Clock, AlertCircle, Info, CheckCircle, Activity } from 'lucide-react';
export interface LogEntry {
level: string;
message: string;
timestamp: string;
}
interface LogViewerProps {
logs: LogEntry[];
}
const LogViewer: React.FC<LogViewerProps> = ({ logs }) => {
const endRef = useRef<HTMLDivElement>(null);
useEffect(() => {
endRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [logs]);
const getIcon = (level: string) => {
switch (level) {
case 'ERROR': return <AlertCircle size={14} className="text-destructive mb-0.5" />;
case 'WARN': return <AlertCircle size={14} className="text-yellow-500 mb-0.5" />;
case 'SUCCESS': return <CheckCircle size={14} className="text-green-500 mb-0.5" />;
case 'DEBUG': return <Activity size={14} className="text-blue-400 mb-0.5" />;
default: return <Info size={14} className="text-primary mb-0.5" />;
}
};
const getColor = (level: string) => {
switch (level) {
case 'ERROR': return 'text-destructive';
case 'WARN': return 'text-yellow-500';
case 'SUCCESS': return 'text-green-500';
case 'DEBUG': return 'text-muted-foreground';
default: return 'text-foreground';
}
};
const handleExportLogs = () => {
const text = logs.map(l => `[${l.timestamp}] [${l.level}] ${l.message}`).join('\n');
const blob = new Blob([text], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `hearbit_logs_${new Date().toISOString().slice(0, 10)}.txt`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
return (
<div className="flex flex-col h-full bg-background font-mono text-sm">
<div className="p-3 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">System Logs</span>
<button
onClick={handleExportLogs}
className="text-xs bg-secondary hover:bg-secondary/80 text-foreground px-2 py-1 rounded border border-border transition-colors flex items-center gap-1"
>
<Terminal size={12} className="inline mr-1" /> Export .txt
</button>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-1">
{logs.length === 0 && (
<div className="text-muted-foreground opacity-50 text-center mt-10 italic">
No logs yet...
</div>
)}
{logs.map((log, i) => (
<div key={i} className="flex gap-3 py-1.5 px-3 hover:bg-secondary/30 rounded-md transition-colors items-start">
<span className="text-muted-foreground/60 text-xs mt-0.5 shrink-0 select-none flex items-center gap-1.5 min-w-[70px]">
<Clock size={10} />
{log.timestamp}
</span>
<div className={`mt-0.5 shrink-0 ${getColor(log.level)}`}>
{getIcon(log.level)}
</div>
<span className={`break-all ${getColor(log.level)}`}>
{log.message}
</span>
</div>
))}
<div ref={endRef} />
</div>
</div>
);
};
export default LogViewer;

View File

@@ -1,8 +1,7 @@
import React, { useState, useEffect } from 'react';
import { Mic, Square, Settings as SettingsIcon, FileText, Sparkles, Copy, Check } from 'lucide-react';
import { Mic, Square } from 'lucide-react';
import { invoke } from "@tauri-apps/api/core";
import logo from '../assets/logo.png'; // Import logo
import ReactMarkdown from 'react-markdown'; // Import Markdown renderer
interface PromptTemplate {
id: string;
@@ -22,7 +21,7 @@ interface RecorderProps {
productId: string;
prompts: PromptTemplate[];
onOpenSettings: () => void;
// Lifted State Props
// Lifted State Props (still passed for state management, though unused in view)
transcription: string;
setTranscription: (val: string) => void;
summary: string;
@@ -32,6 +31,8 @@ interface RecorderProps {
onSaveToHistory: (t?: string, s?: string) => void;
onDeleteHistory: (id: string) => void;
onLoadHistory: (item: HistoryItem) => void;
savePath: string | null;
onRecordingComplete: () => void;
}
interface AudioDevice {
@@ -39,30 +40,19 @@ interface AudioDevice {
name: string;
}
const LLM_MODELS = [
{ id: 'mixtral', name: 'Mixtral 8x7B (Best for Logic)' },
{ id: 'llama3', name: 'Llama 3 (Balanced)' },
{ id: 'llama3-70b', name: 'Llama 3 70B (High Quality)' },
{ id: 'granite-code', name: 'Granite Code (Coding)' }
];
const Recorder: React.FC<RecorderProps> = ({
apiKey, productId, prompts, onOpenSettings,
transcription, setTranscription, summary, setSummary,
history, onSaveToHistory, onDeleteHistory, onLoadHistory
apiKey, productId, prompts,
setTranscription, setSummary,
onSaveToHistory, savePath, onRecordingComplete
}) => {
const [isRecording, setIsRecording] = useState(false);
const [isPaused, setIsPaused] = useState(false);
const [status, setStatus] = useState<string>('Ready to record');
const [selectedDevice, setSelectedDevice] = useState<string>('');
const [selectedPromptId, setSelectedPromptId] = useState<string>('');
const [selectedModel, setSelectedModel] = useState<string>('mixtral');
const [devices, setDevices] = useState<AudioDevice[]>([]);
const [availableModels, setAvailableModels] = useState<Array<{ id: string, name: string }>>(LLM_MODELS);
const [showHistory, setShowHistory] = useState(false); // Toggle history view
// Local state for copy feedback only
const [copiedTrans, setCopiedTrans] = useState(false);
const [copiedSum, setCopiedSum] = useState(false);
const [availableModels, setAvailableModels] = useState<Array<{ id: string, name: string }>>([]);
useEffect(() => {
loadDevices();
@@ -75,7 +65,6 @@ const Recorder: React.FC<RecorderProps> = ({
try {
const models = await invoke<Array<{ id: string, name: string }>>('get_available_models', { apiKey, productId });
if (models && models.length > 0) {
// Sort models alphabetically
models.sort((a, b) => a.name.localeCompare(b.name));
setAvailableModels(models);
}
@@ -84,13 +73,11 @@ const Recorder: React.FC<RecorderProps> = ({
}
};
// Set default prompt selection
useEffect(() => {
if (prompts.length > 0 && !selectedPromptId) {
setSelectedPromptId(prompts[0].id);
} else if (prompts.length > 0 && selectedPromptId) {
// Check if selected still exists
if (!prompts.find(p => p.id === selectedPromptId)) {
setSelectedPromptId(prompts[0].id);
}
@@ -100,20 +87,42 @@ const Recorder: React.FC<RecorderProps> = ({
const loadDevices = async () => {
try {
const devList = await invoke<AudioDevice[]>('get_input_devices');
setDevices(devList);
if (devList.length > 0 && !selectedDevice) {
setSelectedDevice(devList[0].id);
// Alias BlackHole
const aliasedDevs = devList.map(d => ({
...d,
name: d.name.includes('BlackHole') ? 'Hearbit Virtual Mic (BlackHole)' : d.name
}));
setDevices(aliasedDevs);
// Select Hearbit mic by default if available and no selection made
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);
}
}
} catch (e) {
console.error('Failed to load devices', e);
}
};
const openAudioSetup = async () => {
try {
await invoke('open_audio_midi_setup');
} catch (e) {
console.error(e);
setStatus('Failed to open Audio Setup');
}
};
const startRecording = async () => {
try {
setStatus('Starting...');
await invoke('start_recording', { deviceId: selectedDevice });
await invoke('start_recording', { deviceId: selectedDevice, savePath: savePath || null });
setIsRecording(true);
setIsPaused(false);
setTranscription('');
setSummary('');
setStatus('Recording...');
@@ -124,9 +133,26 @@ const Recorder: React.FC<RecorderProps> = ({
}
};
const togglePause = async () => {
try {
if (isPaused) {
await invoke('resume_recording');
setIsPaused(false);
setStatus('Recording...');
} else {
await invoke('pause_recording');
setIsPaused(true);
setStatus('Paused');
}
} catch (e) {
console.error("Pause/Resume error:", e);
}
};
const stopRecording = async () => {
try {
setIsRecording(false);
setIsPaused(false);
setStatus('Processing...');
const filePath = await invoke<string>('stop_recording');
@@ -138,12 +164,9 @@ const Recorder: React.FC<RecorderProps> = ({
});
setTranscription(transText);
setTranscription(transText);
// Check if transcription is empty or just whitespace
if (!transText || transText.trim().length === 0) {
setStatus('Done (No speech detected)');
// If empty, set a placeholder so the UI shows something, but DON'T summarize
setTranscription('(No speech detected. Check your microphone settings.)');
setTimeout(() => setStatus('Ready to record'), 3000);
return;
@@ -167,6 +190,7 @@ const Recorder: React.FC<RecorderProps> = ({
onSaveToHistory(transText, sumText);
setStatus('Done!');
onRecordingComplete(); // Auto-switch tab
setTimeout(() => setStatus('Ready to record'), 3000);
} catch (e) {
console.error(e);
@@ -174,251 +198,127 @@ const Recorder: React.FC<RecorderProps> = ({
}
};
const installDriver = async () => {
try {
setStatus('Installing driver...');
const res = await invoke('install_driver');
console.log(res);
setStatus('Driver installed. Please restart app.');
loadDevices();
} catch (e) {
setStatus(`Install failed: ${e}`);
}
};
const copyToClipboard = async (text: string, isSummary: boolean) => {
try {
await navigator.clipboard.writeText(text);
if (isSummary) {
setCopiedSum(true);
setTimeout(() => setCopiedSum(false), 2000);
} else {
setCopiedTrans(true);
setTimeout(() => setCopiedTrans(false), 2000);
}
} catch (err) {
console.error('Failed to copy!', err);
}
};
return (
<div className="flex flex-col w-full h-full bg-background relative">
{/* Fixed Header */}
<div className="w-full flex justify-between items-center p-6 border-b border-border/40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 z-10 sticky top-0 shrink-0">
<div className="w-full flex justify-center items-center p-6 shrink-0">
<img src={logo} alt="Logo" className="h-12 object-contain" />
<div className="flex items-center gap-2">
<button
onClick={() => setShowHistory(!showHistory)}
className={`flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-medium transition-all ${showHistory
? 'bg-primary/10 text-primary'
: 'text-muted-foreground hover:text-foreground hover:bg-secondary'
}`}
>
<FileText size={16} />
{showHistory ? 'Recorder' : 'Records'}
</button>
<button
onClick={onOpenSettings}
className="p-2 text-muted-foreground hover:text-foreground hover:bg-secondary rounded-md transition-colors"
>
<SettingsIcon size={20} />
</button>
</div>
</div>
{/* Scrollable Content */}
<div className="flex-1 overflow-y-auto p-6 flex flex-col items-center pb-20">
{showHistory ? (
<div className="w-full max-w-md space-y-4">
<h2 className="text-xl font-bold mb-4">Saved Recordings</h2>
{history.length === 0 && <p className="text-muted-foreground">No saved history.</p>}
{history.map(item => (
<div key={item.id} className="p-4 rounded-lg bg-card border border-border shadow-sm">
<div className="flex justify-between items-start mb-2">
<span className="text-xs text-muted-foreground">{item.date}</span>
<div className="flex gap-2">
<button
onClick={() => { onLoadHistory(item); setShowHistory(false); }}
className="text-xs bg-primary text-primary-foreground px-2 py-1 rounded"
>
Load
</button>
<button
onClick={() => onDeleteHistory(item.id)}
className="text-xs bg-destructive text-destructive-foreground px-2 py-1 rounded"
>
Delete
</button>
</div>
</div>
<p className="text-sm truncate font-medium">{item.summary ? item.summary.slice(0, 50) + "..." : "No Summary"}</p>
<p className="text-xs text-muted-foreground truncate">{item.transcription.slice(0, 50)}...</p>
<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'}`}>
{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>
))}
</div>
) : (
<>
<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 ? 'bg-red-500/10 animate-pulse' : 'bg-secondary'}`}>
{isRecording ? (
<div className="w-24 h-24 rounded-full bg-red-500 flex items-center justify-center shadow-[0_0_20px_rgba(239,68,68,0.5)]">
<Mic size={40} 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>
)}
</div>
</div>
<h1 className="text-2xl font-bold mb-2 text-foreground">
{isRecording ? 'Listening...' : 'Ready to Record'}
</h1>
<p className="text-muted-foreground mb-6 text-center text-sm h-6">
{status}
</p>
<div className="w-full max-w-sm space-y-4 mb-6 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={stopRecording}
className="w-full 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 Recording
</button>
)}
<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}
>
{devices.map(d => (
<option key={d.id} value={d.id}>{d.name}</option>
))}
{devices.length === 0 && <option value="">Loading devices...</option>}
</select>
</div>
<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)}
className="w-full p-2 text-sm bg-secondary rounded border border-border outline-none focus:ring-2 focus:ring-primary"
disabled={isRecording}
>
{availableModels.map(m => (
<option key={m.id} value={m.id}>{m.name}</option>
))}
</select>
</div>
</div>
<div className="w-full">
<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)}
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}
>
{prompts.map(p => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
{prompts.length === 0 && <option value="">No templates</option>}
</select>
</div>
<button
onClick={installDriver}
className="text-xs text-primary underline w-full text-center hover:text-primary/80 mt-1 block"
>
Install BlackHole Driver (for System Audio)
</button>
</div>
{(transcription || summary) && (
<div className="w-full space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500 pb-10">
{/* Transcription Block (Source) */}
{transcription && (
<div className="p-5 bg-card rounded-xl border border-border shadow-sm">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2 text-blue-500 font-semibold">
<FileText size={18} />
<h3>Original Transcription</h3>
</div>
<button
onClick={() => copyToClipboard(transcription, false)}
className="p-1.5 hover:bg-secondary rounded-md transition-colors text-muted-foreground hover:text-foreground"
title="Copy Transcription"
>
{copiedTrans ? <Check size={16} className="text-green-500" /> : <Copy size={16} />}
</button>
</div>
<p className="text-sm text-foreground/80 whitespace-pre-wrap max-h-[200px] overflow-y-auto pr-2 bg-secondary/30 p-3 rounded-lg border border-border/50 font-medium">
{transcription}
</p>
</div>
)}
{/* Summary Block (Result) */}
{summary && (
<div className="p-5 bg-card rounded-xl border border-border shadow-sm ring-1 ring-purple-500/20">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2 text-purple-500 font-semibold">
<Sparkles size={18} />
<h3>AI Summary</h3>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => onSaveToHistory()}
className="text-xs bg-green-600 text-white px-3 py-1.5 rounded-md hover:bg-green-700 font-medium transition-colors"
>
Save
</button>
<button
onClick={() => copyToClipboard(summary, true)}
className="p-1.5 hover:bg-secondary rounded-md transition-colors text-muted-foreground hover:text-foreground"
title="Copy Summary"
>
{copiedSum ? <Check size={16} className="text-green-500" /> : <Copy size={16} />}
</button>
</div>
</div>
<div className="text-sm text-foreground/90 leading-relaxed max-h-[400px] overflow-y-auto pr-2 custom-scrollbar prose prose-sm dark:prose-invert max-w-none prose-p:my-1 prose-headings:my-2 prose-ul:my-1 prose-li:my-0.5">
<ReactMarkdown>
{summary}
</ReactMarkdown>
</div>
</div>
)}
) : (
<div className="w-24 h-24 rounded-full bg-primary flex items-center justify-center">
<Mic size={40} 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>
<p className="text-muted-foreground mb-6 text-center text-sm h-6">
{status}
</p>
<div className="w-full max-w-sm space-y-4 mb-6 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>
) : (
<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>
<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
</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}
>
{devices.map(d => (
<option key={d.id} value={d.id}>{d.name}</option>
))}
{devices.length === 0 && <option value="">Loading devices...</option>}
</select>
</div>
<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)}
className="w-full p-2 text-sm bg-secondary rounded border border-border outline-none focus:ring-2 focus:ring-primary"
disabled={isRecording}
>
{availableModels.map(m => (
<option key={m.id} value={m.id}>{m.name}</option>
))}
</select>
</div>
</div>
<div className="w-full">
<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)}
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}
>
{prompts.map(p => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
{prompts.length === 0 && <option value="">No templates</option>}
</select>
</div>
<div className="flex flex-col gap-2 mt-2 w-full">
<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)
</button>
</div>
</div>
</div>
</div>
);

View File

@@ -1,157 +1,319 @@
import React, { useState, useEffect } from 'react';
import { Plus, Trash2 } from 'lucide-react';
interface PromptTemplate {
id: string;
name: string;
content: string;
}
import React, { useState } from 'react';
import { Save, FolderOpen, Lock, Upload, Download, Eye, EyeOff } from 'lucide-react';
import { open } from '@tauri-apps/plugin-dialog';
import { encryptData, decryptData } from '../utils/backup';
import { PromptTemplate } from '../App';
interface SettingsProps {
onSave: (apiKey: string, productId: string, prompts: PromptTemplate[]) => void;
onBack: () => void; // New onBack prop
initialApiKey: string;
initialProductId: string;
initialPrompts: PromptTemplate[];
apiKey: string;
productId: string;
savePath: string;
prompts: PromptTemplate[];
onSave: (apiKey: string, productId: string, prompts: PromptTemplate[], savePath: string) => void;
onClose: () => void;
}
const Settings: React.FC<SettingsProps> = ({ onSave, onBack, initialApiKey, initialProductId, initialPrompts }) => {
const [apiKey, setApiKey] = useState(initialApiKey);
const [productId, setProductId] = useState(initialProductId);
const [prompts, setPrompts] = useState<PromptTemplate[]>(initialPrompts);
const Settings: React.FC<SettingsProps> = ({ apiKey, productId, prompts, savePath, onSave, onClose }) => {
const [localApiKey, setLocalApiKey] = useState(apiKey);
const [localProductId, setLocalProductId] = useState(productId);
const [localSavePath, setLocalSavePath] = useState(savePath);
const [localPrompts, setLocalPrompts] = useState<PromptTemplate[]>(prompts);
const [statusIdx, setStatusIdx] = useState<string | null>(null);
useEffect(() => {
setApiKey(initialApiKey);
setProductId(initialProductId);
// Only reset prompts if passed different ones (mounting), usually state is preserved in App
}, [initialApiKey, initialProductId]);
// Backup & Restore State
const [backupPassword, setBackupPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [isImportModalOpen, setIsImportModalOpen] = useState(false);
const [importFileContent, setImportFileContent] = useState<string | null>(null);
const handleSave = (e: React.FormEvent) => {
e.preventDefault();
onSave(apiKey, productId, prompts);
const handlePromptChange = (id: string, field: 'name' | 'content', value: string) => {
setLocalPrompts(localPrompts.map(p => p.id === id ? { ...p, [field]: value } : p));
};
const addPrompt = () => {
setPrompts([...prompts, { id: Date.now().toString(), name: 'New Prompt', content: '' }]);
};
const updatePrompt = (id: string, field: 'name' | 'content', value: string) => {
setPrompts(prompts.map(p => p.id === id ? { ...p, [field]: value } : p));
setLocalPrompts([...localPrompts, { id: Date.now().toString(), name: 'New Prompt', content: '' }]);
};
const removePrompt = (id: string) => {
setPrompts(prompts.filter(p => p.id !== id));
setLocalPrompts(localPrompts.filter(p => p.id !== id));
};
const handleSave = () => {
onSave(localApiKey, localProductId, localPrompts, localSavePath);
onClose();
};
const handleSelectFolder = async () => {
try {
const selected = await open({
directory: true,
multiple: false,
defaultPath: localSavePath || undefined,
});
if (selected && typeof selected === 'string') {
setLocalSavePath(selected);
}
} catch (e) {
console.error("Failed to open directory picker", e);
setStatusIdx('Error: Failed to open directory picker.');
}
};
const handleExport = async () => {
if (!backupPassword) {
setStatusIdx('Error: Password required to encrypt backup.');
return;
}
try {
const data = {
apiKey: localApiKey,
productId: localProductId,
prompts: localPrompts,
savePath: localSavePath
};
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!');
} catch (e) {
console.error(e);
setStatusIdx('Export failed.');
}
};
const triggerImport = () => {
document.getElementById('import-file-input')?.click();
};
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
if (event.target?.result) {
setImportFileContent(event.target.result as string);
setIsImportModalOpen(true);
setBackupPassword('');
}
};
reader.readAsText(file);
e.target.value = '';
};
const confirmImport = async () => {
if (!backupPassword) {
setStatusIdx('Error: Password required to decrypt.');
return;
}
if (!importFileContent) return;
try {
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.savePath) setLocalSavePath(data.savePath);
setStatusIdx('Configuration imported! Click Save to apply.');
setIsImportModalOpen(false);
setImportFileContent(null);
} catch (e) {
console.error(e);
setStatusIdx('Import failed: Wrong password or corrupted file.');
}
};
return (
<div className="p-6 w-full max-w-2xl mx-auto mt-6 animate-in fade-in duration-500">
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-bold text-foreground">Infomaniak Settings</h2>
<button
type="button"
onClick={onBack}
className="text-sm text-muted-foreground hover:text-foreground hover:bg-secondary px-3 py-1.5 rounded-md transition-colors"
>
Back to Recorder
<div className="flex flex-col h-full bg-background font-mono text-sm relative">
{/* Import Password Modal */}
{isImportModalOpen && (
<div className="absolute inset-0 z-50 bg-black/50 flex items-center justify-center p-4">
<div className="bg-background border border-border rounded-lg shadow-xl p-6 w-full max-w-sm space-y-4">
<div className="flex items-center gap-2 text-foreground font-semibold">
<Lock size={16} /> Import Configuration
</div>
<p className="text-muted-foreground text-xs">
Enter the password used to encrypt this backup file.
</p>
<div className="relative">
<input
type={showPassword ? "text" : "password"}
value={backupPassword}
onChange={(e) => setBackupPassword(e.target.value)}
placeholder="Backup Password"
className="w-full p-2 pr-8 rounded border border-border bg-secondary text-foreground focus:ring-2 focus:ring-primary outline-none"
/>
<button
onClick={() => setShowPassword(!showPassword)}
className="absolute right-2 top-2.5 text-muted-foreground hover:text-foreground"
>
{showPassword ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
</div>
<div className="flex justify-end gap-2 pt-2">
<button
onClick={() => setIsImportModalOpen(false)}
className="px-3 py-1.5 text-xs font-medium rounded border border-border hover:bg-secondary text-foreground transition-colors"
>
Cancel
</button>
<button
onClick={confirmImport}
disabled={!backupPassword}
className="px-3 py-1.5 text-xs font-medium rounded bg-primary text-primary-foreground hover:bg-primary/90 transition-colors disabled:opacity-50"
>
Decrypt & Import
</button>
</div>
</div>
</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>
<form onSubmit={handleSave} className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<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">
Infomaniak API Key
</label>
<label htmlFor="apiKey" className="block text-sm font-medium mb-1 text-foreground">API Key</label>
<input
id="apiKey"
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder="ik_..."
className="w-full p-2 rounded border border-border bg-secondary text-foreground focus:ring-2 focus:ring-primary outline-none"
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>
<label htmlFor="productId" className="block text-sm font-medium mb-1 text-foreground">Product ID</label>
<input
id="productId"
type="text"
value={productId}
onChange={(e) => setProductId(e.target.value)}
placeholder="Number (e.g., 12345)"
className="w-full p-2 rounded border border-border bg-secondary text-foreground focus:ring-2 focus:ring-primary outline-none"
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>
<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"
/>
<button
onClick={() => setShowPassword(!showPassword)}
className="absolute right-2 top-2.5 text-muted-foreground hover:text-foreground"
>
{showPassword ? <EyeOff size={14} /> : <Eye size={14} />}
</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="border-t border-border pt-6">
<div className="flex justify-between items-center mb-4">
<label className="block text-sm font-medium text-foreground">
Summarization Templates
</label>
<button
type="button"
onClick={addPrompt}
className="text-xs flex items-center gap-1 bg-secondary hover:bg-secondary/80 text-foreground px-3 py-1 rounded transition-colors"
>
<Plus size={14} /> Add Template
<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 className="space-y-4 max-h-[400px] overflow-y-auto pr-2">
{prompts.map((prompt) => (
<div key={prompt.id} className="p-4 rounded-lg border border-border bg-card shadow-sm space-y-3">
<div className="flex justify-between gap-3">
<input
type="text"
value={prompt.name}
onChange={(e) => updatePrompt(prompt.id, 'name', e.target.value)}
placeholder="Template Name (e.g., Email Summary)"
className="flex-1 font-semibold bg-transparent border-none p-0 focus:ring-0 text-foreground placeholder:text-muted-foreground outline-none"
/>
<button
type="button"
onClick={() => removePrompt(prompt.id)}
className="text-muted-foreground hover:text-destructive transition-colors"
title="Remove Template"
>
<Trash2 size={16} />
</button>
</div>
<textarea
value={prompt.content}
onChange={(e) => updatePrompt(prompt.id, 'content', e.target.value)}
placeholder="Enter instructions for the AI..."
rows={3}
className="w-full p-2 text-sm rounded border border-border bg-secondary/50 text-foreground focus:ring-1 focus:ring-primary outline-none resize-none"
/>
</div>
))}
{prompts.length === 0 && (
<p className="text-center text-sm text-muted-foreground py-4">
No templates defined. Click "Add Template" to create one.
</p>
)}
{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>
</div>
<div className="flex gap-4">
<button
type="button"
onClick={onBack}
className="w-1/3 bg-secondary text-secondary-foreground py-3 px-4 rounded-lg hover:bg-secondary/80 transition-all font-semibold"
>
Cancel
</button>
<button
type="submit"
className="w-2/3 bg-primary text-primary-foreground py-3 px-4 rounded-lg hover:bg-primary/90 transition-all font-semibold shadow-lg hover:shadow-xl transform hover:-translate-y-0.5"
>
Save Configuration
</button>
</div>
</form>
)}
</div>
</div>
);
};

54
src/components/Tabs.tsx Normal file
View File

@@ -0,0 +1,54 @@
import React from 'react';
import { Mic, Terminal, FileText } from 'lucide-react';
interface TabsProps {
currentTab: 'recorder' | 'logs' | 'transcription' | 'settings';
onTabChange: (tab: 'recorder' | 'logs' | 'transcription' | 'settings') => void;
}
const Tabs: React.FC<TabsProps> = ({ currentTab, onTabChange }) => {
return (
<div className="flex items-center gap-1 bg-secondary/50 p-1 rounded-full border border-border/40 self-center">
<button
onClick={() => onTabChange('recorder')}
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${currentTab === 'recorder' ? 'bg-secondary text-foreground' : 'text-muted-foreground hover:text-foreground hover:bg-secondary/50'}`}
>
<Mic size={16} />
Recording
</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'}`}
>
<FileText size={16} />
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'
}`}
>
<Terminal size={14} />
Logs
</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".
Let's keep settings as a tab to simplify navigation for now. */}
{/* <button
onClick={() => onTabChange('settings')}
className={`flex items-center gap-2 px-4 py-1.5 rounded-full text-sm font-medium transition-all duration-200 ${
currentTab === 'settings'
? 'bg-background shadow-sm text-foreground ring-1 ring-border/50'
: 'text-muted-foreground hover:text-foreground hover:bg-background/50'
}`}
>
<Settings size={14} />
Settings
</button> */}
</div>
);
};
export default Tabs;

View File

@@ -0,0 +1,81 @@
import React, { useState } from 'react';
import ReactMarkdown from 'react-markdown';
import { Copy, Check } from 'lucide-react';
interface TranscriptionViewProps {
transcription: string;
summary: string;
}
const TranscriptionView: React.FC<TranscriptionViewProps> = ({ transcription, summary }) => {
const [copiedTrans, setCopiedTrans] = useState(false);
const [copiedSum, setCopiedSum] = useState(false);
const handleCopy = async (text: string, isSummary: boolean) => {
if (!text) return;
try {
await navigator.clipboard.writeText(text);
if (isSummary) {
setCopiedSum(true);
setTimeout(() => setCopiedSum(false), 2000);
} else {
setCopiedTrans(true);
setTimeout(() => setCopiedTrans(false), 2000);
}
} catch (err) {
console.error('Failed to copy!', err);
}
};
return (
<div className="flex flex-col h-full bg-background font-mono text-sm overflow-hidden">
<div className="flex-1 flex flex-col md:flex-row divide-y md:divide-y-0 md:divide-x divide-border/40 overflow-hidden">
{/* Original Transcription */}
<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">Original Transcription</span>
<button
onClick={() => handleCopy(transcription, false)}
className="text-xs flex items-center gap-1 hover:text-primary transition-colors disabled:opacity-50"
disabled={!transcription}
>
{copiedTrans ? <Check size={14} className="text-green-500" /> : <Copy size={14} />}
{copiedTrans ? 'Copied' : 'Copy'}
</button>
</div>
<div className="flex-1 overflow-y-auto p-4 bg-background">
{transcription ? (
<p className="whitespace-pre-wrap text-foreground leading-relaxed">{transcription}</p>
) : (
<p className="text-muted-foreground italic text-xs">No transcription available yet...</p>
)}
</div>
</div>
{/* AI Summary */}
<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>
<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>
) : (
<p className="text-muted-foreground italic text-xs">No summary available yet...</p>
)}
</div>
</div>
</div>
</div>
);
};
export default TranscriptionView;

92
src/utils/backup.ts Normal file
View File

@@ -0,0 +1,92 @@
// Generate a key from a password
async function getKey(password: string, salt: Uint8Array): Promise<CryptoKey> {
const enc = new TextEncoder();
const keyMaterial = await window.crypto.subtle.importKey(
"raw",
enc.encode(password),
{ name: "PBKDF2" },
false,
["deriveKey"]
);
return window.crypto.subtle.deriveKey(
{
name: "PBKDF2",
salt: salt as any,
iterations: 100000,
hash: "SHA-256"
},
keyMaterial,
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"]
);
}
export async function encryptData(data: object, password: string): Promise<string> {
const salt = window.crypto.getRandomValues(new Uint8Array(16));
const iv = window.crypto.getRandomValues(new Uint8Array(12));
const key = await getKey(password, salt);
const enc = new TextEncoder();
const encodedData = enc.encode(JSON.stringify(data));
const encryptedContent = await window.crypto.subtle.encrypt(
{
name: "AES-GCM",
iv: iv
},
key,
encodedData
);
const buffer = new Uint8Array(salt.byteLength + iv.byteLength + encryptedContent.byteLength);
buffer.set(salt, 0);
buffer.set(iv, salt.byteLength);
buffer.set(new Uint8Array(encryptedContent), salt.byteLength + iv.byteLength);
// Safer binary to string conversion to avoid stack overflow with spread operator
let binary = '';
const len = buffer.byteLength;
for (let i = 0; i < len; i++) {
binary += String.fromCharCode(buffer[i]);
}
return btoa(binary);
}
export async function decryptData(base64Data: string, password: string): Promise<any> {
try {
const binaryString = atob(base64Data.trim());
const len = binaryString.length;
const buffer = new Uint8Array(len);
for (let i = 0; i < len; i++) {
buffer[i] = binaryString.charCodeAt(i);
}
const salt = buffer.slice(0, 16);
const iv = buffer.slice(16, 28);
const ciphertext = buffer.slice(28);
const key = await getKey(password, salt);
const decryptedContent = await window.crypto.subtle.decrypt(
{
name: "AES-GCM",
iv: iv
},
key,
ciphertext
);
const dec = new TextDecoder();
return JSON.parse(dec.decode(decryptedContent));
} catch (e: any) {
console.error("Decryption internal error:", e);
// Distinguish between password error (OperationError) and others if possible
if (e.name === 'OperationError') {
throw new Error("Incorrect password.");
}
throw new Error(`Import failed: ${e.message || 'Corrupted file'}`);
}
}