Release 1.1: Pre-meeting models, Calendar improvements, Logs, Compact Recorder

This commit is contained in:
michael.borak
2026-01-20 17:15:14 +01:00
parent f61bcf1cc3
commit 79f509951c
16 changed files with 2011 additions and 321 deletions

View File

@@ -11,6 +11,7 @@
* **Upcoming Meetings**: View your daily schedule and join with **one click**. * **Upcoming Meetings**: View your daily schedule and join with **one click**.
* **Meeting Details**: View full agenda and **invited attendee status** (Accepted/Declined). * **Meeting Details**: View full agenda and **invited attendee status** (Accepted/Declined).
* **💾 Persistent History**: Automatically saves all transcripts and summaries to disk. Search and review past meetings anytime. * **💾 Persistent History**: Automatically saves all transcripts and summaries to disk. Search and review past meetings anytime.
* **✉️ Email Summaries**: Send professional, formatted HTML summaries (with preview) directly to attendees via your own SMTP server.
* **🧠 Powered by Infomaniak AI**: * **🧠 Powered by Infomaniak AI**:
* **Precision Transcription**: Standard-compliant formatting with **second-by-second timestamps**. * **Precision Transcription**: Standard-compliant formatting with **second-by-second timestamps**.
* **Smart Summaries**: Uses **Smart Templates** to automatically select the best format (Business Protocol vs. 1:1) based on meeting content. * **Smart Summaries**: Uses **Smart Templates** to automatically select the best format (Business Protocol vs. 1:1) based on meeting content.

View File

@@ -1,7 +1,7 @@
{ {
"name": "hearbit-ai", "name": "hearbit-ai",
"private": true, "private": true,
"version": "0.1.0", "version": "1.1.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

460
src-tauri/Cargo.lock generated
View File

@@ -8,6 +8,29 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "ahash"
version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9"
dependencies = [
"getrandom 0.2.17",
"once_cell",
"version_check",
]
[[package]]
name = "ahash"
version = "0.8.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
dependencies = [
"cfg-if",
"once_cell",
"version_check",
"zerocopy",
]
[[package]] [[package]]
name = "aho-corasick" name = "aho-corasick"
version = "1.1.4" version = "1.1.4"
@@ -32,6 +55,12 @@ dependencies = [
"alloc-no-stdlib", "alloc-no-stdlib",
] ]
[[package]]
name = "allocator-api2"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]] [[package]]
name = "alsa" name = "alsa"
version = "0.10.0" version = "0.10.0"
@@ -54,6 +83,23 @@ dependencies = [
"pkg-config", "pkg-config",
] ]
[[package]]
name = "android_log-sys"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84521a3cf562bc62942e294181d9eef17eb38ceb8c68677bc49f144e4c3d4f8d"
[[package]]
name = "android_logger"
version = "0.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbb4e440d04be07da1f1bf44fb4495ebd58669372fe0cffa6e48595ac5bd88a3"
dependencies = [
"android_log-sys",
"env_filter",
"log",
]
[[package]] [[package]]
name = "android_system_properties" name = "android_system_properties"
version = "0.1.5" version = "0.1.5"
@@ -69,6 +115,21 @@ version = "1.0.100"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
[[package]]
name = "ar_archive_writer"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0c269894b6fe5e9d7ada0cf69b5bf847ff35bc25fc271f08e1d080fce80339a"
dependencies = [
"object",
]
[[package]]
name = "arrayvec"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]] [[package]]
name = "async-broadcast" name = "async-broadcast"
version = "0.7.2" version = "0.7.2"
@@ -274,6 +335,18 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "bitvec"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c"
dependencies = [
"funty",
"radium",
"tap",
"wyz",
]
[[package]] [[package]]
name = "block-buffer" name = "block-buffer"
version = "0.10.4" version = "0.10.4"
@@ -305,6 +378,29 @@ dependencies = [
"piper", "piper",
] ]
[[package]]
name = "borsh"
version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1da5ab77c1437701eeff7c88d968729e7766172279eab0676857b3d63af7a6f"
dependencies = [
"borsh-derive",
"cfg_aliases",
]
[[package]]
name = "borsh-derive"
version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0686c856aa6aac0c4498f936d7d6a02df690f614c03e4d906d1018062b5c5e2c"
dependencies = [
"once_cell",
"proc-macro-crate 3.4.0",
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]] [[package]]
name = "brotli" name = "brotli"
version = "8.0.2" version = "8.0.2"
@@ -332,6 +428,40 @@ version = "3.19.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510"
[[package]]
name = "byte-unit"
version = "5.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c6d47a4e2961fb8721bcfc54feae6455f2f64e7054f9bc67e875f0e77f4c58d"
dependencies = [
"rust_decimal",
"schemars 1.2.0",
"serde",
"utf8-width",
]
[[package]]
name = "bytecheck"
version = "0.6.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2"
dependencies = [
"bytecheck_derive",
"ptr_meta",
"simdutf8",
]
[[package]]
name = "bytecheck_derive"
version = "0.6.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659"
dependencies = [
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]] [[package]]
name = "bytemuck" name = "bytemuck"
version = "1.24.0" version = "1.24.0"
@@ -463,6 +593,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]] [[package]]
name = "chrono" name = "chrono"
version = "0.4.43" version = "0.4.43"
@@ -477,6 +613,16 @@ dependencies = [
"windows-link 0.2.1", "windows-link 0.2.1",
] ]
[[package]]
name = "chumsky"
version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8eebd66744a15ded14960ab4ccdbfb51ad3b81f51f3f04a80adac98c985396c9"
dependencies = [
"hashbrown 0.14.5",
"stacker",
]
[[package]] [[package]]
name = "combine" name = "combine"
version = "4.6.7" version = "4.6.7"
@@ -879,6 +1025,22 @@ version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
name = "email-encoding"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6"
dependencies = [
"base64 0.22.1",
"memchr",
]
[[package]]
name = "email_address"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449"
[[package]] [[package]]
name = "embed-resource" name = "embed-resource"
version = "3.0.6" version = "3.0.6"
@@ -935,6 +1097,16 @@ dependencies = [
"syn 2.0.114", "syn 2.0.114",
] ]
[[package]]
name = "env_filter"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2"
dependencies = [
"log",
"regex",
]
[[package]] [[package]]
name = "equivalent" name = "equivalent"
version = "1.0.2" version = "1.0.2"
@@ -998,6 +1170,15 @@ dependencies = [
"simd-adler32", "simd-adler32",
] ]
[[package]]
name = "fern"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4316185f709b23713e41e3195f90edef7fb00c3ed4adc79769cf09cc762a3b29"
dependencies = [
"log",
]
[[package]] [[package]]
name = "field-offset" name = "field-offset"
version = "0.3.6" version = "0.3.6"
@@ -1092,6 +1273,12 @@ dependencies = [
"percent-encoding", "percent-encoding",
] ]
[[package]]
name = "funty"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
[[package]] [[package]]
name = "futf" name = "futf"
version = "0.1.5" version = "0.1.5"
@@ -1530,6 +1717,19 @@ name = "hashbrown"
version = "0.12.3" version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
dependencies = [
"ahash 0.7.8",
]
[[package]]
name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
dependencies = [
"ahash 0.8.12",
"allocator-api2",
]
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
@@ -1539,11 +1739,12 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
[[package]] [[package]]
name = "hearbit-ai" name = "hearbit-ai"
version = "0.1.0" version = "1.1.0"
dependencies = [ dependencies = [
"chrono", "chrono",
"cpal", "cpal",
"hound", "hound",
"lettre",
"oauth2", "oauth2",
"reqwest 0.11.27", "reqwest 0.11.27",
"rubato", "rubato",
@@ -1553,6 +1754,7 @@ dependencies = [
"tauri-build", "tauri-build",
"tauri-plugin-dialog", "tauri-plugin-dialog",
"tauri-plugin-fs", "tauri-plugin-fs",
"tauri-plugin-log",
"tauri-plugin-oauth", "tauri-plugin-oauth",
"tauri-plugin-opener", "tauri-plugin-opener",
"tokio", "tokio",
@@ -1584,6 +1786,17 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hostname"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd"
dependencies = [
"cfg-if",
"libc",
"windows-link 0.2.1",
]
[[package]] [[package]]
name = "hound" name = "hound"
version = "3.5.1" version = "3.5.1"
@@ -2086,6 +2299,34 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "lettre"
version = "0.11.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e13e10e8818f8b2a60f52cb127041d388b89f3a96a62be9ceaffa22262fef7f"
dependencies = [
"async-trait",
"base64 0.22.1",
"chumsky",
"email-encoding",
"email_address",
"fastrand",
"futures-io",
"futures-util",
"hostname",
"httpdate",
"idna",
"mime",
"native-tls",
"nom",
"percent-encoding",
"quoted_printable",
"socket2 0.6.1",
"tokio",
"tokio-native-tls",
"url",
]
[[package]] [[package]]
name = "libappindicator" name = "libappindicator"
version = "0.9.0" version = "0.9.0"
@@ -2163,6 +2404,9 @@ name = "log"
version = "0.4.29" version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
dependencies = [
"value-bag",
]
[[package]] [[package]]
name = "mac" name = "mac"
@@ -2367,6 +2611,15 @@ version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb"
[[package]]
name = "nom"
version = "8.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "num-complex" name = "num-complex"
version = "0.4.6" version = "0.4.6"
@@ -2433,6 +2686,15 @@ dependencies = [
"syn 2.0.114", "syn 2.0.114",
] ]
[[package]]
name = "num_threads"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "oauth2" name = "oauth2"
version = "4.4.2" version = "4.4.2"
@@ -2715,6 +2977,15 @@ dependencies = [
"objc2-security", "objc2-security",
] ]
[[package]]
name = "object"
version = "0.32.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6a622008b6e321afc04970976f62ee297fdbaa6f95318ca343e3eebb9648441"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "once_cell" name = "once_cell"
version = "1.21.3" version = "1.21.3"
@@ -3238,6 +3509,36 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "psm"
version = "0.1.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d11f2fedc3b7dafdc2851bc52f277377c5473d378859be234bc7ebb593144d01"
dependencies = [
"ar_archive_writer",
"cc",
]
[[package]]
name = "ptr_meta"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1"
dependencies = [
"ptr_meta_derive",
]
[[package]]
name = "ptr_meta_derive"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac"
dependencies = [
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]] [[package]]
name = "quick-xml" name = "quick-xml"
version = "0.38.4" version = "0.38.4"
@@ -3256,12 +3557,24 @@ dependencies = [
"proc-macro2", "proc-macro2",
] ]
[[package]]
name = "quoted_printable"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "640c9bd8497b02465aeef5375144c26062e0dcd5939dfcbb0f5db76cb8c17c73"
[[package]] [[package]]
name = "r-efi" name = "r-efi"
version = "5.3.0" version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "radium"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09"
[[package]] [[package]]
name = "rand" name = "rand"
version = "0.7.3" version = "0.7.3"
@@ -3442,6 +3755,15 @@ version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58"
[[package]]
name = "rend"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c"
dependencies = [
"bytecheck",
]
[[package]] [[package]]
name = "reqwest" name = "reqwest"
version = "0.11.27" version = "0.11.27"
@@ -3560,6 +3882,35 @@ dependencies = [
"windows-sys 0.52.0", "windows-sys 0.52.0",
] ]
[[package]]
name = "rkyv"
version = "0.7.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1"
dependencies = [
"bitvec",
"bytecheck",
"bytes",
"hashbrown 0.12.3",
"ptr_meta",
"rend",
"rkyv_derive",
"seahash",
"tinyvec",
"uuid",
]
[[package]]
name = "rkyv_derive"
version = "0.7.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5"
dependencies = [
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]] [[package]]
name = "rubato" name = "rubato"
version = "0.14.1" version = "0.14.1"
@@ -3572,6 +3923,22 @@ dependencies = [
"realfft", "realfft",
] ]
[[package]]
name = "rust_decimal"
version = "1.40.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61f703d19852dbf87cbc513643fa81428361eb6940f1ac14fd58155d295a3eb0"
dependencies = [
"arrayvec",
"borsh",
"bytes",
"num-traits",
"rand 0.8.5",
"rkyv",
"serde",
"serde_json",
]
[[package]] [[package]]
name = "rustc_version" name = "rustc_version"
version = "0.4.1" version = "0.4.1"
@@ -3745,6 +4112,12 @@ dependencies = [
"untrusted", "untrusted",
] ]
[[package]]
name = "seahash"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b"
[[package]] [[package]]
name = "security-framework" name = "security-framework"
version = "2.11.1" version = "2.11.1"
@@ -4010,6 +4383,12 @@ version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
[[package]]
name = "simdutf8"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
[[package]] [[package]]
name = "siphasher" name = "siphasher"
version = "0.3.11" version = "0.3.11"
@@ -4125,6 +4504,19 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "stacker"
version = "0.1.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1f8b29fb42aafcea4edeeb6b2f2d7ecd0d969c48b4cf0d2e64aafc471dd6e59"
dependencies = [
"cc",
"cfg-if",
"libc",
"psm",
"windows-sys 0.59.0",
]
[[package]] [[package]]
name = "strength_reduce" name = "strength_reduce"
version = "0.2.4" version = "0.2.4"
@@ -4306,6 +4698,12 @@ dependencies = [
"syn 2.0.114", "syn 2.0.114",
] ]
[[package]]
name = "tap"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
[[package]] [[package]]
name = "tar" name = "tar"
version = "0.4.44" version = "0.4.44"
@@ -4494,6 +4892,28 @@ dependencies = [
"url", "url",
] ]
[[package]]
name = "tauri-plugin-log"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7545bd67f070a4500432c826e2e0682146a1d6712aee22a2786490156b574d93"
dependencies = [
"android_logger",
"byte-unit",
"fern",
"log",
"objc2",
"objc2-foundation",
"serde",
"serde_json",
"serde_repr",
"swift-rs",
"tauri",
"tauri-plugin",
"thiserror 2.0.18",
"time",
]
[[package]] [[package]]
name = "tauri-plugin-oauth" name = "tauri-plugin-oauth"
version = "2.0.0" version = "2.0.0"
@@ -4704,7 +5124,9 @@ checksum = "f9e442fc33d7fdb45aa9bfeb312c095964abdf596f7567261062b2a7107aaabd"
dependencies = [ dependencies = [
"deranged", "deranged",
"itoa", "itoa",
"libc",
"num-conv", "num-conv",
"num_threads",
"powerfmt", "powerfmt",
"serde_core", "serde_core",
"time-core", "time-core",
@@ -4737,6 +5159,21 @@ dependencies = [
"zerovec", "zerovec",
] ]
[[package]]
name = "tinyvec"
version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa"
dependencies = [
"tinyvec_macros",
]
[[package]]
name = "tinyvec_macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]] [[package]]
name = "tokio" name = "tokio"
version = "1.49.0" version = "1.49.0"
@@ -5177,6 +5614,12 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
[[package]]
name = "utf8-width"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1292c0d970b54115d14f2492fe0170adf21d68a1de108eebc51c1df4f346a091"
[[package]] [[package]]
name = "utf8_iter" name = "utf8_iter"
version = "1.0.4" version = "1.0.4"
@@ -5195,6 +5638,12 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "value-bag"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0"
[[package]] [[package]]
name = "vcpkg" name = "vcpkg"
version = "0.2.15" version = "0.2.15"
@@ -6111,6 +6560,15 @@ dependencies = [
"x11-dl", "x11-dl",
] ]
[[package]]
name = "wyz"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed"
dependencies = [
"tap",
]
[[package]] [[package]]
name = "x11" name = "x11"
version = "2.21.0" version = "2.21.0"

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "hearbit-ai" name = "hearbit-ai"
version = "0.1.0" version = "1.1.0"
description = "A Tauri App" description = "A Tauri App"
authors = ["you"] authors = ["you"]
edition = "2021" edition = "2021"
@@ -34,3 +34,5 @@ rubato = "0.14.1"
tauri-plugin-oauth = "2.0.0" tauri-plugin-oauth = "2.0.0"
oauth2 = "4.4" oauth2 = "4.4"
url = "2.5" url = "2.5"
lettre = { version = "0.11", features = ["tokio1", "tokio1-native-tls", "builder"] }
tauri-plugin-log = "2.0.0"

