feat: release 1.0 - rename to Hearbit AI, fix timestamps, update UI
7
.gitignore
vendored
@@ -12,6 +12,13 @@ dist
|
|||||||
dist-ssr
|
dist-ssr
|
||||||
*.local
|
*.local
|
||||||
|
|
||||||
|
# Tauri build output
|
||||||
|
src-tauri/target/
|
||||||
|
src-tauri/target_tmp/
|
||||||
|
src-tauri/gen/
|
||||||
|
**/*.dmg
|
||||||
|
**/*.app
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
.vscode/*
|
.vscode/*
|
||||||
!.vscode/extensions.json
|
!.vscode/extensions.json
|
||||||
|
|||||||
112
README.md
@@ -1,74 +1,74 @@
|
|||||||
# Hearbit AI 🦉🎙️
|
# 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
|

|
||||||
|
|
||||||
* **Dual-Channel Recording**: Capture both your microphone (e.g., for voice notes) and system audio (e.g., for meetings) simultaneously.
|
## ✨ Features
|
||||||
* **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.
|
|
||||||
|
|
||||||
## 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`).
|
### 1. Prerequisites
|
||||||
## Recording System Audio (Teams, Zoom, etc.)
|
* **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)
|
## 🎧 Recording System Audio (Teams, Zoom, etc.)
|
||||||
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**.
|
|
||||||
|
|
||||||

|
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. **Install BlackHole**: Download and install [BlackHole 2ch](https://existential.audio/blackhole/).
|
||||||
1. In Audio MIDI Setup, click `+` and select **Create Aggregate Device**.
|
2. **Create a Multi-Output Device** (So you can hear the audio too!):
|
||||||
2. Name it "Hearbit-AI" (or similar).
|
* Open **Audio MIDI Setup** on your Mac.
|
||||||
3. Check **BlackHole 2ch** AND **MacBook Pro Microphone**.
|
* Create a "Multi-Output Device".
|
||||||
4. Ensure "Drift Correction" is enabled for the Microphone.
|
* 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**.
|
||||||
|
|
||||||

|
---
|
||||||
|
|
||||||
### 4. Setup in Hearbit AI
|
## 🛠️ Usage Guide
|
||||||
1. Open **Hearbit AI**.
|
|
||||||
2. In the **Input Device** dropdown, select your Aggregate Device (e.g., "Hearbit-AI").
|
|
||||||
3. Start Recording.
|
|
||||||
|
|
||||||
### 5. Setup in Teams/Zoom
|
1. **Configuration**:
|
||||||
* Set your **Speaker** output in Teams/Zoom to the **Multi-Output Device** you created in Step 2.
|
* Click the **Settings** (gear icon).
|
||||||
|
|
||||||
1. **Configure**:
|
|
||||||
* Click the **Settings** (gear icon) in the top right.
|
|
||||||
* Enter your **Infomaniak API Key** and **Product ID**.
|
* Enter your **Infomaniak API Key** and **Product ID**.
|
||||||
* (Optional) Customize your AI prompts.
|
* (Optional) Customize where recordings are saved.
|
||||||
2. **Record**:
|
|
||||||
* Select your **Input Device** (e.g., "MacBook Pro Microphone" or an aggregate device for system audio).
|
2. **Recording**:
|
||||||
* Select an **LLM Model** (e.g., Mixtral).
|
* Choose your **Template** (e.g., "Meeting Protocol").
|
||||||
|
* Select your **Input Device**.
|
||||||
* Click **Start Recording**.
|
* 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
|
### Setup
|
||||||
```bash
|
```bash
|
||||||
@@ -77,8 +77,6 @@ npm install
|
|||||||
|
|
||||||
### Run Locally
|
### Run Locally
|
||||||
```bash
|
```bash
|
||||||
npm run dev
|
|
||||||
# OR
|
|
||||||
npm run tauri dev
|
npm run tauri dev
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -86,9 +84,9 @@ npm run tauri dev
|
|||||||
```bash
|
```bash
|
||||||
npm run tauri build
|
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
|
||||||
[License Name]
|
Property of Livtec. All rights reserved.
|
||||||
|
|||||||
14
package-lock.json
generated
@@ -1,15 +1,16 @@
|
|||||||
{
|
{
|
||||||
"name": "infomaniak-recorder",
|
"name": "hearbit-ai",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "infomaniak-recorder",
|
"name": "hearbit-ai",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/postcss": "^4.1.18",
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
|
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||||
"@tauri-apps/plugin-opener": "^2",
|
"@tauri-apps/plugin-opener": "^2",
|
||||||
"jimp": "^1.6.0",
|
"jimp": "^1.6.0",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
@@ -2076,6 +2077,15 @@
|
|||||||
"node": ">= 10"
|
"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": {
|
"node_modules/@tauri-apps/plugin-opener": {
|
||||||
"version": "2.5.3",
|
"version": "2.5.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.3.tgz",
|
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.3.tgz",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/postcss": "^4.1.18",
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
|
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||||
"@tauri-apps/plugin-opener": "^2",
|
"@tauri-apps/plugin-opener": "^2",
|
||||||
"jimp": "^1.6.0",
|
"jimp": "^1.6.0",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
|
|||||||
100
src-tauri/Cargo.lock
generated
@@ -488,8 +488,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118"
|
checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"iana-time-zone",
|
"iana-time-zone",
|
||||||
|
"js-sys",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
"serde",
|
"serde",
|
||||||
|
"wasm-bindgen",
|
||||||
"windows-link 0.2.1",
|
"windows-link 0.2.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -819,6 +821,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec"
|
checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.10.0",
|
"bitflags 2.10.0",
|
||||||
|
"block2",
|
||||||
|
"libc",
|
||||||
"objc2",
|
"objc2",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1515,6 +1519,23 @@ version = "0.16.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
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]]
|
[[package]]
|
||||||
name = "heck"
|
name = "heck"
|
||||||
version = "0.4.1"
|
version = "0.4.1"
|
||||||
@@ -1834,21 +1855,6 @@ dependencies = [
|
|||||||
"cfb",
|
"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]]
|
[[package]]
|
||||||
name = "ipnet"
|
name = "ipnet"
|
||||||
version = "2.11.0"
|
version = "2.11.0"
|
||||||
@@ -3309,6 +3315,30 @@ dependencies = [
|
|||||||
"web-sys",
|
"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]]
|
[[package]]
|
||||||
name = "ring"
|
name = "ring"
|
||||||
version = "0.17.14"
|
version = "0.17.14"
|
||||||
@@ -4167,6 +4197,46 @@ dependencies = [
|
|||||||
"walkdir",
|
"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]]
|
[[package]]
|
||||||
name = "tauri-plugin-opener"
|
name = "tauri-plugin-opener"
|
||||||
version = "2.5.3"
|
version = "2.5.3"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "infomaniak-recorder"
|
name = "hearbit-ai"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
description = "A Tauri App"
|
description = "A Tauri App"
|
||||||
authors = ["you"]
|
authors = ["you"]
|
||||||
@@ -20,10 +20,11 @@ tauri-build = { version = "2", features = [] }
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
tauri = { version = "2", features = [] }
|
tauri = { version = "2", features = [] }
|
||||||
tauri-plugin-opener = "2"
|
tauri-plugin-opener = "2"
|
||||||
|
tauri-plugin-dialog = "2"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1.0"
|
||||||
|
chrono = "0.4"
|
||||||
cpal = "0.17.1"
|
cpal = "0.17.1"
|
||||||
hound = "3.5.1"
|
hound = "3.5.1"
|
||||||
reqwest = { version = "0.13.1", features = ["json", "multipart"] }
|
reqwest = { version = "0.13.1", features = ["json", "multipart"] }
|
||||||
tokio = { version = "1.40.0", features = ["full"] }
|
tokio = { version = "1.40.0", features = ["full"] }
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,12 @@
|
|||||||
"$schema": "../gen/schemas/desktop-schema.json",
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
"identifier": "default",
|
"identifier": "default",
|
||||||
"description": "Capability for the main window",
|
"description": "Capability for the main window",
|
||||||
"windows": ["main"],
|
"windows": [
|
||||||
|
"main"
|
||||||
|
],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default",
|
"core:default",
|
||||||
"opener:default"
|
"opener:default",
|
||||||
|
"dialog:default"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 746 B After Width: | Height: | Size: 2.7 KiB |
BIN
src-tauri/icons/64x64.png
Normal file
|
After Width: | Height: | Size: 8.9 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 903 B After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 152 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 5.7 KiB |
@@ -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>
|
||||||
BIN
src-tauri/icons/android/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 4.3 KiB |
BIN
src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
BIN
src-tauri/icons/android/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 79 KiB |
BIN
src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 165 KiB |
BIN
src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 280 KiB |
BIN
src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="ic_launcher_background">#fff</color>
|
||||||
|
</resources>
|
||||||
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 382 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@1x.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@2x-1.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@2x.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@3x.png
Normal file
|
After Width: | Height: | Size: 7.9 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@1x.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@2x-1.png
Normal file
|
After Width: | Height: | Size: 7.5 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@2x.png
Normal file
|
After Width: | Height: | Size: 7.5 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@3x.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@1x.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@2x-1.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@2x.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@3x.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
src-tauri/icons/ios/AppIcon-512@2x.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
src-tauri/icons/ios/AppIcon-60x60@2x.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
src-tauri/icons/ios/AppIcon-60x60@3x.png
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
src-tauri/icons/ios/AppIcon-76x76@1x.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
src-tauri/icons/ios/AppIcon-76x76@2x.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
src-tauri/resources/BlackHole2ch.v0.6.1.pkg
Normal file
@@ -1,4 +1,4 @@
|
|||||||
use tauri::State;
|
use tauri::{AppHandle, Manager, State, Emitter};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||||
@@ -17,6 +17,22 @@ struct AudioDevice {
|
|||||||
name: String,
|
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]
|
#[tauri::command]
|
||||||
fn greet(name: &str) -> String {
|
fn greet(name: &str) -> String {
|
||||||
format!("Hello, {}! You've been greeted from Rust!", name)
|
format!("Hello, {}! You've been greeted from Rust!", name)
|
||||||
@@ -41,22 +57,11 @@ fn get_input_devices() -> Result<Vec<AudioDevice>, String> {
|
|||||||
Ok(result)
|
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]
|
#[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();
|
let host = cpal::default_host();
|
||||||
|
|
||||||
// Find device by name (using name as ID)
|
// 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,
|
sample_format: hound::SampleFormat::Int,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create a temporary file
|
// Determine file path: User provided or Temp
|
||||||
let temp_dir = std::env::temp_dir();
|
let file_path = if let Some(path) = save_path {
|
||||||
let file_path = temp_dir.join(format!("recording_{}.wav", std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs()));
|
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();
|
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 = hound::WavWriter::create(&file_path, spec).map_err(|e| e.to_string())?;
|
||||||
let writer = Arc::new(Mutex::new(writer));
|
let writer = Arc::new(Mutex::new(writer));
|
||||||
let writer_clone = writer.clone();
|
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() {
|
let stream = match config.sample_format() {
|
||||||
cpal::SampleFormat::F32 => device.build_input_stream(
|
cpal::SampleFormat::F32 => device.build_input_stream(
|
||||||
@@ -128,13 +148,15 @@ fn start_recording(state: State<'_, AppState>, device_id: String) -> Result<(),
|
|||||||
|
|
||||||
// Store state
|
// Store state
|
||||||
*state.recording_stream.lock().unwrap() = Some(stream);
|
*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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[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
|
// Drop stream to stop recording
|
||||||
{
|
{
|
||||||
let mut stream_guard = state.recording_stream.lock().unwrap();
|
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
|
// Return file path
|
||||||
let mut path_guard = state.recording_file_path.lock().unwrap();
|
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)]
|
#[derive(serde::Deserialize)]
|
||||||
@@ -157,6 +207,7 @@ struct ModelListResponse {
|
|||||||
#[derive(serde::Deserialize)]
|
#[derive(serde::Deserialize)]
|
||||||
struct ModelData {
|
struct ModelData {
|
||||||
id: String,
|
id: String,
|
||||||
|
#[allow(dead_code)]
|
||||||
owned_by: Option<String>,
|
owned_by: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,6 +228,7 @@ struct Choice {
|
|||||||
}
|
}
|
||||||
#[derive(serde::Deserialize)]
|
#[derive(serde::Deserialize)]
|
||||||
struct Message {
|
struct Message {
|
||||||
|
#[allow(dead_code)]
|
||||||
content: String,
|
content: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,20 +239,27 @@ struct ModelInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[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();
|
let client = reqwest::Client::new();
|
||||||
// Use the v2/openai compliant endpoint as per docs
|
// Use the v2/openai compliant endpoint as per docs
|
||||||
let url = format!("https://api.infomaniak.com/2/ai/{}/openai/v1/models", product_id);
|
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)
|
let res = client.get(&url)
|
||||||
.header("Authorization", format!("Bearer {}", api_key))
|
.header("Authorization", format!("Bearer {}", api_key))
|
||||||
.send()
|
.send()
|
||||||
.await
|
.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() {
|
if res.status().is_success() {
|
||||||
let raw_body = res.text().await.map_err(|e| e.to_string())?;
|
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)
|
let list: ModelListResponse = serde_json::from_str(&raw_body)
|
||||||
.map_err(|e| format!("Failed to parse models: {}. Body: {}", e, 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 {
|
.map(|m| ModelInfo {
|
||||||
id: m.id.clone(),
|
id: m.id.clone(),
|
||||||
name: m.id, // Use ID as name for now, or fetch more details if available
|
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)
|
Ok(models)
|
||||||
} else {
|
} 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();
|
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))
|
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]
|
#[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();
|
let client = reqwest::Client::new();
|
||||||
|
|
||||||
// Prepare file part
|
// 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()
|
let form = reqwest::multipart::Form::new()
|
||||||
.part("file", file_part)
|
.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);
|
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)
|
let res = client.post(&url)
|
||||||
.header("Authorization", format!("Bearer {}", api_key))
|
.header("Authorization", format!("Bearer {}", api_key))
|
||||||
.multipart(form)
|
.multipart(form)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.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() {
|
if res.status().is_success() {
|
||||||
let raw_body = res.text().await.map_err(|e| e.to_string())?;
|
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
|
// Check if we got a batch ID
|
||||||
// Attempt to parse text or batch_id
|
#[derive(serde::Deserialize)]
|
||||||
let response: WhisperResponse = serde_json::from_str(&raw_body)
|
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))?;
|
.map_err(|e| format!("Failed to decode JSON: {}. Body: {}", e, raw_body))?;
|
||||||
|
|
||||||
match (response.text, response.batch_id) {
|
if let Some(segments) = response.segments {
|
||||||
(Some(text), _) => Ok(text),
|
emit_log(&app, "INFO", &format!("Found {} segments (Direct).", segments.len()));
|
||||||
(_, Some(batch_id)) => {
|
for (i, seg) in segments.iter().take(3).enumerate() {
|
||||||
// Need to poll
|
emit_log(&app, "DEBUG", &format!("Seg {}: start={}", i, seg.start));
|
||||||
poll_transcription(&client, &api_key, &product_id, &batch_id).await
|
}
|
||||||
},
|
|
||||||
_ => Err(format!("Response contained neither text nor batch_id. Body: {}", raw_body))
|
// 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 {
|
} else {
|
||||||
let error_text = res.text().await.unwrap_or_default();
|
let error_text = res.text().await.unwrap_or_default();
|
||||||
|
emit_log(&app, "ERROR", &format!("Transcription failed: {}", error_text));
|
||||||
Err(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> {
|
async fn poll_transcription(app: &AppHandle, 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.
|
|
||||||
let status_url = format!("https://api.infomaniak.com/1/ai/{}/results/{}", product_id, batch_id);
|
let status_url = format!("https://api.infomaniak.com/1/ai/{}/results/{}", product_id, batch_id);
|
||||||
|
|
||||||
let mut attempts = 0;
|
let mut attempts = 0;
|
||||||
@@ -280,6 +397,7 @@ async fn poll_transcription(client: &reqwest::Client, api_key: &str, product_id:
|
|||||||
attempts += 1;
|
attempts += 1;
|
||||||
sleep(Duration::from_secs(2)).await;
|
sleep(Duration::from_secs(2)).await;
|
||||||
|
|
||||||
|
emit_log(app, "DEBUG", &format!("Polling status... Attempt {}", attempts));
|
||||||
let res = client.get(&status_url)
|
let res = client.get(&status_url)
|
||||||
.header("Authorization", format!("Bearer {}", api_key))
|
.header("Authorization", format!("Bearer {}", api_key))
|
||||||
.send()
|
.send()
|
||||||
@@ -301,31 +419,63 @@ async fn poll_transcription(client: &reqwest::Client, api_key: &str, product_id:
|
|||||||
|
|
||||||
if dl_res.status().is_success() {
|
if dl_res.status().is_success() {
|
||||||
let content = dl_res.text().await.map_err(|e| e.to_string())?;
|
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": "..." }
|
// Try to parse as Verbose JSON to get timestamps
|
||||||
if let Ok(json_val) = serde_json::from_str::<serde_json::Value>(&content) {
|
if let Ok(response) = serde_json::from_str::<WhisperVerboseResponse>(&content) {
|
||||||
if let Some(text_content) = json_val.get("text").and_then(|t| t.as_str()) {
|
if let Some(segments) = response.segments {
|
||||||
return Ok(text_content.to_string());
|
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
|
// If not JSON or no text field, return raw content
|
||||||
return Ok(content);
|
return Ok(content);
|
||||||
} else {
|
} else {
|
||||||
|
emit_log(app, "ERROR", "Failed to download transcription results.");
|
||||||
return Err(format!("Download failed: {}", dl_res.status()));
|
return Err(format!("Download failed: {}", dl_res.status()));
|
||||||
}
|
}
|
||||||
} else if status == "failed" || status == "error" {
|
} else if status == "failed" || status == "error" {
|
||||||
|
emit_log(app, "ERROR", &format!("Batch processing failed: {:?}", json));
|
||||||
return Err(format!("Batch processing failed: {:?}", json));
|
return Err(format!("Batch processing failed: {:?}", json));
|
||||||
}
|
}
|
||||||
// If 'processing' or 'pending', continue loop
|
// If 'processing' or 'pending', continue loop
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
emit_log(app, "ERROR", "Transcription timed out after 80s.");
|
||||||
Err("Transcription timed out".to_string())
|
Err("Transcription timed out".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[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 client = reqwest::Client::new();
|
||||||
let url = format!("https://api.infomaniak.com/2/ai/{}/openai/v1/chat/completions", product_id);
|
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
|
"messages": messages
|
||||||
});
|
});
|
||||||
|
|
||||||
|
emit_log(&app, "DEBUG", &format!("POST {}", url));
|
||||||
|
|
||||||
let res = client.post(&url)
|
let res = client.post(&url)
|
||||||
.header("Authorization", format!("Bearer {}", api_key))
|
.header("Authorization", format!("Bearer {}", api_key))
|
||||||
.header("Content-Type", "application/json")
|
.header("Content-Type", "application/json")
|
||||||
.json(&body)
|
.json(&body)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.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() {
|
if res.status().is_success() {
|
||||||
let raw_body = res.text().await.map_err(|e| e.to_string())?;
|
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)
|
let response_body: ChatCompletionResponse = serde_json::from_str(&raw_body)
|
||||||
.map_err(|e| format!("Failed to decode JSON: {}. Body: {}", e, raw_body))?;
|
.map_err(|e| format!("Failed to decode JSON: {}. Body: {}", e, raw_body))?;
|
||||||
|
|
||||||
if let Some(choice) = response_body.choices.first() {
|
if let Some(choice) = response_body.choices.first() {
|
||||||
|
emit_log(&app, "SUCCESS", "Summarization received.");
|
||||||
Ok(choice.message.content.clone())
|
Ok(choice.message.content.clone())
|
||||||
} else {
|
} else {
|
||||||
|
emit_log(&app, "WARN", "No summary generated in response.");
|
||||||
Err("No summary generated".to_string())
|
Err("No summary generated".to_string())
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let error_text = res.text().await.unwrap_or_default();
|
let error_text = res.text().await.unwrap_or_default();
|
||||||
|
emit_log(&app, "ERROR", &format!("Summarization failed: {}", error_text));
|
||||||
Err(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)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.manage(AppState {
|
.manage(AppState {
|
||||||
recording_stream: Mutex::new(None),
|
recording_stream: Mutex::new(None),
|
||||||
recording_file_path: Mutex::new(None),
|
recording_file_path: Mutex::new(None),
|
||||||
@@ -378,12 +550,14 @@ pub fn run() {
|
|||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
greet,
|
greet,
|
||||||
get_input_devices,
|
get_input_devices,
|
||||||
install_driver,
|
|
||||||
start_recording,
|
start_recording,
|
||||||
stop_recording,
|
stop_recording,
|
||||||
|
pause_recording,
|
||||||
|
resume_recording,
|
||||||
transcribe_audio,
|
transcribe_audio,
|
||||||
summarize_text,
|
summarize_text,
|
||||||
get_available_models
|
get_available_models,
|
||||||
|
open_audio_midi_setup
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Hearbit AI",
|
"productName": "Hearbit AI",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"identifier": "com.hearbit-ai.app",
|
"identifier": "com.hearbit-ai.desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
"devUrl": "http://localhost:1420",
|
"devUrl": "http://localhost:1420",
|
||||||
@@ -13,8 +13,8 @@
|
|||||||
"windows": [
|
"windows": [
|
||||||
{
|
{
|
||||||
"title": "Hearbit AI",
|
"title": "Hearbit AI",
|
||||||
"width": 800,
|
"width": 1000,
|
||||||
"height": 600
|
"height": 800
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"security": {
|
||||||
@@ -30,6 +30,9 @@
|
|||||||
"icons/128x128@2x.png",
|
"icons/128x128@2x.png",
|
||||||
"icons/icon.icns",
|
"icons/icon.icns",
|
||||||
"icons/icon.ico"
|
"icons/icon.ico"
|
||||||
|
],
|
||||||
|
"resources": [
|
||||||
|
"resources/BlackHole2ch.v0.6.1.pkg"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
214
src/App.tsx
@@ -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 Settings from "./components/Settings";
|
||||||
import Recorder from "./components/Recorder";
|
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;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
content: string;
|
content: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function App() {
|
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 [apiKey, setApiKey] = useState(localStorage.getItem('infomaniak_api_key') || '');
|
||||||
const [productId, setProductId] = useState(localStorage.getItem('infomaniak_product_id') || '');
|
const [productId, setProductId] = useState(localStorage.getItem('infomaniak_product_id') || '');
|
||||||
|
const [savePath, setSavePath] = useState(localStorage.getItem('infomaniak_save_path') || '');
|
||||||
|
|
||||||
// Default prompts if none exist
|
// Default prompts if none exist
|
||||||
|
/* eslint-disable no-useless-escape */ // Escape quotes in prompts
|
||||||
const defaultPrompts: PromptTemplate[] = [
|
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: '1',
|
||||||
{ id: '3', name: 'Email Draft', content: 'Draft a follow-up email based on this conversation.' }
|
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[]>(() => {
|
const [prompts, setPrompts] = useState<PromptTemplate[]>(() => {
|
||||||
@@ -25,14 +147,16 @@ function App() {
|
|||||||
return saved ? JSON.parse(saved) : defaultPrompts;
|
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);
|
setApiKey(newApiKey);
|
||||||
setProductId(newProductId);
|
setProductId(newProductId);
|
||||||
setPrompts(newPrompts);
|
setPrompts(newPrompts);
|
||||||
|
setSavePath(newSavePath);
|
||||||
localStorage.setItem('infomaniak_api_key', newApiKey);
|
localStorage.setItem('infomaniak_api_key', newApiKey);
|
||||||
localStorage.setItem('infomaniak_product_id', newProductId);
|
localStorage.setItem('infomaniak_product_id', newProductId);
|
||||||
localStorage.setItem('infomaniak_prompts', JSON.stringify(newPrompts));
|
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)
|
// State for Recorder (lifted to persist across view changes)
|
||||||
@@ -76,19 +200,56 @@ function App() {
|
|||||||
const handleLoadHistory = (item: HistoryItem) => {
|
const handleLoadHistory = (item: HistoryItem) => {
|
||||||
setTranscription(item.transcription);
|
setTranscription(item.transcription);
|
||||||
setSummary(item.summary);
|
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 (
|
return (
|
||||||
<div className="min-h-screen bg-background text-foreground flex flex-col select-none">
|
<div className="min-h-screen bg-background text-foreground flex flex-col select-none overflow-hidden">
|
||||||
<div className="flex-1 flex flex-col justify-center h-full">
|
{/* Top Navigation Bar */}
|
||||||
{view === 'recorder' ? (
|
{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
|
<Recorder
|
||||||
apiKey={apiKey}
|
apiKey={apiKey}
|
||||||
productId={productId}
|
productId={productId}
|
||||||
prompts={prompts}
|
prompts={prompts}
|
||||||
onOpenSettings={() => setView('settings')}
|
onOpenSettings={() => {
|
||||||
|
setLastTab('recorder');
|
||||||
|
setView('settings');
|
||||||
|
}}
|
||||||
transcription={transcription}
|
transcription={transcription}
|
||||||
setTranscription={setTranscription}
|
setTranscription={setTranscription}
|
||||||
summary={summary}
|
summary={summary}
|
||||||
@@ -97,23 +258,30 @@ function App() {
|
|||||||
onSaveToHistory={handleSaveToHistory}
|
onSaveToHistory={handleSaveToHistory}
|
||||||
onDeleteHistory={handleDeleteHistory}
|
onDeleteHistory={handleDeleteHistory}
|
||||||
onLoadHistory={handleLoadHistory}
|
onLoadHistory={handleLoadHistory}
|
||||||
|
savePath={savePath}
|
||||||
|
onRecordingComplete={() => setView('transcription')}
|
||||||
/>
|
/>
|
||||||
) : (
|
)}
|
||||||
|
|
||||||
|
{view === 'transcription' && (
|
||||||
|
<TranscriptionView transcription={transcription} summary={summary} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{view === 'logs' && (
|
||||||
|
<LogViewer logs={logs} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{view === 'settings' && (
|
||||||
<Settings
|
<Settings
|
||||||
onSave={handleSaveSettings}
|
onSave={handleSaveSettings}
|
||||||
onBack={() => setView('recorder')}
|
onClose={() => setView(lastTab)}
|
||||||
initialApiKey={apiKey}
|
apiKey={apiKey}
|
||||||
initialProductId={productId}
|
productId={productId}
|
||||||
initialPrompts={prompts}
|
prompts={prompts}
|
||||||
|
savePath={savePath}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{view === 'settings' && (
|
|
||||||
<div className="fixed top-4 right-4 z-50">
|
|
||||||
{/* Close button handled inside Settings typically */}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
91
src/components/LogViewer.tsx
Normal 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;
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
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 { invoke } from "@tauri-apps/api/core";
|
||||||
import logo from '../assets/logo.png'; // Import logo
|
import logo from '../assets/logo.png'; // Import logo
|
||||||
import ReactMarkdown from 'react-markdown'; // Import Markdown renderer
|
|
||||||
|
|
||||||
interface PromptTemplate {
|
interface PromptTemplate {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -22,7 +21,7 @@ interface RecorderProps {
|
|||||||
productId: string;
|
productId: string;
|
||||||
prompts: PromptTemplate[];
|
prompts: PromptTemplate[];
|
||||||
onOpenSettings: () => void;
|
onOpenSettings: () => void;
|
||||||
// Lifted State Props
|
// Lifted State Props (still passed for state management, though unused in view)
|
||||||
transcription: string;
|
transcription: string;
|
||||||
setTranscription: (val: string) => void;
|
setTranscription: (val: string) => void;
|
||||||
summary: string;
|
summary: string;
|
||||||
@@ -32,6 +31,8 @@ interface RecorderProps {
|
|||||||
onSaveToHistory: (t?: string, s?: string) => void;
|
onSaveToHistory: (t?: string, s?: string) => void;
|
||||||
onDeleteHistory: (id: string) => void;
|
onDeleteHistory: (id: string) => void;
|
||||||
onLoadHistory: (item: HistoryItem) => void;
|
onLoadHistory: (item: HistoryItem) => void;
|
||||||
|
savePath: string | null;
|
||||||
|
onRecordingComplete: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AudioDevice {
|
interface AudioDevice {
|
||||||
@@ -39,30 +40,19 @@ interface AudioDevice {
|
|||||||
name: string;
|
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> = ({
|
const Recorder: React.FC<RecorderProps> = ({
|
||||||
apiKey, productId, prompts, onOpenSettings,
|
apiKey, productId, prompts,
|
||||||
transcription, setTranscription, summary, setSummary,
|
setTranscription, setSummary,
|
||||||
history, onSaveToHistory, onDeleteHistory, onLoadHistory
|
onSaveToHistory, savePath, onRecordingComplete
|
||||||
}) => {
|
}) => {
|
||||||
const [isRecording, setIsRecording] = useState(false);
|
const [isRecording, setIsRecording] = useState(false);
|
||||||
|
const [isPaused, setIsPaused] = useState(false);
|
||||||
const [status, setStatus] = useState<string>('Ready to record');
|
const [status, setStatus] = useState<string>('Ready to record');
|
||||||
const [selectedDevice, setSelectedDevice] = useState<string>('');
|
const [selectedDevice, setSelectedDevice] = useState<string>('');
|
||||||
const [selectedPromptId, setSelectedPromptId] = useState<string>('');
|
const [selectedPromptId, setSelectedPromptId] = useState<string>('');
|
||||||
const [selectedModel, setSelectedModel] = useState<string>('mixtral');
|
const [selectedModel, setSelectedModel] = useState<string>('mixtral');
|
||||||
const [devices, setDevices] = useState<AudioDevice[]>([]);
|
const [devices, setDevices] = useState<AudioDevice[]>([]);
|
||||||
const [availableModels, setAvailableModels] = useState<Array<{ id: string, name: string }>>(LLM_MODELS);
|
const [availableModels, setAvailableModels] = useState<Array<{ id: string, name: string }>>([]);
|
||||||
const [showHistory, setShowHistory] = useState(false); // Toggle history view
|
|
||||||
|
|
||||||
// Local state for copy feedback only
|
|
||||||
const [copiedTrans, setCopiedTrans] = useState(false);
|
|
||||||
const [copiedSum, setCopiedSum] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadDevices();
|
loadDevices();
|
||||||
@@ -75,7 +65,6 @@ const Recorder: React.FC<RecorderProps> = ({
|
|||||||
try {
|
try {
|
||||||
const models = await invoke<Array<{ id: string, name: string }>>('get_available_models', { apiKey, productId });
|
const models = await invoke<Array<{ id: string, name: string }>>('get_available_models', { apiKey, productId });
|
||||||
if (models && models.length > 0) {
|
if (models && models.length > 0) {
|
||||||
// Sort models alphabetically
|
|
||||||
models.sort((a, b) => a.name.localeCompare(b.name));
|
models.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
setAvailableModels(models);
|
setAvailableModels(models);
|
||||||
}
|
}
|
||||||
@@ -84,13 +73,11 @@ const Recorder: React.FC<RecorderProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
// Set default prompt selection
|
// Set default prompt selection
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (prompts.length > 0 && !selectedPromptId) {
|
if (prompts.length > 0 && !selectedPromptId) {
|
||||||
setSelectedPromptId(prompts[0].id);
|
setSelectedPromptId(prompts[0].id);
|
||||||
} else if (prompts.length > 0 && selectedPromptId) {
|
} else if (prompts.length > 0 && selectedPromptId) {
|
||||||
// Check if selected still exists
|
|
||||||
if (!prompts.find(p => p.id === selectedPromptId)) {
|
if (!prompts.find(p => p.id === selectedPromptId)) {
|
||||||
setSelectedPromptId(prompts[0].id);
|
setSelectedPromptId(prompts[0].id);
|
||||||
}
|
}
|
||||||
@@ -100,20 +87,42 @@ const Recorder: React.FC<RecorderProps> = ({
|
|||||||
const loadDevices = async () => {
|
const loadDevices = async () => {
|
||||||
try {
|
try {
|
||||||
const devList = await invoke<AudioDevice[]>('get_input_devices');
|
const devList = await invoke<AudioDevice[]>('get_input_devices');
|
||||||
setDevices(devList);
|
// Alias BlackHole
|
||||||
if (devList.length > 0 && !selectedDevice) {
|
const aliasedDevs = devList.map(d => ({
|
||||||
setSelectedDevice(devList[0].id);
|
...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) {
|
} catch (e) {
|
||||||
console.error('Failed to load devices', 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 () => {
|
const startRecording = async () => {
|
||||||
try {
|
try {
|
||||||
setStatus('Starting...');
|
setStatus('Starting...');
|
||||||
await invoke('start_recording', { deviceId: selectedDevice });
|
await invoke('start_recording', { deviceId: selectedDevice, savePath: savePath || null });
|
||||||
setIsRecording(true);
|
setIsRecording(true);
|
||||||
|
setIsPaused(false);
|
||||||
setTranscription('');
|
setTranscription('');
|
||||||
setSummary('');
|
setSummary('');
|
||||||
setStatus('Recording...');
|
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 () => {
|
const stopRecording = async () => {
|
||||||
try {
|
try {
|
||||||
setIsRecording(false);
|
setIsRecording(false);
|
||||||
|
setIsPaused(false);
|
||||||
setStatus('Processing...');
|
setStatus('Processing...');
|
||||||
const filePath = await invoke<string>('stop_recording');
|
const filePath = await invoke<string>('stop_recording');
|
||||||
|
|
||||||
@@ -138,12 +164,9 @@ const Recorder: React.FC<RecorderProps> = ({
|
|||||||
});
|
});
|
||||||
setTranscription(transText);
|
setTranscription(transText);
|
||||||
|
|
||||||
setTranscription(transText);
|
|
||||||
|
|
||||||
// Check if transcription is empty or just whitespace
|
// Check if transcription is empty or just whitespace
|
||||||
if (!transText || transText.trim().length === 0) {
|
if (!transText || transText.trim().length === 0) {
|
||||||
setStatus('Done (No speech detected)');
|
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.)');
|
setTranscription('(No speech detected. Check your microphone settings.)');
|
||||||
setTimeout(() => setStatus('Ready to record'), 3000);
|
setTimeout(() => setStatus('Ready to record'), 3000);
|
||||||
return;
|
return;
|
||||||
@@ -167,6 +190,7 @@ const Recorder: React.FC<RecorderProps> = ({
|
|||||||
onSaveToHistory(transText, sumText);
|
onSaveToHistory(transText, sumText);
|
||||||
|
|
||||||
setStatus('Done!');
|
setStatus('Done!');
|
||||||
|
onRecordingComplete(); // Auto-switch tab
|
||||||
setTimeout(() => setStatus('Ready to record'), 3000);
|
setTimeout(() => setStatus('Ready to record'), 3000);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(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 (
|
return (
|
||||||
<div className="flex flex-col w-full h-full bg-background relative">
|
<div className="flex flex-col w-full h-full bg-background relative">
|
||||||
{/* Fixed Header */}
|
{/* 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" />
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Scrollable Content */}
|
{/* Scrollable Content */}
|
||||||
<div className="flex-1 overflow-y-auto p-6 flex flex-col items-center pb-20">
|
<div className="flex-1 overflow-y-auto p-6 flex flex-col items-center pb-20">
|
||||||
|
<div className="mb-6 relative shrink-0">
|
||||||
{showHistory ? (
|
<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'}`}>
|
||||||
<div className="w-full max-w-md space-y-4">
|
{isRecording ? (
|
||||||
<h2 className="text-xl font-bold mb-4">Saved Recordings</h2>
|
<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'}`}>
|
||||||
{history.length === 0 && <p className="text-muted-foreground">No saved history.</p>}
|
<Mic size={40} className="text-white animate-bounce" />
|
||||||
{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>
|
</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 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>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,157 +1,319 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Plus, Trash2 } from 'lucide-react';
|
import { Save, FolderOpen, Lock, Upload, Download, Eye, EyeOff } from 'lucide-react';
|
||||||
|
import { open } from '@tauri-apps/plugin-dialog';
|
||||||
interface PromptTemplate {
|
import { encryptData, decryptData } from '../utils/backup';
|
||||||
id: string;
|
import { PromptTemplate } from '../App';
|
||||||
name: string;
|
|
||||||
content: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SettingsProps {
|
interface SettingsProps {
|
||||||
onSave: (apiKey: string, productId: string, prompts: PromptTemplate[]) => void;
|
apiKey: string;
|
||||||
onBack: () => void; // New onBack prop
|
productId: string;
|
||||||
initialApiKey: string;
|
savePath: string;
|
||||||
initialProductId: string;
|
prompts: PromptTemplate[];
|
||||||
initialPrompts: PromptTemplate[];
|
onSave: (apiKey: string, productId: string, prompts: PromptTemplate[], savePath: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Settings: React.FC<SettingsProps> = ({ onSave, onBack, initialApiKey, initialProductId, initialPrompts }) => {
|
const Settings: React.FC<SettingsProps> = ({ apiKey, productId, prompts, savePath, onSave, onClose }) => {
|
||||||
const [apiKey, setApiKey] = useState(initialApiKey);
|
const [localApiKey, setLocalApiKey] = useState(apiKey);
|
||||||
const [productId, setProductId] = useState(initialProductId);
|
const [localProductId, setLocalProductId] = useState(productId);
|
||||||
const [prompts, setPrompts] = useState<PromptTemplate[]>(initialPrompts);
|
const [localSavePath, setLocalSavePath] = useState(savePath);
|
||||||
|
const [localPrompts, setLocalPrompts] = useState<PromptTemplate[]>(prompts);
|
||||||
|
const [statusIdx, setStatusIdx] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
// Backup & Restore State
|
||||||
setApiKey(initialApiKey);
|
const [backupPassword, setBackupPassword] = useState('');
|
||||||
setProductId(initialProductId);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
// Only reset prompts if passed different ones (mounting), usually state is preserved in App
|
const [isImportModalOpen, setIsImportModalOpen] = useState(false);
|
||||||
}, [initialApiKey, initialProductId]);
|
const [importFileContent, setImportFileContent] = useState<string | null>(null);
|
||||||
|
|
||||||
const handleSave = (e: React.FormEvent) => {
|
const handlePromptChange = (id: string, field: 'name' | 'content', value: string) => {
|
||||||
e.preventDefault();
|
setLocalPrompts(localPrompts.map(p => p.id === id ? { ...p, [field]: value } : p));
|
||||||
onSave(apiKey, productId, prompts);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const addPrompt = () => {
|
const addPrompt = () => {
|
||||||
setPrompts([...prompts, { id: Date.now().toString(), name: 'New Prompt', content: '' }]);
|
setLocalPrompts([...localPrompts, { 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));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const removePrompt = (id: string) => {
|
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 (
|
return (
|
||||||
<div className="p-6 w-full max-w-2xl mx-auto mt-6 animate-in fade-in duration-500">
|
<div className="flex flex-col h-full bg-background font-mono text-sm relative">
|
||||||
<div className="flex justify-between items-center mb-6">
|
{/* Import Password Modal */}
|
||||||
<h2 className="text-2xl font-bold text-foreground">Infomaniak Settings</h2>
|
{isImportModalOpen && (
|
||||||
<button
|
<div className="absolute inset-0 z-50 bg-black/50 flex items-center justify-center p-4">
|
||||||
type="button"
|
<div className="bg-background border border-border rounded-lg shadow-xl p-6 w-full max-w-sm space-y-4">
|
||||||
onClick={onBack}
|
<div className="flex items-center gap-2 text-foreground font-semibold">
|
||||||
className="text-sm text-muted-foreground hover:text-foreground hover:bg-secondary px-3 py-1.5 rounded-md transition-colors"
|
<Lock size={16} /> Import Configuration
|
||||||
>
|
</div>
|
||||||
Back to Recorder
|
<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>
|
</button>
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<label htmlFor="apiKey" className="block text-sm font-medium mb-1 text-foreground">
|
<label htmlFor="apiKey" className="block text-sm font-medium mb-1 text-foreground">API Key</label>
|
||||||
Infomaniak API Key
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
id="apiKey"
|
id="apiKey"
|
||||||
type="password"
|
type="password"
|
||||||
value={apiKey}
|
value={localApiKey}
|
||||||
onChange={(e) => setApiKey(e.target.value)}
|
onChange={(e) => setLocalApiKey(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 font-mono text-sm"
|
||||||
className="w-full p-2 rounded border border-border bg-secondary text-foreground focus:ring-2 focus:ring-primary outline-none"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="productId" className="block text-sm font-medium mb-1 text-foreground">
|
<label htmlFor="productId" className="block text-sm font-medium mb-1 text-foreground">Product ID</label>
|
||||||
Product ID
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
id="productId"
|
id="productId"
|
||||||
type="text"
|
type="text"
|
||||||
value={productId}
|
value={localProductId}
|
||||||
onChange={(e) => setProductId(e.target.value)}
|
onChange={(e) => setLocalProductId(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 font-mono text-sm"
|
||||||
className="w-full p-2 rounded border border-border bg-secondary text-foreground focus:ring-2 focus:ring-primary outline-none"
|
/>
|
||||||
|
</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>
|
</div>
|
||||||
|
|
||||||
<div className="border-t border-border pt-6">
|
<div className="space-y-4 border rounded p-4 border-border/50">
|
||||||
<div className="flex justify-between items-center mb-4">
|
<div className="flex justify-between items-center">
|
||||||
<label className="block text-sm font-medium text-foreground">
|
<h3 className="text-foreground font-semibold">Prompts</h3>
|
||||||
Summarization Templates
|
<button onClick={addPrompt} className="text-xs bg-primary text-primary-foreground px-2 py-1 rounded hover:bg-primary/90">
|
||||||
</label>
|
+ Add Prompt
|
||||||
<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
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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">
|
{statusIdx && (
|
||||||
{prompts.map((prompt) => (
|
<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'}`}>
|
||||||
<div key={prompt.id} className="p-4 rounded-lg border border-border bg-card shadow-sm space-y-3">
|
{statusIdx}
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</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
@@ -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;
|
||||||
81
src/components/TranscriptionView.tsx
Normal 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
@@ -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'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||