Fix critical integration issues for end-to-end functionality

- Rewrite SidecarManager as singleton with OnceLock, reusing one Python
  process across all commands instead of spawning per call
- Separate stdin/stdout ownership with dedicated BufReader to prevent
  data corruption between wait_for_ready and send_and_receive
- Add ensure_running() for auto-start on first command
- Fix asset protocol URL: use convertFileSrc() instead of manual
  encodeURIComponent which broke file paths with slashes
- Add +layout.svelte with global dark theme, CSS reset, and custom
  scrollbar styling to prevent white flash on startup
- Register AppState with Tauri .manage(), initialize SQLite database
  on app startup at ~/.voicetonotes/voice_to_notes.db
- Wire project commands (create/get/list) to real database queries
  instead of placeholder stubs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-26 16:50:14 -08:00
parent d3c2954c5e
commit d00281f0c7
9 changed files with 205 additions and 134 deletions

View File

@@ -1,19 +1,7 @@
use serde_json::{json, Value};
use crate::sidecar::messages::IPCMessage;
use crate::sidecar::SidecarManager;
fn get_sidecar() -> Result<SidecarManager, String> {
let python_path = std::env::current_dir()
.map_err(|e| e.to_string())?
.join("../python")
.canonicalize()
.map_err(|e| format!("Cannot find python directory: {e}"))?;
let manager = SidecarManager::new();
manager.start(&python_path.to_string_lossy())?;
Ok(manager)
}
use crate::sidecar::sidecar;
/// Send a chat message to the AI provider via the Python sidecar.
#[tauri::command]
@@ -22,14 +10,8 @@ pub fn ai_chat(
transcript_context: Option<String>,
provider: Option<String>,
) -> Result<Value, String> {
let manager = get_sidecar()?;
let request_id = uuid::Uuid::new_v4().to_string();
let payload = json!({
"action": "chat",
"messages": messages,
"transcript_context": transcript_context.unwrap_or_default(),
});
let manager = sidecar();
manager.ensure_running()?;
// If a specific provider is requested, set it first
if let Some(p) = provider {
@@ -41,7 +23,17 @@ pub fn ai_chat(
let _ = manager.send_and_receive(&set_msg)?;
}
let msg = IPCMessage::new(&request_id, "ai.chat", payload);
let request_id = uuid::Uuid::new_v4().to_string();
let msg = IPCMessage::new(
&request_id,
"ai.chat",
json!({
"action": "chat",
"messages": messages,
"transcript_context": transcript_context.unwrap_or_default(),
}),
);
let response = manager.send_and_receive(&msg)?;
if response.msg_type == "error" {
@@ -57,7 +49,8 @@ pub fn ai_chat(
/// List available AI providers.
#[tauri::command]
pub fn ai_list_providers() -> Result<Value, String> {
let manager = get_sidecar()?;
let manager = sidecar();
manager.ensure_running()?;
let request_id = uuid::Uuid::new_v4().to_string();
let msg = IPCMessage::new(
@@ -73,7 +66,8 @@ pub fn ai_list_providers() -> Result<Value, String> {
/// Configure an AI provider with API key/settings.
#[tauri::command]
pub fn ai_configure(provider: String, config: Value) -> Result<Value, String> {
let manager = get_sidecar()?;
let manager = sidecar();
manager.ensure_running()?;
let request_id = uuid::Uuid::new_v4().to_string();
let msg = IPCMessage::new(

View File

@@ -1,7 +1,7 @@
use serde_json::{json, Value};
use crate::sidecar::messages::IPCMessage;
use crate::sidecar::SidecarManager;
use crate::sidecar::sidecar;
/// Export transcript to caption/text format via the Python sidecar.
#[tauri::command]
@@ -12,16 +12,8 @@ pub fn export_transcript(
output_path: String,
title: Option<String>,
) -> Result<Value, String> {
let python_path = std::env::current_dir()
.map_err(|e| e.to_string())?
.join("../python")
.canonicalize()
.map_err(|e| format!("Cannot find python directory: {e}"))?;
let python_path_str = python_path.to_string_lossy().to_string();
let manager = SidecarManager::new();
manager.start(&python_path_str)?;
let manager = sidecar();
manager.ensure_running()?;
let request_id = uuid::Uuid::new_v4().to_string();
let msg = IPCMessage::new(

View File

@@ -1,27 +1,23 @@
use tauri::State;
use crate::db::models::Project;
use crate::db::queries;
use crate::state::AppState;
#[tauri::command]
pub fn create_project(name: String) -> Result<Project, String> {
// TODO: Use actual database connection from app state
Ok(Project {
id: uuid::Uuid::new_v4().to_string(),
name,
created_at: chrono::Utc::now().to_rfc3339(),
updated_at: chrono::Utc::now().to_rfc3339(),
settings: None,
status: "active".to_string(),
})
pub fn create_project(name: String, state: State<AppState>) -> Result<Project, String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
queries::create_project(&conn, &name).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn get_project(id: String) -> Result<Option<Project>, String> {
// TODO: Use actual database connection from app state
let _ = id;
Ok(None)
pub fn get_project(id: String, state: State<AppState>) -> Result<Option<Project>, String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
queries::get_project(&conn, &id).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn list_projects() -> Result<Vec<Project>, String> {
// TODO: Use actual database connection from app state
Ok(vec![])
pub fn list_projects(state: State<AppState>) -> Result<Vec<Project>, String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
queries::list_projects(&conn).map_err(|e| e.to_string())
}

View File

@@ -1,12 +1,9 @@
use serde_json::{json, Value};
use crate::sidecar::messages::IPCMessage;
use crate::sidecar::SidecarManager;
use crate::sidecar::sidecar;
/// Start transcription of an audio file via the Python sidecar.
///
/// This is a blocking command — it starts the sidecar if needed,
/// sends the transcribe request, and waits for the result.
#[tauri::command]
pub fn transcribe_file(
file_path: String,
@@ -14,17 +11,8 @@ pub fn transcribe_file(
device: Option<String>,
language: Option<String>,
) -> Result<Value, String> {
// Determine Python sidecar path (relative to app)
let python_path = std::env::current_dir()
.map_err(|e| e.to_string())?
.join("../python")
.canonicalize()
.map_err(|e| format!("Cannot find python directory: {e}"))?;
let python_path_str = python_path.to_string_lossy().to_string();
let manager = SidecarManager::new();
manager.start(&python_path_str)?;
let manager = sidecar();
manager.ensure_running()?;
let request_id = uuid::Uuid::new_v4().to_string();
let msg = IPCMessage::new(
@@ -63,16 +51,8 @@ pub fn run_pipeline(
max_speakers: Option<u32>,
skip_diarization: Option<bool>,
) -> Result<Value, String> {
let python_path = std::env::current_dir()
.map_err(|e| e.to_string())?
.join("../python")
.canonicalize()
.map_err(|e| format!("Cannot find python directory: {e}"))?;
let python_path_str = python_path.to_string_lossy().to_string();
let manager = SidecarManager::new();
manager.start(&python_path_str)?;
let manager = sidecar();
manager.ensure_running()?;
let request_id = uuid::Uuid::new_v4().to_string();
let msg = IPCMessage::new(

View File

@@ -10,12 +10,16 @@ use commands::project::{create_project, get_project, list_projects};
use commands::settings::{load_settings, save_settings};
use commands::system::{get_data_dir, llama_list_models, llama_start, llama_status, llama_stop};
use commands::transcribe::{run_pipeline, transcribe_file};
use state::AppState;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let app_state = AppState::new().expect("Failed to initialize app state");
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init())
.manage(app_state)
.invoke_handler(tauri::generate_handler![
create_project,
get_project,

View File

@@ -2,38 +2,82 @@ pub mod ipc;
pub mod messages;
use std::io::{BufRead, BufReader, Write};
use std::process::{Child, Command, Stdio};
use std::sync::Mutex;
use std::process::{Child, ChildStdin, Command, Stdio};
use std::sync::{Mutex, OnceLock};
use crate::sidecar::messages::IPCMessage;
/// Get the global sidecar manager singleton.
pub fn sidecar() -> &'static SidecarManager {
static INSTANCE: OnceLock<SidecarManager> = OnceLock::new();
INSTANCE.get_or_init(SidecarManager::new)
}
/// Manages the Python sidecar process lifecycle.
/// Uses separated stdin/stdout ownership to avoid BufReader conflicts.
pub struct SidecarManager {
process: Mutex<Option<Child>>,
stdin: Mutex<Option<ChildStdin>>,
reader: Mutex<Option<BufReader<std::process::ChildStdout>>>,
}
impl SidecarManager {
pub fn new() -> Self {
Self {
process: Mutex::new(None),
stdin: Mutex::new(None),
reader: Mutex::new(None),
}
}
/// Ensure the sidecar is running, starting it if needed.
pub fn ensure_running(&self) -> Result<(), String> {
if self.is_running() {
return Ok(());
}
let python_path = std::env::current_dir()
.map_err(|e| e.to_string())?
.join("../python")
.canonicalize()
.map_err(|e| format!("Cannot find python directory: {e}"))?;
self.start(&python_path.to_string_lossy())
}
/// Spawn the Python sidecar process.
pub fn start(&self, python_path: &str) -> Result<(), String> {
let child = Command::new("python3")
// Stop existing process if any
self.stop().ok();
let mut child = Command::new("python3")
.arg("-m")
.arg("voice_to_notes.main")
.current_dir(python_path)
.env("PYTHONPATH", python_path)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::inherit()) // Let sidecar logs go to parent's stderr
.stderr(Stdio::inherit())
.spawn()
.map_err(|e| format!("Failed to start sidecar: {e}"))?;
let mut proc = self.process.lock().map_err(|e| e.to_string())?;
*proc = Some(child);
// Take ownership of stdin and stdout separately
let stdin = child.stdin.take().ok_or("Failed to get sidecar stdin")?;
let stdout = child.stdout.take().ok_or("Failed to get sidecar stdout")?;
let buf_reader = BufReader::new(stdout);
{
let mut proc = self.process.lock().map_err(|e| e.to_string())?;
*proc = Some(child);
}
{
let mut s = self.stdin.lock().map_err(|e| e.to_string())?;
*s = Some(stdin);
}
{
let mut r = self.reader.lock().map_err(|e| e.to_string())?;
*r = Some(buf_reader);
}
// Wait for the "ready" message
self.wait_for_ready()?;
@@ -43,23 +87,28 @@ impl SidecarManager {
/// Wait for the sidecar to send its ready message.
fn wait_for_ready(&self) -> Result<(), String> {
let mut proc = self.process.lock().map_err(|e| e.to_string())?;
if let Some(ref mut child) = *proc {
if let Some(ref mut stdout) = child.stdout {
let reader = BufReader::new(stdout);
for line in reader.lines() {
let line = line.map_err(|e| format!("Read error: {e}"))?;
if line.is_empty() {
continue;
}
if let Ok(msg) = serde_json::from_str::<IPCMessage>(&line) {
if msg.msg_type == "ready" {
return Ok(());
}
}
// If we got a non-ready message, something's wrong but don't block forever
break;
let mut reader_guard = self.reader.lock().map_err(|e| e.to_string())?;
if let Some(ref mut reader) = *reader_guard {
let mut line = String::new();
loop {
line.clear();
let bytes = reader
.read_line(&mut line)
.map_err(|e| format!("Read error: {e}"))?;
if bytes == 0 {
return Err("Sidecar closed stdout before sending ready".to_string());
}
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
if let Ok(msg) = serde_json::from_str::<IPCMessage>(trimmed) {
if msg.msg_type == "ready" {
return Ok(());
}
}
// Non-ready message: something is wrong
break;
}
}
Err("Sidecar did not send ready message".to_string())
@@ -68,10 +117,10 @@ impl SidecarManager {
/// Send a message to the sidecar and read the response.
/// This is a blocking call.
pub fn send_and_receive(&self, msg: &IPCMessage) -> Result<IPCMessage, String> {
let mut proc = self.process.lock().map_err(|e| e.to_string())?;
if let Some(ref mut child) = *proc {
// Write message to stdin
if let Some(ref mut stdin) = child.stdin {
// Write to stdin
{
let mut stdin_guard = self.stdin.lock().map_err(|e| e.to_string())?;
if let Some(ref mut stdin) = *stdin_guard {
let json = serde_json::to_string(msg).map_err(|e| e.to_string())?;
stdin
.write_all(json.as_bytes())
@@ -83,12 +132,13 @@ impl SidecarManager {
} else {
return Err("Sidecar stdin not available".to_string());
}
}
// Read response from stdout
if let Some(ref mut stdout) = child.stdout {
let mut reader = BufReader::new(stdout);
// Read from stdout
{
let mut reader_guard = self.reader.lock().map_err(|e| e.to_string())?;
if let Some(ref mut reader) = *reader_guard {
let mut line = String::new();
// Read lines until we get a response (skip progress messages, collect them)
loop {
line.clear();
let bytes_read = reader
@@ -101,40 +151,45 @@ impl SidecarManager {
if trimmed.is_empty() {
continue;
}
let response: IPCMessage =
serde_json::from_str(trimmed).map_err(|e| format!("Parse error: {e}"))?;
let response: IPCMessage = serde_json::from_str(trimmed)
.map_err(|e| format!("Parse error: {e}"))?;
// If it's a progress message, we could emit it as an event
// For now, skip progress and return the final result/error
// Skip progress messages, return the final result/error
if response.msg_type != "progress" {
return Ok(response);
}
}
} else {
return Err("Sidecar stdout not available".to_string());
Err("Sidecar stdout not available".to_string())
}
} else {
Err("Sidecar not running".to_string())
}
}
/// Stop the sidecar process.
pub fn stop(&self) -> Result<(), String> {
let mut proc = self.process.lock().map_err(|e| e.to_string())?;
if let Some(ref mut child) = proc.take() {
// Close stdin to signal EOF
drop(child.stdin.take());
// Wait briefly for clean exit, then kill
match child.wait() {
Ok(_) => Ok(()),
Err(e) => {
let _ = child.kill();
Err(format!("Sidecar did not exit cleanly: {e}"))
// Drop stdin to signal EOF
{
let mut stdin_guard = self.stdin.lock().map_err(|e| e.to_string())?;
*stdin_guard = None;
}
// Drop reader
{
let mut reader_guard = self.reader.lock().map_err(|e| e.to_string())?;
*reader_guard = None;
}
// Wait for process to exit
{
let mut proc = self.process.lock().map_err(|e| e.to_string())?;
if let Some(ref mut child) = proc.take() {
match child.wait() {
Ok(_) => {}
Err(_) => {
let _ = child.kill();
}
}
}
} else {
Ok(())
}
Ok(())
}
pub fn is_running(&self) -> bool {

View File

@@ -3,17 +3,28 @@ use std::sync::Mutex;
use rusqlite::Connection;
use crate::db;
use crate::llama::LlamaManager;
/// Shared application state managed by Tauri.
pub struct AppState {
pub db: Mutex<Option<Connection>>,
pub db: Mutex<Connection>,
pub data_dir: PathBuf,
}
impl AppState {
pub fn new(data_dir: PathBuf) -> Self {
Self {
db: Mutex::new(None),
pub fn new() -> Result<Self, String> {
let data_dir = LlamaManager::data_dir();
std::fs::create_dir_all(&data_dir)
.map_err(|e| format!("Cannot create data dir: {e}"))?;
let db_path = data_dir.join("voice_to_notes.db");
let conn = db::open_database(&db_path)
.map_err(|e| format!("Cannot open database: {e}"))?;
Ok(Self {
db: Mutex::new(conn),
data_dir,
}
})
}
}

39
src/routes/+layout.svelte Normal file
View File

@@ -0,0 +1,39 @@
<script>
let { children } = $props();
</script>
{@render children()}
<style>
:global(html, body) {
margin: 0;
padding: 0;
background: #0a0a23;
color: #e0e0e0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen,
Ubuntu, Cantarell, sans-serif;
overflow: hidden;
height: 100%;
}
:global(*, *::before, *::after) {
box-sizing: border-box;
}
:global(::-webkit-scrollbar) {
width: 8px;
}
:global(::-webkit-scrollbar-track) {
background: #0a0a23;
}
:global(::-webkit-scrollbar-thumb) {
background: #2a3a5e;
border-radius: 4px;
}
:global(::-webkit-scrollbar-thumb:hover) {
background: #4a5568;
}
</style>

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { invoke } from '@tauri-apps/api/core';
import { invoke, convertFileSrc } from '@tauri-apps/api/core';
import { open, save } from '@tauri-apps/plugin-dialog';
import WaveformPlayer from '$lib/components/WaveformPlayer.svelte';
import TranscriptEditor from '$lib/components/TranscriptEditor.svelte';
@@ -81,8 +81,8 @@
});
if (!filePath) return;
// Convert file path to URL for wavesurfer
audioUrl = `asset://localhost/${encodeURIComponent(filePath)}`;
// Convert file path to asset URL for wavesurfer
audioUrl = convertFileSrc(filePath);
waveformPlayer?.loadAudio(audioUrl);
// Start pipeline (transcription + diarization)