View File

@@ -131,8 +131,15 @@ impl AudioProcessor {
while self.vad_buffer.len() >= self.vad_chunk_size { while self.vad_buffer.len() >= self.vad_chunk_size {
let vad_chunk: Vec<f32> = self.vad_buffer.drain(0..self.vad_chunk_size).collect(); let vad_chunk: Vec<f32> = self.vad_buffer.drain(0..self.vad_chunk_size).collect();
// Run Detection // Run Detection
let probability = self.vad.predict(vad_chunk); // Run Detection
let is_speech = probability > 0.5; let probability = self.vad.predict(vad_chunk.clone());
// Calculate RMS for this chunk to use as fallback/hybrid detection
let sq_sum: f32 = vad_chunk.iter().map(|x| x * x).sum();
let rms = (sq_sum / vad_chunk.len() as f32).sqrt();
// Hybrid VAD: Probability > 0.4 OR RMS > 0.005 (approx -46dB)
let is_speech = probability > 0.4 || rms > 0.005;
if is_speech { if is_speech {
self.is_speech_active = true; self.is_speech_active = true;
@@ -141,8 +148,14 @@ impl AudioProcessor {
// Emit VAD event periodically (every 500ms) // Emit VAD event periodically (every 500ms)
if self.last_event_time.elapsed().as_millis() > 500 { if self.last_event_time.elapsed().as_millis() > 500 {
// Calculate simple RMS of the current chunk for debugging
let sq_sum: f32 = vad_chunk.iter().map(|x| x * x).sum();
let rms = (sq_sum / vad_chunk.len() as f32).sqrt();
// Print debug info to stdout (viewable in terminal)
println!("VAD Debug: Prob={:.4}, RMS={:.6}, Speech={}", probability, rms, is_speech);
if let Some(app) = &self.app_handle { if let Some(app) = &self.app_handle {
// Calculate crude RMS for visualization or just send probability
// Just sending probability is enough for now // Just sending probability is enough for now
#[derive(serde::Serialize, Clone)] #[derive(serde::Serialize, Clone)]
struct VadEvent { struct VadEvent {

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

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

View File

@@ -8,6 +8,7 @@ use tokio::time::sleep;
mod audio_processor; mod audio_processor;
use audio_processor::AudioProcessor; use audio_processor::AudioProcessor;
mod auth; mod auth;
mod email;
// State to hold the active recording stream // State to hold the active recording stream
struct AppState { struct AppState {
@@ -625,9 +626,26 @@ async fn save_text_file(app: AppHandle, path: String, content: String) -> Result
#[tauri::command]
async fn read_log_file(app: AppHandle) -> Result<String, String> {
let log_path = app.path().app_log_dir().map_err(|e| e.to_string())?.join("hearbit-ai.log");
if log_path.exists() {
let content = std::fs::read_to_string(&log_path).map_err(|e| e.to_string())?;
Ok(content)
} else {
Ok("No log file found yet.".to_string())
}
}
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_log::Builder::default()
.targets([
tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::Stdout),
tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::LogDir { file_name: Some("hearbit-ai.log".to_string()) }),
])
.build())
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_fs::init())
@@ -650,7 +668,9 @@ pub fn run() {
create_hearbit_audio_device, create_hearbit_audio_device,
auth::start_auth_flow, auth::start_auth_flow,
auth::get_calendar_events, auth::get_calendar_events,
save_text_file save_text_file,
read_log_file,
email::send_smtp_email
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");

View File

@@ -1,7 +1,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": "1.1.0",
"identifier": "com.hearbit-ai.desktop", "identifier": "com.hearbit-ai.desktop",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",

View File

@@ -1,9 +1,8 @@
import { useState, useEffect } from 'react'; import { useState } from 'react';
import { listen } from "@tauri-apps/api/event";
import { Settings as SettingsIcon } from "lucide-react"; import { Settings as SettingsIcon } from "lucide-react";
import Settings from "./components/Settings"; import Settings, { SmtpConfig, AzureConfig } from "./components/Settings";
import Recorder from "./components/Recorder"; import Recorder from "./components/Recorder";
import LogViewer, { LogEntry } from "./components/LogViewer";
import TranscriptionView from "./components/TranscriptionView"; import TranscriptionView from "./components/TranscriptionView";
import Tabs from "./components/Tabs"; import Tabs from "./components/Tabs";
import MeetingsView from "./components/MeetingsView"; import MeetingsView from "./components/MeetingsView";
@@ -17,9 +16,16 @@ export interface PromptTemplate {
keywords?: string[]; keywords?: string[];
} }
export interface EmailTemplate {
id: string;
name: string;
subject: string;
body: string;
}
function App() { function App() {
const [view, setView] = useState<'recorder' | 'logs' | 'settings' | 'transcription' | 'meetings' | 'history'>('recorder'); const [view, setView] = useState<'recorder' | 'settings' | 'transcription' | 'meetings' | 'history'>('recorder');
const [lastTab, setLastTab] = useState<'recorder' | 'logs' | 'transcription' | 'meetings' | 'history'>('recorder'); const [lastTab, setLastTab] = useState<'recorder' | 'transcription' | 'meetings' | 'history'>('recorder');
// Auto-start recording state to handle "Join & Record" transition // Auto-start recording state to handle "Join & Record" transition
@@ -40,6 +46,23 @@ function App() {
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') || ''); const [savePath, setSavePath] = useState(localStorage.getItem('infomaniak_save_path') || '');
const [smtpConfig, setSmtpConfig] = useState<SmtpConfig>(() => {
const saved = localStorage.getItem('hearbit_smtp_config');
return saved ? JSON.parse(saved) : { host: '', port: '587', user: '', pass: '', sender: '', senderName: '' };
});
const [azureConfig, setAzureConfig] = useState<AzureConfig>(() => {
const saved = localStorage.getItem('hearbit_azure_config');
return saved ? JSON.parse(saved) : { clientId: '', tenantId: '' };
});
const [selectedModel, setSelectedModel] = useState<string>(() => {
return localStorage.getItem('hearbit_selected_model') || 'mixtral';
});
const handleModelChange = (model: string) => {
setSelectedModel(model);
localStorage.setItem('hearbit_selected_model', model);
};
// Default prompts if none exist // Default prompts if none exist
/* eslint-disable no-useless-escape */ // Escape quotes in prompts /* eslint-disable no-useless-escape */ // Escape quotes in prompts
@@ -164,20 +187,70 @@ Wann findet das nächste Meeting statt oder was ist der nächste Meilenstein?`,
} }
]; ];
// Default Email Templates
const defaultEmailTemplates: EmailTemplate[] = [
{
id: '1',
name: 'Meeting Summary (Standard)',
subject: 'Meeting Summary: {{subject}}',
body: `Hi everyone,
Here is the summary of our meeting "{{subject}}" from {{date}}.
{{summary}}
Best regards,
Hearbit Assistant`
},
{
id: '2',
name: 'Action Items Only',
subject: 'Action Items: {{subject}}',
body: `Hi Team,
Please find below the action items from our call on {{date}}:
{{summary}}
Thanks!`
}
];
const [prompts, setPrompts] = useState<PromptTemplate[]>(() => { const [prompts, setPrompts] = useState<PromptTemplate[]>(() => {
const saved = localStorage.getItem('infomaniak_prompts'); const saved = localStorage.getItem('infomaniak_prompts');
return saved ? JSON.parse(saved) : defaultPrompts; return saved ? JSON.parse(saved) : defaultPrompts;
}); });
const handleSaveSettings = (newApiKey: string, newProductId: string, newPrompts: PromptTemplate[], newSavePath: string) => { const [emailTemplates, setEmailTemplates] = useState<EmailTemplate[]>(() => {
const saved = localStorage.getItem('hearbit_email_templates');
return saved ? JSON.parse(saved) : defaultEmailTemplates;
});
const handleSaveSettings = (
newApiKey: string,
newProductId: string,
newPrompts: PromptTemplate[],
newSavePath: string,
newSmtp: SmtpConfig,
newAzure: AzureConfig,
newEmailTemplates: EmailTemplate[]
) => {
setApiKey(newApiKey); setApiKey(newApiKey);
setProductId(newProductId); setProductId(newProductId);
setPrompts(newPrompts); setPrompts(newPrompts);
setSavePath(newSavePath); setSavePath(newSavePath);
setSmtpConfig(newSmtp);
setAzureConfig(newAzure);
setEmailTemplates(newEmailTemplates);
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));
localStorage.setItem('infomaniak_save_path', newSavePath); localStorage.setItem('infomaniak_save_path', newSavePath);
localStorage.setItem('hearbit_smtp_config', JSON.stringify(newSmtp));
localStorage.setItem('hearbit_azure_config', JSON.stringify(newAzure));
localStorage.setItem('hearbit_email_templates', JSON.stringify(newEmailTemplates));
setView(lastTab); setView(lastTab);
}; };
@@ -250,18 +323,7 @@ Wann findet das nächste Meeting statt oder was ist der nächste Meilenstein?`,
setView('transcription'); // Switch to Transcription view to see content setView('transcription'); // Switch to Transcription view to see content
}; };
// Logs State
const [logs, setLogs] = useState<LogEntry[]>([]);
useEffect(() => {
const unlisten = listen<LogEntry>('log-event', (event) => {
setLogs((prevLogs) => [...prevLogs, event.payload]);
});
return () => {
unlisten.then(f => f());
};
}, []);
return ( return (
<div className="min-h-screen bg-background text-foreground flex flex-col select-none overflow-hidden"> <div className="min-h-screen bg-background text-foreground flex flex-col select-none overflow-hidden">
@@ -271,7 +333,7 @@ Wann findet das nächste Meeting statt oder was ist der nächste Meilenstein?`,
<div className="absolute right-4 top-4"> <div className="absolute right-4 top-4">
<button <button
onClick={() => { onClick={() => {
setLastTab(view === 'logs' || view === 'history' ? view : 'recorder'); setLastTab(view === 'history' ? view : 'recorder');
setView('settings'); setView('settings');
}} }}
className="p-2 text-muted-foreground hover:text-foreground hover:bg-secondary rounded-full transition-colors" className="p-2 text-muted-foreground hover:text-foreground hover:bg-secondary rounded-full transition-colors"
@@ -281,7 +343,7 @@ Wann findet das nächste Meeting statt oder was ist der nächste Meilenstein?`,
</div> </div>
<Tabs <Tabs
currentTab={view as 'recorder' | 'logs' | 'transcription' | 'meetings' | 'history'} currentTab={view as 'recorder' | 'transcription' | 'meetings' | 'history'}
onTabChange={(t) => setView(t)} onTabChange={(t) => setView(t)}
/> />
</div> </div>
@@ -313,11 +375,34 @@ Wann findet das nächste Meeting statt oder was ist der nächste Meilenstein?`,
recordingSubject={recordingSubject} recordingSubject={recordingSubject}
onAutoStartHandled={() => setAutoStartRecording(false)} onAutoStartHandled={() => setAutoStartRecording(false)}
addToast={addToast} addToast={addToast}
selectedModel={selectedModel}
onModelChange={handleModelChange}
/> />
)} )}
{view === 'transcription' && ( {view === 'transcription' && (
<TranscriptionView transcription={transcription} summary={summary} /> <TranscriptionView
transcription={transcription}
summary={summary}
smtpConfig={smtpConfig}
apiKey={apiKey}
productId={productId}
prompts={prompts}
emailTemplates={emailTemplates}
onUpdateSummary={(newSummary) => {
setSummary(newSummary); // Update view
// Also update history item if it exists
// We identify by transcription content match (simple heuristic) or we should track currentId
const histIdx = history.findIndex(h => h.transcription === transcription);
if (histIdx >= 0) {
const newHist = [...history];
newHist[histIdx] = { ...newHist[histIdx], summary: newSummary };
setHistory(newHist);
localStorage.setItem('infomaniak_history', JSON.stringify(newHist));
}
}}
addToast={addToast}
/>
)} )}
{view === 'history' && ( {view === 'history' && (
@@ -329,18 +414,22 @@ Wann findet das nächste Meeting statt oder was ist der nächste Meilenstein?`,
)} )}
{view === 'meetings' && ( {view === 'meetings' && (
<MeetingsView <MeetingsView
azureClientId={azureConfig.clientId}
onStartRecording={(subject) => { onStartRecording={(subject) => {
setView('recorder'); setView('recorder');
setRecordingSubject(subject || ''); setRecordingSubject(subject || '');
setAutoStartRecording(true); setAutoStartRecording(true);
}} }}
apiKey={apiKey}
productId={productId}
selectedModel={selectedModel}
onModelChange={handleModelChange}
/> />
)} )}
{view === 'logs' && (
<LogViewer logs={logs} />
)}
{view === 'settings' && ( {view === 'settings' && (
<Settings <Settings
@@ -350,6 +439,9 @@ Wann findet das nächste Meeting statt oder was ist der nächste Meilenstein?`,
productId={productId} productId={productId}
prompts={prompts} prompts={prompts}
savePath={savePath} savePath={savePath}
smtpConfig={smtpConfig}
azureConfig={azureConfig}
emailTemplates={emailTemplates}
/> />
)} )}
</div> </div>

View File

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

View File

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

View File

@@ -17,17 +17,28 @@ interface CalendarEvent {
interface MeetingsViewProps { interface MeetingsViewProps {
onStartRecording: (subject?: string) => void; onStartRecording: (subject?: string) => void;
azureClientId: string;
apiKey: string;
productId: string;
selectedModel: string;
onModelChange: (model: string) => void;
} }
export default function MeetingsView({ onStartRecording }: MeetingsViewProps) { export default function MeetingsView({ onStartRecording, azureClientId, apiKey, productId, selectedModel, onModelChange }: MeetingsViewProps) {
const [isAuthenticated, setIsAuthenticated] = useState(false); const [isAuthenticated, setIsAuthenticated] = useState(false);
const [token, setToken] = useState(localStorage.getItem('m365_token') || ''); const [token, setToken] = useState(localStorage.getItem('m365_token') || '');
const [clientId, setClientId] = useState(localStorage.getItem('m365_client_id') || ''); // const [clientId, setClientId] = useState(azureClientId); // Use prop directly
// Sync prop to state if needed, or just use prop.
// Let's us prop directly in startAuthFlow
const [events, setEvents] = useState<CalendarEvent[]>([]); const [events, setEvents] = useState<CalendarEvent[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set()); const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
const [availableModels, setAvailableModels] = useState<Array<{ id: string, name: string }>>([]);
const toggleExpand = (id: string) => { const toggleExpand = (id: string) => {
const newSet = new Set(expandedIds); const newSet = new Set(expandedIds);
if (newSet.has(id)) { if (newSet.has(id)) {
@@ -38,6 +49,24 @@ export default function MeetingsView({ onStartRecording }: MeetingsViewProps) {
setExpandedIds(newSet); setExpandedIds(newSet);
}; };
useEffect(() => {
if (apiKey && productId) {
loadModels();
}
}, [apiKey, productId]);
const loadModels = async () => {
try {
const models = await invoke<Array<{ id: string, name: string }>>('get_available_models', { apiKey, productId });
if (models && models.length > 0) {
models.sort((a, b) => a.name.localeCompare(b.name));
setAvailableModels(models);
}
} catch (e) {
console.error("Failed to load models:", e);
}
};
useEffect(() => { useEffect(() => {
if (token) { if (token) {
setIsAuthenticated(true); setIsAuthenticated(true);
@@ -46,16 +75,16 @@ export default function MeetingsView({ onStartRecording }: MeetingsViewProps) {
}, [token]); }, [token]);
const handleLogin = async () => { const handleLogin = async () => {
if (!clientId) { if (!azureClientId) {
setError("Please enter a Client ID"); setError("Please configure Client ID in Settings");
return; return;
} }
localStorage.setItem('m365_client_id', clientId); localStorage.setItem('m365_client_id', azureClientId);
setLoading(true); setLoading(true);
setError(''); setError('');
try { try {
const accessToken = await invoke<string>('start_auth_flow', { clientId }); const accessToken = await invoke<string>('start_auth_flow', { clientId: azureClientId });
setToken(accessToken); setToken(accessToken);
localStorage.setItem('m365_token', accessToken); localStorage.setItem('m365_token', accessToken);
setIsAuthenticated(true); setIsAuthenticated(true);
@@ -115,10 +144,24 @@ export default function MeetingsView({ onStartRecording }: MeetingsViewProps) {
const formatDate = (isoString: string) => { const formatDate = (isoString: string) => {
const date = new Date(isoString); const date = new Date(isoString);
const today = new Date(); const today = new Date();
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
if (date.toDateString() === today.toDateString()) return "Today"; if (date.toDateString() === today.toDateString()) return "Today";
return date.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' }); if (date.toDateString() === tomorrow.toDateString()) return "Tomorrow";
return date.toLocaleDateString([], { weekday: 'long', month: 'long', day: 'numeric' });
}; };
// Group events by date
const groupedEvents = events.reduce((groups, event) => {
const dateKey = formatDate(event.start.dateTime);
if (!groups[dateKey]) {
groups[dateKey] = [];
}
groups[dateKey].push(event);
return groups;
}, {} as Record<string, CalendarEvent[]>);
return ( return (
<div className="flex flex-col w-full h-full bg-background p-6"> <div className="flex flex-col w-full h-full bg-background p-6">
<h1 className="text-2xl font-bold mb-6 flex items-center gap-2"> <h1 className="text-2xl font-bold mb-6 flex items-center gap-2">
@@ -137,17 +180,17 @@ export default function MeetingsView({ onStartRecording }: MeetingsViewProps) {
</p> </p>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<input <div className="text-sm p-3 bg-secondary/50 rounded border border-border text-center">
type="text" {azureClientId ? (
placeholder="Client ID (Dynamics/Azure)" <span className="text-green-600 font-medium">Client ID Configured</span>
value={clientId} ) : (
onChange={(e) => setClientId(e.target.value)} <span className="text-destructive font-medium">Client ID Missing in Settings</span>
className="text-sm p-2 rounded border border-input bg-background w-full" )}
/> </div>
<button <button
onClick={handleLogin} onClick={handleLogin}
disabled={loading || !clientId} disabled={loading || !azureClientId}
className="bg-primary text-primary-foreground px-4 py-2 rounded-md text-sm flex items-center justify-center gap-2 hover:opacity-90 disabled:opacity-50 w-full transition-all" className="bg-primary text-primary-foreground px-4 py-2 rounded-md text-sm flex items-center justify-center gap-2 hover:opacity-90 disabled:opacity-50 w-full transition-all"
> >
{loading ? <RefreshCw className="animate-spin" size={16} /> : <LogIn size={16} />} {loading ? <RefreshCw className="animate-spin" size={16} /> : <LogIn size={16} />}
@@ -171,7 +214,21 @@ export default function MeetingsView({ onStartRecording }: MeetingsViewProps) {
<div className="flex flex-col flex-1 overflow-hidden"> <div className="flex flex-col flex-1 overflow-hidden">
<div className="flex justify-between items-center mb-4 px-1"> <div className="flex justify-between items-center mb-4 px-1">
<span className="text-sm text-muted-foreground font-medium">Next 7 Days</span> <span className="text-sm text-muted-foreground font-medium">Next 7 Days</span>
<div className="flex gap-2"> <div className="flex items-center gap-3">
{/* Model Selector */}
<div className="flex items-center gap-2 bg-secondary/50 p-1 rounded-lg border border-border/50">
<span className="text-[10px] uppercase font-bold text-muted-foreground pl-2">Using:</span>
<select
value={selectedModel}
onChange={(e) => onModelChange(e.target.value)}
className="bg-transparent text-xs font-medium outline-none text-foreground cursor-pointer"
>
{availableModels.map(m => (
<option key={m.id} value={m.id}>{m.name}</option>
))}
</select>
</div>
<button onClick={() => fetchEvents(token)} disabled={loading} className="text-muted-foreground hover:text-foreground p-1 rounded hover:bg-secondary transition-colors" title="Refresh"> <button onClick={() => fetchEvents(token)} disabled={loading} className="text-muted-foreground hover:text-foreground p-1 rounded hover:bg-secondary transition-colors" title="Refresh">
<RefreshCw size={16} className={loading ? 'animate-spin' : ''} /> <RefreshCw size={16} className={loading ? 'animate-spin' : ''} />
</button> </button>
@@ -196,24 +253,29 @@ export default function MeetingsView({ onStartRecording }: MeetingsViewProps) {
</div> </div>
)} )}
<div className="flex-1 overflow-y-auto pr-2 space-y-3"> <div className="flex-1 overflow-y-auto pr-2 space-y-6 pb-20">
{events.map(event => ( {Object.entries(groupedEvents).map(([dateLabel, dateEvents]) => (
<div key={dateLabel} className="space-y-3">
<div className="sticky top-0 z-10 bg-background/95 backdrop-blur py-2 border-b border-border/50 text-xs font-bold uppercase tracking-wider text-muted-foreground">
{dateLabel}
</div>
{dateEvents.map(event => (
<div key={event.id} className="bg-card border border-border rounded-xl p-4 hover:shadow-md transition-all group"> <div key={event.id} className="bg-card border border-border rounded-xl p-4 hover:shadow-md transition-all group">
<div className="flex justify-between items-start"> <div className="flex justify-between items-start">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<div className="flex items-baseline gap-2"> <div className="flex items-baseline gap-2">
<span className="text-sm font-bold text-primary bg-primary/10 px-2 py-0.5 rounded"> <span className="text-lg font-mono font-medium text-foreground">
{formatDate(event.start.dateTime)}
</span>
<span className="text-lg font-mono font-medium">
{formatTime(event.start.dateTime)} {formatTime(event.start.dateTime)}
</span> </span>
<span className="text-xs text-muted-foreground">
- {formatTime(event.end.dateTime)}
</span>
</div> </div>
<h3 className="text-lg font-semibold group-hover:text-primary transition-colors"> <h3 className="text-base font-semibold group-hover:text-primary transition-colors leading-tight">
{event.subject} {event.subject}
</h3> </h3>
{event.location?.displayName && ( {event.location?.displayName && (
<div className="text-sm text-muted-foreground"> <div className="text-xs text-muted-foreground flex items-center gap-1">
📍 {event.location.displayName} 📍 {event.location.displayName}
</div> </div>
)} )}
@@ -222,14 +284,15 @@ export default function MeetingsView({ onStartRecording }: MeetingsViewProps) {
{event.onlineMeeting?.joinUrl ? ( {event.onlineMeeting?.joinUrl ? (
<button <button
onClick={() => handleJoin(event.onlineMeeting?.joinUrl, event.subject)} onClick={() => handleJoin(event.onlineMeeting?.joinUrl, event.subject)}
className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 shadow-sm hover:shadow transition-all" className="shrink-0 bg-green-600 hover:bg-green-700 text-white p-2 rounded-lg shadow-sm hover:shadow transition-all flex flex-col items-center justify-center gap-0.5 min-w-[70px]"
title={`Join & Summarize with ${selectedModel}`}
> >
<Video size={18} /> <Video size={16} />
<span className="font-semibold">Join & Record</span> <span className="text-[10px] font-bold">JOIN</span>
</button> </button>
) : ( ) : (
<div className="px-3 py-1.5 bg-secondary text-muted-foreground text-xs rounded-lg italic"> <div className="px-2 py-1 bg-secondary text-muted-foreground text-[10px] rounded italic">
No online link No Link
</div> </div>
)} )}
</div> </div>
@@ -237,17 +300,17 @@ export default function MeetingsView({ onStartRecording }: MeetingsViewProps) {
{/* Expand/Collapse Button */} {/* Expand/Collapse Button */}
<button <button
onClick={() => toggleExpand(event.id)} onClick={() => toggleExpand(event.id)}
className="text-xs text-muted-foreground hover:text-primary mt-2 flex items-center gap-1 transition-colors w-full justify-center py-1 bg-secondary/30 hover:bg-secondary/50 rounded" className="text-[10px] text-muted-foreground hover:text-primary mt-2 flex items-center gap-1 transition-colors w-full justify-center py-0.5 bg-secondary/30 hover:bg-secondary/50 rounded"
> >
{expandedIds.has(event.id) ? "Hide Details" : "Show Details"} {expandedIds.has(event.id) ? "Hide Details" : "Show Details"}
</button> </button>
{/* Expanded Content */} {/* Expanded Content */}
{expandedIds.has(event.id) && ( {expandedIds.has(event.id) && (
<div className="mt-3 text-sm text-foreground/80 bg-background/50 p-3 rounded border border-border/50 animate-in fade-in slide-in-from-top-1"> <div className="mt-2 text-xs text-foreground/80 bg-background/50 p-3 rounded border border-border/50 animate-in fade-in slide-in-from-top-1">
{event.body?.content ? ( {event.body?.content ? (
<div <div
className="prose prose-sm dark:prose-invert max-w-none break-words" className="prose prose-xs dark:prose-invert max-w-none break-words"
dangerouslySetInnerHTML={{ __html: event.body.content }} dangerouslySetInnerHTML={{ __html: event.body.content }}
/> />
) : ( ) : (
@@ -255,20 +318,17 @@ export default function MeetingsView({ onStartRecording }: MeetingsViewProps) {
)} )}
{event.attendees && event.attendees.length > 0 && ( {event.attendees && event.attendees.length > 0 && (
<div className="mt-4 pt-4 border-t border-border/50"> <div className="mt-3 pt-3 border-t border-border/50">
<h4 className="text-sm font-semibold text-foreground mb-3 flex items-center gap-2"> <h4 className="text-xs font-semibold text-foreground mb-2 flex items-center gap-2">
👥 Attendees 👥 Attendees ({event.attendees.length})
<span className="text-xs font-normal text-muted-foreground bg-secondary px-1.5 py-0.5 rounded-full">
{event.attendees.length}
</span>
</h4> </h4>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-1.5">
{event.attendees.map((att, i) => ( {event.attendees.map((att, i) => (
<div key={i} className="flex items-center gap-2 bg-secondary/50 border border-border/50 px-3 py-1.5 rounded-lg text-sm transition-colors hover:bg-secondary hover:border-border"> <div key={i} className="flex items-center gap-1.5 bg-secondary/50 border border-border/50 px-2 py-1 rounded text-xs transition-colors">
<div className={`w-2 h-2 rounded-full shrink-0 ${att.status.response === 'accepted' ? 'bg-green-500 shadow-[0_0_4px_rgba(34,197,94,0.4)]' : <div className={`w-1.5 h-1.5 rounded-full shrink-0 ${att.status.response === 'accepted' ? 'bg-green-500 shadow-[0_0_4px_rgba(34,197,94,0.4)]' :
att.status.response === 'declined' ? 'bg-red-500' : 'bg-yellow-500' att.status.response === 'declined' ? 'bg-red-500' : 'bg-yellow-500'
}`} title={`Status: ${att.status.response}`} /> }`} title={`Status: ${att.status.response}`} />
<span className="font-medium truncate max-w-[200px]" title={att.emailAddress.address}> <span className="truncate max-w-[150px]" title={att.emailAddress.address}>
{att.emailAddress.name || att.emailAddress.address} {att.emailAddress.name || att.emailAddress.address}
</span> </span>
</div> </div>
@@ -281,6 +341,8 @@ export default function MeetingsView({ onStartRecording }: MeetingsViewProps) {
</div> </div>
))} ))}
</div> </div>
))}
</div>
</div> </div>
)} )}
</div> </div>

View File

@@ -40,6 +40,8 @@ interface RecorderProps {
recordingSubject?: string; recordingSubject?: string;
onAutoStartHandled?: () => void; onAutoStartHandled?: () => void;
addToast: (msg: string, type: 'success' | 'error' | 'info', duration?: number) => void; addToast: (msg: string, type: 'success' | 'error' | 'info', duration?: number) => void;
selectedModel: string;
onModelChange: (model: string) => void;
} }
interface AudioDevice { interface AudioDevice {
@@ -51,14 +53,15 @@ const Recorder: React.FC<RecorderProps> = ({
apiKey, productId, prompts, apiKey, productId, prompts,
setTranscription, setSummary, setTranscription, setSummary,
onSaveToHistory, savePath, onRecordingComplete, onSaveToHistory, savePath, onRecordingComplete,
onOpenSettings, addToast, ...props onOpenSettings, addToast, selectedModel, onModelChange, ...props
}) => { }) => {
const [isRecording, setIsRecording] = useState(false); const [isRecording, setIsRecording] = useState(false);
const [isStopping, setIsStopping] = useState(false); // New lock state
const [isPaused, setIsPaused] = 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'); // selectedModel is now a prop
const [recordingMode, setRecordingMode] = useState<'voice' | 'meeting'>('voice'); const [recordingMode, setRecordingMode] = useState<'voice' | 'meeting'>('voice');
const [devices, setDevices] = useState<AudioDevice[]>([]); const [devices, setDevices] = useState<AudioDevice[]>([]);
const [availableModels, setAvailableModels] = useState<Array<{ id: string, name: string }>>([]); const [availableModels, setAvailableModels] = useState<Array<{ id: string, name: string }>>([]);
@@ -191,9 +194,9 @@ const Recorder: React.FC<RecorderProps> = ({
setSilenceDuration(diff); setSilenceDuration(diff);
// Auto-stop after 30 seconds of silence // Auto-stop after 30 seconds of silence
if (diff > 30) { // 30 seconds if (diff > 30 && !isStopping) { // Check lock
console.log("Auto-stopping due to silence"); console.log("Auto-stopping due to silence");
setStatus("Auto-stopping (Silence detected)..."); addToast("Auto-stopping (Silence detected)", "info", 3000);
stopRecording(); stopRecording();
} }
} }
@@ -264,12 +267,18 @@ const Recorder: React.FC<RecorderProps> = ({
}; };
const stopRecording = async () => { const stopRecording = async () => {
if (isStopping) return;
setIsStopping(true);
try { try {
setIsRecording(false); setIsRecording(false);
setIsPaused(false); setIsPaused(false);
setStatus('Processing...'); setStatus('Processing...');
const filePath = await invoke<string>('stop_recording'); const filePath = await invoke<string>('stop_recording');
// Wait a moment for file flush (safety)
await new Promise(r => setTimeout(r, 500));
setStatus('Transcribing (Infomaniak Whisper)...'); setStatus('Transcribing (Infomaniak Whisper)...');
const transText = await invoke<string>('transcribe_audio', { const transText = await invoke<string>('transcribe_audio', {
filePath, filePath,
@@ -343,51 +352,53 @@ const Recorder: React.FC<RecorderProps> = ({
console.error(e); console.error(e);
setStatus(`Error: ${e}`); setStatus(`Error: ${e}`);
addToast(`Error processing: ${e}`, 'error'); addToast(`Error processing: ${e}`, 'error');
} finally {
setIsStopping(false);
} }
}; };
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 - Reduced padding */}
<div className="w-full flex justify-center items-center p-6 shrink-0"> <div className="w-full flex justify-center items-center p-4 shrink-0">
<img src={logo} alt="Logo" className="h-12 object-contain" /> <img src={logo} alt="Logo" className="h-10 object-contain" />
</div> </div>
{/* Scrollable Content */} {/* Scrollable Content - Reduced spacing */}
<div className="flex-1 overflow-y-auto p-6 flex flex-col items-center pb-20"> <div className="flex-1 overflow-y-auto px-6 pb-6 flex flex-col items-center">
<div className="mb-6 relative shrink-0"> <div className="mb-4 relative shrink-0">
<div className={`w-32 h-32 rounded-full flex items-center justify-center transition-all duration-300 ${isRecording ? (isPaused ? 'bg-yellow-500/10' : 'bg-red-500/10 animate-pulse') : 'bg-secondary'}`}> <div className={`w-24 h-24 rounded-full flex items-center justify-center transition-all duration-300 ${isRecording ? (isPaused ? 'bg-yellow-500/10' : 'bg-red-500/10 animate-pulse') : 'bg-secondary'}`}>
{isRecording ? ( {isRecording ? (
<div className={`w-24 h-24 rounded-full flex items-center justify-center shadow-[0_0_20px_rgba(239,68,68,0.5)] ${isPaused ? 'bg-yellow-500' : 'bg-red-500'}`}> <div className={`w-16 h-16 rounded-full flex items-center justify-center shadow-[0_0_20px_rgba(239,68,68,0.5)] ${isPaused ? 'bg-yellow-500' : 'bg-red-500'}`}>
<Mic size={40} className="text-white animate-bounce" /> <Mic size={32} className="text-white animate-bounce" />
</div> </div>
) : ( ) : (
<div className="w-24 h-24 rounded-full bg-primary flex items-center justify-center"> <div className="w-16 h-16 rounded-full bg-primary flex items-center justify-center">
<Mic size={40} className="text-primary-foreground" /> <Mic size={32} className="text-primary-foreground" />
</div> </div>
)} )}
</div> </div>
</div> </div>
<h1 className="text-2xl font-bold mb-2 text-foreground"> <h1 className="text-xl font-bold mb-1 text-foreground">
{isRecording ? (isPaused ? 'Paused' : 'Listening...') : 'Ready to Record'} {isRecording ? (isPaused ? 'Paused' : 'Listening...') : 'Ready to Record'}
</h1> </h1>
<p className="text-muted-foreground mb-6 text-center text-sm h-6"> <p className="text-muted-foreground mb-4 text-center text-xs h-5">
{status} {status}
{isRecording && !isPaused && silenceDuration > 10 && ( {isRecording && !isPaused && silenceDuration > 10 && (
<span className="block text-xs text-yellow-500 mt-1 opacity-80"> <span className="block text-xs text-yellow-500 mt-0.5 opacity-80">
Silence detected: {Math.floor(silenceDuration)}s (Auto-stop in {90 - Math.floor(silenceDuration)}s) Silence detected: {Math.floor(silenceDuration)}s
</span> </span>
)} )}
</p> </p>
<div className="w-full max-w-sm space-y-4 mb-6 shrink-0"> <div className="w-full max-w-sm space-y-3 mb-4 shrink-0">
{!isRecording ? ( {!isRecording ? (
<button <button
onClick={() => startRecording()} onClick={() => startRecording()}
disabled={!apiKey || !productId} 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" className="w-full py-3 text-base font-semibold bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-md hover:shadow-lg"
> >
{!apiKey ? 'Configure API Key First' : 'Start Recording'} {!apiKey ? 'Configure API Key First' : 'Start Recording'}
</button> </button>
@@ -456,9 +467,13 @@ const Recorder: React.FC<RecorderProps> = ({
</label> </label>
<select <select
value={selectedModel} value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)} onChange={(e) => {
onModelChange(e.target.value);
// localStorage handled in App.tsx
}}
className="w-full p-2 text-sm bg-secondary rounded border border-border outline-none focus:ring-2 focus:ring-primary" className="w-full p-2 text-sm bg-secondary rounded border border-border outline-none focus:ring-2 focus:ring-primary"
disabled={isRecording} // Allow changing model while recording (since it's used for summary after)
disabled={false}
> >
{availableModels.map(m => ( {availableModels.map(m => (
<option key={m.id} value={m.id}>{m.name}</option> <option key={m.id} value={m.id}>{m.name}</option>
@@ -475,7 +490,8 @@ const Recorder: React.FC<RecorderProps> = ({
value={selectedPromptId} value={selectedPromptId}
onChange={(e) => setSelectedPromptId(e.target.value)} 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" className="w-full p-2 text-sm bg-secondary rounded border border-border outline-none focus:ring-2 focus:ring-primary"
disabled={isRecording || prompts.length === 0} // Allow changing template while recording
disabled={prompts.length === 0}
> >
{prompts.map(p => ( {prompts.map(p => (
<option key={p.id} value={p.id}>{p.name}</option> <option key={p.id} value={p.id}>{p.name}</option>

View File

@@ -1,25 +1,56 @@
import React, { useState } from 'react'; import React, { useState, useEffect } from 'react';
import { Save, FolderOpen, Lock, Upload, Download, Eye, EyeOff } from 'lucide-react'; import { Save, FolderOpen, Lock, Upload, Download, Eye, EyeOff, Mail, FileText, ScrollText } from 'lucide-react';
import { save, open } from '@tauri-apps/plugin-dialog'; import { save, open } from '@tauri-apps/plugin-dialog';
import { writeTextFile } from '@tauri-apps/plugin-fs'; // Removed writeTextFile as we use invoke 'save_text_file'
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import { encryptData, decryptData } from '../utils/backup'; import { encryptData, decryptData } from '../utils/backup';
import { PromptTemplate } from '../App'; import EmailTemplateEditor from './EmailTemplateEditor';
import { PromptTemplate, EmailTemplate } from '../App';
interface SettingsProps { interface SettingsProps {
apiKey: string; apiKey: string;
productId: string; productId: string;
savePath: string; savePath: string;
prompts: PromptTemplate[]; prompts: PromptTemplate[];
onSave: (apiKey: string, productId: string, prompts: PromptTemplate[], savePath: string) => void; emailTemplates: EmailTemplate[];
smtpConfig: SmtpConfig;
azureConfig: AzureConfig;
onSave: (
apiKey: string,
productId: string,
prompts: PromptTemplate[],
savePath: string,
smtp: SmtpConfig,
azure: AzureConfig,
emailTemplates: EmailTemplate[]
) => void;
onClose: () => void; onClose: () => void;
} }
const Settings: React.FC<SettingsProps> = ({ apiKey, productId, prompts, savePath, onSave, onClose }) => { export interface SmtpConfig {
host: string;
port: string;
user: string;
pass: string;
sender: string;
senderName: string;
}
export interface AzureConfig {
clientId: string;
tenantId: string;
}
const Settings: React.FC<SettingsProps> = ({ apiKey, productId, prompts, savePath, onSave, onClose, ...props }) => {
const [localApiKey, setLocalApiKey] = useState(apiKey); const [localApiKey, setLocalApiKey] = useState(apiKey);
const [localProductId, setLocalProductId] = useState(productId); const [localProductId, setLocalProductId] = useState(productId);
const [localSavePath, setLocalSavePath] = useState(savePath); const [localSavePath, setLocalSavePath] = useState(savePath);
const [localPrompts, setLocalPrompts] = useState<PromptTemplate[]>(prompts); const [localPrompts, setLocalPrompts] = useState<PromptTemplate[]>(prompts);
const [localEmailTemplates, setLocalEmailTemplates] = useState<EmailTemplate[]>(props.emailTemplates); // New state
const [localSmtp, setLocalSmtp] = useState<SmtpConfig>(props.smtpConfig);
const [localAzure, setLocalAzure] = useState<AzureConfig>(props.azureConfig);
const [statusIdx, setStatusIdx] = useState<string | null>(null); const [statusIdx, setStatusIdx] = useState<string | null>(null);
// Backup & Restore State // Backup & Restore State
@@ -28,6 +59,48 @@ const Settings: React.FC<SettingsProps> = ({ apiKey, productId, prompts, savePat
const [isImportModalOpen, setIsImportModalOpen] = useState(false); const [isImportModalOpen, setIsImportModalOpen] = useState(false);
const [importFileContent, setImportFileContent] = useState<string | null>(null); const [importFileContent, setImportFileContent] = useState<string | null>(null);
// Email Template Editor State
const [editingTemplate, setEditingTemplate] = useState<EmailTemplate | null>(null);
const [isEmailEditorOpen, setIsEmailEditorOpen] = useState(false);
const [activeTab, setActiveTab] = useState<'general' | 'prompts' | 'email' | 'backup' | 'logs'>('general');
const [logs, setLogs] = useState<string>('Loading logs...');
useEffect(() => {
if (activeTab === 'logs') {
loadLogs();
}
}, [activeTab]);
const loadLogs = async () => {
try {
const content = await invoke<string>('read_log_file');
setLogs(content);
} catch (e) {
setLogs(`Failed to load logs: ${e}`);
}
};
const handleSaveLogs = async () => {
try {
const filePath = await save({
defaultPath: `hearbit_logs_${new Date().toISOString().slice(0, 10)}.log`,
filters: [{
name: 'Log File',
extensions: ['log', 'txt']
}]
});
if (filePath) {
await invoke('save_text_file', { path: filePath, content: logs });
setStatusIdx(`Logs exported to: ${filePath}`);
}
} catch (e) {
console.error(e);
setStatusIdx(`Failed to export logs: ${e}`);
}
};
const handlePromptChange = (id: string, field: 'name' | 'content', value: string) => { const handlePromptChange = (id: string, field: 'name' | 'content', value: string) => {
setLocalPrompts(localPrompts.map(p => p.id === id ? { ...p, [field]: value } : p)); setLocalPrompts(localPrompts.map(p => p.id === id ? { ...p, [field]: value } : p));
}; };
@@ -40,8 +113,26 @@ const Settings: React.FC<SettingsProps> = ({ apiKey, productId, prompts, savePat
setLocalPrompts(localPrompts.filter(p => p.id !== id)); setLocalPrompts(localPrompts.filter(p => p.id !== id));
}; };
const handleSaveEmailTemplate = (template: EmailTemplate) => {
const exists = localEmailTemplates.find(t => t.id === template.id);
if (exists) {
setLocalEmailTemplates(localEmailTemplates.map(t => t.id === template.id ? template : t));
} else {
setLocalEmailTemplates([...localEmailTemplates, template]);
}
};
const openEmailEditor = (template: EmailTemplate | null) => {
setEditingTemplate(template);
setIsEmailEditorOpen(true);
};
const removeEmailTemplate = (id: string) => {
setLocalEmailTemplates(localEmailTemplates.filter(t => t.id !== id));
};
const handleSave = () => { const handleSave = () => {
onSave(localApiKey, localProductId, localPrompts, localSavePath); onSave(localApiKey, localProductId, localPrompts, localSavePath, localSmtp, localAzure, localEmailTemplates);
onClose(); onClose();
}; };
@@ -71,7 +162,9 @@ const Settings: React.FC<SettingsProps> = ({ apiKey, productId, prompts, savePat
apiKey: localApiKey, apiKey: localApiKey,
productId: localProductId, productId: localProductId,
prompts: localPrompts, prompts: localPrompts,
savePath: localSavePath savePath: localSavePath,
smtp: localSmtp,
azure: localAzure
}; };
const encrypted = await encryptData(data, backupPassword); const encrypted = await encryptData(data, backupPassword);
@@ -84,12 +177,13 @@ const Settings: React.FC<SettingsProps> = ({ apiKey, productId, prompts, savePat
}); });
if (filePath) { if (filePath) {
await writeTextFile(filePath, encrypted); // Use backend invoke to write file (bypasses fs scope issues)
await invoke('save_text_file', { path: filePath, content: encrypted });
setStatusIdx(`Configuration exported to: ${filePath}`); setStatusIdx(`Configuration exported to: ${filePath}`);
} }
} catch (e) { } catch (e) {
console.error(e); console.error(e);
setStatusIdx('Export failed.'); setStatusIdx(`Export failed: ${e}`);
} }
}; };
@@ -124,8 +218,12 @@ const Settings: React.FC<SettingsProps> = ({ apiKey, productId, prompts, savePat
const data = await decryptData(importFileContent, backupPassword); const data = await decryptData(importFileContent, backupPassword);
if (data.apiKey) setLocalApiKey(data.apiKey); if (data.apiKey) setLocalApiKey(data.apiKey);
if (data.productId) setLocalProductId(data.productId); if (data.productId) setLocalProductId(data.productId);
if (data.prompts) setLocalPrompts(data.prompts); if (data.prompts) setLocalPrompts(data.prompts);
if (data.emailTemplates) setLocalEmailTemplates(data.emailTemplates);
if (data.savePath) setLocalSavePath(data.savePath); if (data.savePath) setLocalSavePath(data.savePath);
if (data.smtp) setLocalSmtp(data.smtp);
if (data.azure) setLocalAzure(data.azure);
setStatusIdx('Configuration imported! Click Save to apply.'); setStatusIdx('Configuration imported! Click Save to apply.');
setIsImportModalOpen(false); setIsImportModalOpen(false);
@@ -147,6 +245,14 @@ const Settings: React.FC<SettingsProps> = ({ apiKey, productId, prompts, savePat
} }
}; };
const tabs = [
{ id: 'general', label: 'General', icon: <Save size={14} /> },
{ id: 'prompts', label: 'Prompts', icon: <FileText size={14} /> },
{ id: 'email', label: 'Email', icon: <Mail size={14} /> },
{ id: 'backup', label: 'Backup', icon: <Lock size={14} /> },
{ id: 'logs', label: 'Logs', icon: <ScrollText size={14} /> },
] as const;
return ( return (
<div className="flex flex-col h-full bg-background font-mono text-sm relative"> <div className="flex flex-col h-full bg-background font-mono text-sm relative">
{/* Import Password Modal */} {/* Import Password Modal */}
@@ -193,16 +299,43 @@ const Settings: React.FC<SettingsProps> = ({ apiKey, productId, prompts, savePat
</div> </div>
)} )}
<div className="p-4 border-b border-border/40 bg-secondary/20 flex justify-between items-center"> {/* Email Template Editor Modal */}
<span className="text-xs uppercase tracking-wider text-muted-foreground font-semibold">Settings</span> <EmailTemplateEditor
<button onClick={handleSave} className="flex items-center gap-1 text-primary hover:text-primary/80 transition-all font-semibold active:scale-95"> isOpen={isEmailEditorOpen}
<Save size={16} /> Save onClose={() => setIsEmailEditorOpen(false)}
template={editingTemplate}
onSave={handleSaveEmailTemplate}
smtpConfig={localSmtp}
addToast={(msg, type) => setStatusIdx(`${type === 'error' ? 'Error' : 'Success'}: ${msg}`)}
/>
<div className="flex flex-col border-b border-border/40 bg-secondary/10">
<div className="p-4 flex justify-between items-center">
<h2 className="text-lg font-semibold tracking-tight">Settings</h2>
<button onClick={handleSave} className="flex items-center gap-2 bg-primary text-primary-foreground px-4 py-2 rounded font-semibold hover:bg-primary/90 transition-all active:scale-95 text-xs">
<Save size={16} /> Save Changes
</button> </button>
</div> </div>
<div className="flex-1 overflow-y-auto p-4 space-y-6"> {/* Tabs */}
<div className="space-y-4 border rounded p-4 border-border/50"> <div className="flex px-4 gap-2">
<h3 className="text-foreground font-semibold flex items-center gap-2">General</h3> {tabs.map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`px-4 py-2 text-xs font-medium border-b-2 transition-colors flex items-center gap-2 ${activeTab === tab.id ? 'border-primary text-primary' : 'border-transparent text-muted-foreground hover:text-foreground'}`}
>
{tab.icon} {tab.label}
</button>
))}
</div>
</div>
<div className="flex-1 overflow-y-auto p-6">
{activeTab === 'general' && (
<div className="space-y-6 max-w-2xl">
<div className="space-y-4">
<h3 className="text-foreground font-semibold border-b border-border pb-2">Application Keys</h3>
<div> <div>
<label htmlFor="apiKey" className="block text-sm font-medium mb-1 text-foreground">API Key</label> <label htmlFor="apiKey" className="block text-sm font-medium mb-1 text-foreground">API Key</label>
<input <input
@@ -223,6 +356,10 @@ const Settings: React.FC<SettingsProps> = ({ apiKey, productId, prompts, savePat
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 font-mono text-sm"
/> />
</div> </div>
</div>
<div className="space-y-4">
<h3 className="text-foreground font-semibold border-b border-border pb-2">Storage</h3>
<div> <div>
<label htmlFor="savePath" className="block text-sm font-medium mb-1 text-foreground">Custom Recordings Folder</label> <label htmlFor="savePath" className="block text-sm font-medium mb-1 text-foreground">Custom Recordings Folder</label>
<div className="flex gap-2"> <div className="flex gap-2">
@@ -243,81 +380,42 @@ const Settings: React.FC<SettingsProps> = ({ apiKey, productId, prompts, savePat
</button> </button>
</div> </div>
</div> </div>
<div className="pt-2 border-t border-border/50 mt-2"> </div>
<label className="block text-sm font-medium mb-1 text-foreground">Advanced Audio Setup</label>
<p className="text-xs text-muted-foreground mb-2"> <div className="space-y-4">
For automatic recording in Teams, create a virtual device combining your Mic and computer audio. <h3 className="text-foreground font-semibold border-b border-border pb-2">System Intergration</h3>
</p> <div className="flex items-center justify-between p-4 bg-secondary/20 rounded border border-border/50">
<div>
<div className="font-medium text-sm">Hearbit Audio Device</div>
<div className="text-xs text-muted-foreground">Required for recording system audio (Teams, Zoom, etc.)</div>
</div>
<button <button
onClick={handleCreateDevice} onClick={handleCreateDevice}
className="bg-secondary hover:bg-secondary/80 text-xs px-3 py-2 rounded border border-border transition-all active:scale-95 flex items-center gap-2" className="bg-secondary hover:bg-secondary/80 text-xs px-3 py-2 rounded border border-border transition-all active:scale-95 flex items-center gap-2"
> >
<span>🪄</span> Create "Hearbit Audio" Device <span>🪄</span> Create / Repair
</button> </button>
</div> </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>
)}
<div className="flex gap-2 pt-2"> {activeTab === 'prompts' && (
<button <div className="space-y-4 max-w-3xl">
onClick={handleExport} <div className="flex justify-between items-center border-b border-border pb-2">
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 active:scale-95" <h3 className="text-foreground font-semibold">AI Prompts</h3>
> <button onClick={addPrompt} className="text-xs bg-primary text-primary-foreground px-3 py-1.5 rounded hover:bg-primary/90 transition-all active:scale-95">
<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 active:scale-95"
>
<Upload size={14} /> Import Config
</button>
<input
type="file"
id="import-file-input"
accept=".conf"
className="hidden"
onChange={handleFileSelect}
/>
</div>
</div>
<div className="space-y-4 border rounded p-4 border-border/50">
<div className="flex justify-between items-center">
<h3 className="text-foreground font-semibold">Prompts</h3>
<button onClick={addPrompt} className="text-xs bg-primary text-primary-foreground px-2 py-1 rounded hover:bg-primary/90 transition-all active:scale-95">
+ Add Prompt + Add Prompt
</button> </button>
</div> </div>
<div className="grid gap-4">
{localPrompts.map((prompt) => ( {localPrompts.map((prompt) => (
<div key={prompt.id} className="space-y-2 p-3 bg-secondary/30 rounded border border-border/50 relative group"> <div key={prompt.id} className="space-y-2 p-4 bg-secondary/10 rounded border border-border/50 relative group">
<button <button
onClick={() => removePrompt(prompt.id)} 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" className="absolute top-2 right-2 text-muted-foreground hover:text-destructive opacity-0 group-hover:opacity-100 transition-opacity text-xs flex items-center gap-1"
> >
<EyeOff size={12} className="inline mr-1" /> Remove <EyeOff size={14} /> Remove
</button> </button>
<input <input
type="text" type="text"
@@ -329,20 +427,226 @@ const Settings: React.FC<SettingsProps> = ({ apiKey, productId, prompts, savePat
<textarea <textarea
value={prompt.content} value={prompt.content}
onChange={(e) => handlePromptChange(prompt.id, 'content', e.target.value)} 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]" className="w-full p-2 bg-secondary/50 rounded border border-border/30 focus:border-primary outline-none text-xs resize-y min-h-[100px] font-mono"
placeholder="Prompt Content" placeholder="Prompt Content"
/> />
</div> </div>
))} ))}
</div> </div>
</div>
)}
{statusIdx && ( {activeTab === 'email' && (
<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 className="space-y-8 max-w-2xl">
{statusIdx} <div className="space-y-4">
<div className="flex justify-between items-center border-b border-border pb-2">
<h3 className="text-foreground font-semibold">Email Templates</h3>
<button
onClick={() => openEmailEditor(null)}
className="text-xs bg-primary text-primary-foreground px-3 py-1.5 rounded hover:bg-primary/90 transition-all active:scale-95"
>
+ Add Template
</button>
</div>
<div className="space-y-2">
{localEmailTemplates.map((template) => (
<div key={template.id} className="flex justify-between items-center p-4 bg-secondary/10 rounded border border-border/50 group hover:border-border/80 transition-colors">
<div className="flex-1 min-w-0 pr-4">
<div className="font-semibold text-sm truncate">{template.name}</div>
<div className="text-xs text-muted-foreground truncate">{template.subject}</div>
</div>
<div className="flex gap-2 shrink-0">
<button
onClick={() => openEmailEditor(template)}
className="px-2 py-1 text-xs font-medium text-primary hover:bg-primary/10 rounded transition-colors"
>
Edit
</button>
<button
onClick={() => removeEmailTemplate(template.id)}
className="px-2 py-1 text-xs font-medium text-muted-foreground hover:text-destructive hover:bg-destructive/10 rounded transition-colors"
>
Remove
</button>
</div>
</div>
))}
{localEmailTemplates.length === 0 && (
<div className="text-center p-8 text-muted-foreground text-xs italic border border-dashed border-border rounded">
No templates created yet.
</div> </div>
)} )}
</div> </div>
</div> </div>
<div className="space-y-4">
<h3 className="text-foreground font-semibold border-b border-border pb-2">SMTP Configuration</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1 text-foreground">SMTP Host</label>
<input
type="text"
value={localSmtp.host}
onChange={(e) => setLocalSmtp({ ...localSmtp, host: e.target.value })}
className="w-full p-2 rounded border border-border bg-secondary text-foreground focus:ring-2 focus:ring-primary outline-none font-mono text-sm"
placeholder="smtp.office365.com"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-foreground">Port</label>
<input
type="text"
value={localSmtp.port}
onChange={(e) => setLocalSmtp({ ...localSmtp, port: e.target.value })}
className="w-full p-2 rounded border border-border bg-secondary text-foreground focus:ring-2 focus:ring-primary outline-none font-mono text-sm"
placeholder="587"
/>
</div>
<div className="col-span-2">
<label className="block text-sm font-medium mb-1 text-foreground">Sender Email</label>
<input
type="text"
value={localSmtp.sender}
onChange={(e) => setLocalSmtp({ ...localSmtp, sender: e.target.value })}
className="w-full p-2 rounded border border-border bg-secondary text-foreground focus:ring-2 focus:ring-primary outline-none font-mono text-sm"
placeholder="you@company.com"
/>
</div>
<div className="col-span-2">
<label className="block text-sm font-medium mb-1 text-foreground">Sender Name (Optional)</label>
<input
type="text"
value={localSmtp.senderName}
onChange={(e) => setLocalSmtp({ ...localSmtp, senderName: e.target.value })}
className="w-full p-2 rounded border border-border bg-secondary text-foreground focus:ring-2 focus:ring-primary outline-none font-mono text-sm"
placeholder="Hearbit Assistant"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-foreground">Username</label>
<input
type="text"
value={localSmtp.user}
onChange={(e) => setLocalSmtp({ ...localSmtp, user: e.target.value })}
className="w-full p-2 rounded border border-border bg-secondary text-foreground focus:ring-2 focus:ring-primary outline-none font-mono text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-foreground">Password</label>
<input
type="password"
value={localSmtp.pass}
onChange={(e) => setLocalSmtp({ ...localSmtp, pass: e.target.value })}
className="w-full p-2 rounded border border-border bg-secondary text-foreground focus:ring-2 focus:ring-primary outline-none font-mono text-sm"
/>
</div>
</div>
</div>
<div className="space-y-4">
<h3 className="text-foreground font-semibold border-b border-border pb-2">Microsoft 365 (Azure AD)</h3>
<p className="text-xs text-muted-foreground">Optional configuration for advanced MS Graph integrations.</p>
<div>
<label className="block text-sm font-medium mb-1 text-foreground">Client ID</label>
<input
type="text"
value={localAzure.clientId}
onChange={(e) => setLocalAzure({ ...localAzure, clientId: e.target.value })}
className="w-full p-2 rounded border border-border bg-secondary text-foreground focus:ring-2 focus:ring-primary outline-none font-mono text-sm"
placeholder="Application (client) ID"
/>
</div>
</div>
</div>
)}
{activeTab === 'backup' && (
<div className="space-y-6 max-w-xl">
<div className="space-y-4">
<h3 className="text-foreground font-semibold border-b border-border pb-2">Configuration Backup</h3>
<p className="text-xs text-muted-foreground">
Securely export your settings, including API keys and prompts. You must set a password to encrypt the backup file.
</p>
<div className="relative">
<label className="block text-xs font-semibold text-muted-foreground mb-1 uppercase tracking-wide">
Encryption Password
</label>
<input
type={showPassword ? "text" : "password"}
value={backupPassword}
onChange={(e) => setBackupPassword(e.target.value)}
placeholder="Enter a strong password"
className="w-full p-2 pr-8 rounded border border-border bg-secondary text-foreground focus:ring-2 focus:ring-primary outline-none text-sm"
/>
<button
onClick={() => setShowPassword(!showPassword)}
className="absolute right-2 top-8 text-muted-foreground hover:text-foreground"
>
{showPassword ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
</div>
<div className="flex gap-4 pt-2">
<button
onClick={handleExport}
className="flex-1 flex items-center justify-center gap-2 py-3 px-4 rounded-lg bg-secondary hover:bg-secondary/80 border border-border text-foreground transition-all font-semibold active:scale-95"
>
<Download size={18} /> Export Config
</button>
<button
onClick={triggerImport}
className="flex-1 flex items-center justify-center gap-2 py-3 px-4 rounded-lg bg-secondary hover:bg-secondary/80 border border-border text-foreground transition-all font-semibold active:scale-95"
>
<Upload size={18} /> Import Config
</button>
<input
type="file"
id="import-file-input"
accept=".conf"
className="hidden"
onChange={handleFileSelect}
/>
</div>
</div>
</div>
)}
{activeTab === 'logs' && (
<div className="space-y-4 max-w-4xl h-full flex flex-col">
<div className="flex justify-between items-center border-b border-border pb-2">
<h3 className="text-foreground font-semibold">Application Logs</h3>
<div className="flex gap-2">
<button
onClick={handleSaveLogs}
className="text-xs bg-secondary hover:bg-secondary/80 px-2 py-1 rounded border border-border transition-all active:scale-95 flex items-center gap-1"
>
<Download size={12} /> Export Logs
</button>
<button
onClick={loadLogs}
className="text-xs bg-secondary hover:bg-secondary/80 px-2 py-1 rounded border border-border transition-all active:scale-95"
>
Refresh Logs
</button>
</div>
</div>
<div className="flex-1 bg-black text-white p-4 rounded font-mono text-xs overflow-auto whitespace-pre-wrap leading-relaxed shadow-inner">
{logs}
</div>
</div>
)}
</div>
{
statusIdx && (
<div className={`p-2 text-xs text-center border-t ${statusIdx.includes('Error') || statusIdx.includes('failed') ? 'bg-destructive/10 border-destructive text-destructive' : 'bg-green-500/10 border-green-500 text-green-500'}`}>
{statusIdx}
</div>
)
}
</div >
); );
}; };

View File

@@ -1,9 +1,9 @@
import React from 'react'; import React from 'react';
import { Mic, Terminal, FileText, Calendar } from 'lucide-react'; import { Mic, FileText, Calendar } from 'lucide-react';
interface TabsProps { interface TabsProps {
currentTab: 'recorder' | 'logs' | 'transcription' | 'settings' | 'meetings' | 'history'; currentTab: 'recorder' | 'transcription' | 'settings' | 'meetings' | 'history';
onTabChange: (tab: 'recorder' | 'logs' | 'transcription' | 'settings' | 'meetings' | 'history') => void; onTabChange: (tab: 'recorder' | 'transcription' | 'settings' | 'meetings' | 'history') => void;
} }
const Tabs: React.FC<TabsProps> = ({ currentTab, onTabChange }) => { const Tabs: React.FC<TabsProps> = ({ currentTab, onTabChange }) => {
@@ -37,16 +37,7 @@ const Tabs: React.FC<TabsProps> = ({ currentTab, onTabChange }) => {
<FileText size={16} /> <FileText size={16} />
History History
</button> </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 */} {/* 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 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". The prompt showed "Recording | Summary | Meetings". We are doing "Recording | Logs".

View File

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