Phase 1
@@ -118,9 +118,10 @@ The "customer asks for a feature, get started right away" path, **pull** model:
|
||||
|
||||
- **UI/IPC thread:** Tauri commands return quickly. Nothing that runs for the length of a meeting
|
||||
blocks a command — long work is spawned and reports via **events**.
|
||||
- **Audio thread:** a dedicated thread runs the WASAPI **polling** capture loop. (WASAPI loopback
|
||||
cannot use event-callback mode, so we poll with a small interval — see `07-research-findings.md`.)
|
||||
It only does capture + disk write + ring-buffer push; no inference.
|
||||
- **Audio thread:** a dedicated thread runs the WASAPI capture loop, waiting on the WASAPI event
|
||||
handle (short timeout so it can also notice a stop/pause request) rather than busy-polling —
|
||||
see `07-research-findings.md` for why the earlier "polling only" note was corrected. It only
|
||||
does capture + disk write + ring-buffer push; no inference.
|
||||
- **Inference worker(s):** transcription runs on its own worker, pulling audio windows from the
|
||||
ring buffer; diarization runs as a discrete post-stop job. Backends are constructed once per
|
||||
session from the `hardware` ranking.
|
||||
|
||||
@@ -28,10 +28,14 @@ low-overhead NFRs. Pairs cleanly with Tauri via Vite. → **ADR-0002.**
|
||||
|
||||
### Audio capture — WASAPI loopback in Rust ✅
|
||||
The `wasapi` crate provides safe Rust access to WASAPI including a **loopback** example
|
||||
(capture system output without a meeting bot). `cpal` is the cross-platform fallback. Known
|
||||
constraint: `AUDCLNT_STREAMFLAGS_LOOPBACK` and `AUDCLNT_STREAMFLAGS_EVENTCALLBACK` cannot be
|
||||
combined, so the capture loop must **poll** rather than rely on event callbacks. This shapes
|
||||
the audio thread design in `docs/02-architecture.md`. → **ADR-0001 / architecture.**
|
||||
(capture system output without a meeting bot). `cpal` is the cross-platform fallback.
|
||||
**Correction (Phase 1 implementation, verified against `wasapi` v0.15 source and current
|
||||
Windows):** `AUDCLNT_STREAMFLAGS_LOOPBACK` and `AUDCLNT_STREAMFLAGS_EVENTCALLBACK` **can** be
|
||||
combined — the crate wires both flags together for a render-device-opened-as-capture client, and
|
||||
the capture loop waits on the WASAPI event handle (with a short timeout so it can also notice a
|
||||
stop/pause request) rather than busy-polling. The pre-implementation note below describing a
|
||||
polling-only design was inaccurate; `docs/02-architecture.md`'s "Audio thread" section has been
|
||||
corrected to match.
|
||||
|
||||
### Transcription — whisper-rs (+ ONNX for NPU) ✅
|
||||
`whisper-rs` wraps **whisper.cpp** with feature-gated acceleration: **CUDA** (NVIDIA),
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import js from "@eslint/js";
|
||||
import tseslint from "typescript-eslint";
|
||||
import svelte from "eslint-plugin-svelte";
|
||||
import globals from "globals";
|
||||
|
||||
export default tseslint.config(
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
...svelte.configs.recommended,
|
||||
{
|
||||
files: ["**/*.ts"],
|
||||
languageOptions: {
|
||||
parser: tseslint.parser,
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["**/*.svelte"],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
parser: tseslint.parser,
|
||||
},
|
||||
globals: { ...globals.browser },
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["**/*.{js,mjs,ts}"],
|
||||
languageOptions: {
|
||||
globals: { ...globals.browser },
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["scripts/**/*.mjs"],
|
||||
languageOptions: {
|
||||
globals: { ...globals.node },
|
||||
},
|
||||
},
|
||||
{
|
||||
ignores: ["dist/", "src-tauri/", "node_modules/"],
|
||||
},
|
||||
);
|
||||
@@ -19,11 +19,15 @@
|
||||
"@tauri-apps/cli": "^2.0.0",
|
||||
"@tsconfig/svelte": "^5.0.0",
|
||||
"eslint": "^9.0.0",
|
||||
"eslint-plugin-svelte": "^3.20.0",
|
||||
"globals": "^17.7.0",
|
||||
"prettier": "^3.3.0",
|
||||
"prettier-plugin-svelte": "^3.2.0",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"svelte-eslint-parser": "^1.8.0",
|
||||
"typescript": "^5.5.0",
|
||||
"typescript-eslint": "^8.62.1",
|
||||
"vite": "^5.4.0"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,12 +1,36 @@
|
||||
// Dev helper (placeholder) — download default models into %LOCALAPPDATA%\WhispAssist\models.
|
||||
// In the app, model download is a Phase 3 in-product flow (FR-MODEL-1) with progress events;
|
||||
// this script is only for setting up a dev machine quickly.
|
||||
// Dev helper — download the Phase 1 CPU whisper model into
|
||||
// %LOCALAPPDATA%\WhispAssist\models. In-product model management (choose size,
|
||||
// download/remove with progress) is Phase 3 (FR-MODEL-1); this script only gets
|
||||
// a dev machine to a working `whisper-rs` baseline quickly.
|
||||
//
|
||||
// Intended models (pin URLs/checksums at implementation time):
|
||||
// - Whisper: base (q5) GGUF for whisper.cpp
|
||||
// - Diarization: pyannote segmentation (ONNX) + ERes2Net speaker embedding (ONNX)
|
||||
//
|
||||
// Usage (once implemented): node scripts/download-models.mjs --set default
|
||||
// Usage: node scripts/download-models.mjs
|
||||
|
||||
console.log("TODO: implement model download (Phase 3). See docs/05-roadmap.md T3.7.");
|
||||
process.exit(0);
|
||||
import { createWriteStream, existsSync, mkdirSync } from "node:fs";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import path from "node:path";
|
||||
|
||||
const MODEL_URL = "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.en-q5_1.bin";
|
||||
const MODEL_NAME = "ggml-base.en-q5_1.bin";
|
||||
|
||||
const modelsDir = path.join(process.env.LOCALAPPDATA ?? process.cwd(), "WhispAssist", "models");
|
||||
const dest = path.join(modelsDir, MODEL_NAME);
|
||||
|
||||
if (existsSync(dest)) {
|
||||
console.log(`Already have ${dest}`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
mkdirSync(modelsDir, { recursive: true });
|
||||
console.log(`Downloading ${MODEL_URL} -> ${dest}`);
|
||||
|
||||
const res = await fetch(MODEL_URL, { redirect: "follow" });
|
||||
if (!res.ok || !res.body) {
|
||||
console.error(`Download failed: HTTP ${res.status}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const tmp = `${dest}.part`;
|
||||
await pipeline(res.body, createWriteStream(tmp));
|
||||
const { renameSync } = await import("node:fs");
|
||||
renameSync(tmp, dest);
|
||||
console.log("Done.");
|
||||
|
||||
@@ -18,14 +18,14 @@ crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = ["tray-icon"] }
|
||||
tauri = { version = "2", features = ["tray-icon", "image-png"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
thiserror = "1"
|
||||
async-trait = "0.1"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "fs"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = "0.3"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
|
||||
# storage
|
||||
@@ -41,6 +41,7 @@ rmcp = { version = "0.16", optional = true, features = ["server"] } # MCP serve
|
||||
# audio / transcription / diarization / calendar are integrated per-phase and are
|
||||
# feature-gated so the CPU-only build always compiles (NFR-MNT-4).
|
||||
hound = { version = "3", optional = true } # WAV I/O (Phase 1)
|
||||
whisper-rs = { version = "0.16", optional = true } # whisper.cpp bindings (Phase 1)
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows = { version = "0.58", features = [
|
||||
@@ -54,7 +55,7 @@ wasapi = { version = "0.15", optional = true } # Phase 1
|
||||
default = ["audio", "cpu-transcription"]
|
||||
# Phase 1
|
||||
audio = ["dep:wasapi", "dep:hound"]
|
||||
cpu-transcription = [] # enables whisper-rs CPU build (add dep when wired in Phase 1)
|
||||
cpu-transcription = ["dep:whisper-rs"] # whisper-rs CPU build
|
||||
# Phase 3 acceleration (built on capable CI runners; never required)
|
||||
cuda = [] # whisper.cpp CUDA
|
||||
vulkan = [] # whisper.cpp Vulkan
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window. App-defined commands (start_recording, etc.) are always invokable; this grants the core plugin permissions the frontend needs on top of that — window/event/app basics plus listening for recording://*, transcript://*, and the other Rust->UI events.",
|
||||
"windows": ["main"],
|
||||
"permissions": ["core:default"]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"default":{"identifier":"default","description":"Capability for the main window. App-defined commands (start_recording, etc.) are always invokable; this grants the core plugin permissions the frontend needs on top of that — window/event/app basics plus listening for recording://*, transcript://*, and the other Rust->UI events.","local":true,"windows":["main"],"permissions":["core:default"]}}
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
@@ -1,7 +1,4 @@
|
||||
# Icons
|
||||
|
||||
Placeholder. Add before first bundle:
|
||||
- `icon.ico` — app/installer icon (multi-resolution).
|
||||
- `tray.png` — system tray icon (referenced by `tauri.conf.json`).
|
||||
|
||||
Generate the full icon set from a single source with `npm run tauri icon path/to/source.png`.
|
||||
Placeholder icon set (Phase 1) generated with `npm run tauri icon`. Replace `icon.ico`/`tray.png`
|
||||
(and re-run `npm run tauri icon path/to/source.png`) with real branding before shipping.
|
||||
|
||||
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
@@ -32,13 +32,22 @@ pub struct RunOutcome {
|
||||
/// Spawns a local coding-agent CLI headless against a repo (claude -p / codex exec / opencode run / copilot).
|
||||
#[async_trait]
|
||||
pub trait AgentRunner: Send + Sync {
|
||||
async fn run(&self, brief: &FeatureBrief, repo: &Path, out: LineSink) -> Result<RunOutcome, AgentError>;
|
||||
async fn run(
|
||||
&self,
|
||||
brief: &FeatureBrief,
|
||||
repo: &Path,
|
||||
out: LineSink,
|
||||
) -> Result<RunOutcome, AgentError>;
|
||||
}
|
||||
|
||||
/// Creates a work item from a brief (e.g. a GitHub issue, optionally assigned to Copilot's cloud agent).
|
||||
#[async_trait]
|
||||
pub trait IssueTracker: Send + Sync {
|
||||
async fn create_issue(&self, brief: &FeatureBrief, assign_copilot: bool) -> Result<String /* url */, AgentError>;
|
||||
async fn create_issue(
|
||||
&self,
|
||||
brief: &FeatureBrief,
|
||||
assign_copilot: bool,
|
||||
) -> Result<String /* url */, AgentError>;
|
||||
}
|
||||
|
||||
// Concrete impls (CliAgentRunner, GitHubIssueTracker) are added in Phase 10c behind a feature flag.
|
||||
|
||||
@@ -1,13 +1,30 @@
|
||||
//! Audio capture service — WASAPI loopback (Phase 1, FR-CAP-1/2).
|
||||
//!
|
||||
//! Design notes (see docs/02-architecture.md):
|
||||
//! - WASAPI loopback CANNOT use event-callback mode, so capture POLLS on a
|
||||
//! dedicated thread (research finding, docs/07).
|
||||
//! - Writes PCM to disk continuously (audio = source of truth) AND pushes frames
|
||||
//! into a bounded ring buffer consumed by the transcription worker.
|
||||
//! - Loopback capture opens the default **render** device and initializes it for
|
||||
//! `Direction::Capture` (see `initialize_client` below). The `wasapi` crate wires
|
||||
//! `AUDCLNT_STREAMFLAGS_EVENTCALLBACK | AUDCLNT_STREAMFLAGS_LOOPBACK` together for
|
||||
//! this combination and it works correctly on current Windows — the polling-only
|
||||
//! claim in `docs/07-research-findings.md` was written before verifying against the
|
||||
//! crate's actual source and doesn't hold for this dependency; the capture loop
|
||||
//! below waits on the WASAPI event handle (with a short timeout so it can also
|
||||
//! notice a stop/pause request) rather than busy-polling.
|
||||
//! - Writes PCM to disk continuously, in the device's native format, so `audio.wav`
|
||||
//! is a byte-accurate capture (audio = source of truth). A downmixed/resampled
|
||||
//! 16kHz-mono copy is pushed into the bounded `FrameSink` for live transcription;
|
||||
//! a full consumer drops preview frames (`try_send`) without blocking the disk
|
||||
//! write or the capture thread.
|
||||
//! - Does no inference itself.
|
||||
|
||||
use hound::{SampleFormat, WavSpec, WavWriter};
|
||||
use std::fs::File;
|
||||
use std::io::BufWriter;
|
||||
use std::path::Path;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::mpsc::SyncSender;
|
||||
use std::sync::Arc;
|
||||
use std::thread::{self, JoinHandle};
|
||||
use wasapi::{Direction, SampleType, ShareMode, WaveFormat};
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AudioError {
|
||||
@@ -20,10 +37,15 @@ pub enum AudioError {
|
||||
}
|
||||
|
||||
/// Opaque handle to a running capture, returned by `start` and consumed by `stop`.
|
||||
pub struct CaptureHandle;
|
||||
pub struct CaptureHandle {
|
||||
running: Arc<AtomicBool>,
|
||||
paused: Arc<AtomicBool>,
|
||||
thread: JoinHandle<Result<CaptureSummary, AudioError>>,
|
||||
}
|
||||
|
||||
/// Where captured frames are delivered for live transcription.
|
||||
pub type FrameSink = std::sync::mpsc::Sender<Vec<f32>>;
|
||||
/// Where captured frames are delivered for live transcription: mono f32 @ 16kHz,
|
||||
/// bounded so a slow/absent consumer can never stall the capture thread.
|
||||
pub type FrameSink = SyncSender<Vec<f32>>;
|
||||
|
||||
pub struct CaptureSummary {
|
||||
pub duration_ms: u64,
|
||||
@@ -44,18 +66,358 @@ pub struct WasapiCapture;
|
||||
|
||||
#[cfg(feature = "audio")]
|
||||
impl AudioCapture for WasapiCapture {
|
||||
fn start(&self, _wav_path: &Path, _sink: FrameSink) -> Result<CaptureHandle, AudioError> {
|
||||
// T1.2: open default render device in loopback mode, spawn polling thread,
|
||||
// write WAV via `hound`, push frames to `sink`.
|
||||
todo!("Phase 1 — WASAPI loopback capture")
|
||||
fn start(&self, wav_path: &Path, sink: FrameSink) -> Result<CaptureHandle, AudioError> {
|
||||
let running = Arc::new(AtomicBool::new(true));
|
||||
let paused = Arc::new(AtomicBool::new(false));
|
||||
let running_th = running.clone();
|
||||
let paused_th = paused.clone();
|
||||
let wav_path = wav_path.to_path_buf();
|
||||
|
||||
let thread = thread::Builder::new()
|
||||
.name("wa-audio-capture".into())
|
||||
.spawn(move || capture_loop(&wav_path, &sink, &running_th, &paused_th))
|
||||
.map_err(|e| AudioError::Capture(format!("spawn failed: {e}")))?;
|
||||
|
||||
Ok(CaptureHandle {
|
||||
running,
|
||||
paused,
|
||||
thread,
|
||||
})
|
||||
}
|
||||
fn pause(&self, _h: &CaptureHandle) -> Result<(), AudioError> {
|
||||
todo!("Phase 1 — pause capture")
|
||||
|
||||
fn pause(&self, h: &CaptureHandle) -> Result<(), AudioError> {
|
||||
h.paused.store(true, Ordering::SeqCst);
|
||||
Ok(())
|
||||
}
|
||||
fn resume(&self, _h: &CaptureHandle) -> Result<(), AudioError> {
|
||||
todo!("Phase 1 — resume capture")
|
||||
|
||||
fn resume(&self, h: &CaptureHandle) -> Result<(), AudioError> {
|
||||
h.paused.store(false, Ordering::SeqCst);
|
||||
Ok(())
|
||||
}
|
||||
fn stop(&self, _h: CaptureHandle) -> Result<CaptureSummary, AudioError> {
|
||||
todo!("Phase 1 — stop capture, finalize WAV")
|
||||
|
||||
fn stop(&self, h: CaptureHandle) -> Result<CaptureSummary, AudioError> {
|
||||
h.running.store(false, Ordering::SeqCst);
|
||||
h.thread
|
||||
.join()
|
||||
.map_err(|_| AudioError::Capture("capture thread panicked".into()))?
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs on a dedicated OS thread for the lifetime of a `CaptureHandle`. Owns the
|
||||
/// WASAPI client and the WAV writer; exits (and finalizes the WAV) once `running`
|
||||
/// is cleared.
|
||||
#[cfg(feature = "audio")]
|
||||
fn capture_loop(
|
||||
wav_path: &Path,
|
||||
sink: &FrameSink,
|
||||
running: &AtomicBool,
|
||||
paused: &AtomicBool,
|
||||
) -> Result<CaptureSummary, AudioError> {
|
||||
wasapi::initialize_mta()
|
||||
.ok()
|
||||
.map_err(|e| AudioError::Device(format!("COM init failed: {e}")))?;
|
||||
|
||||
let device = wasapi::get_default_device(&Direction::Render)
|
||||
.map_err(|e| AudioError::Device(format!("no default render device: {e}")))?;
|
||||
let mut audio_client = device
|
||||
.get_iaudioclient()
|
||||
.map_err(|e| AudioError::Device(format!("activate IAudioClient failed: {e}")))?;
|
||||
let format = audio_client
|
||||
.get_mixformat()
|
||||
.map_err(|e| AudioError::Device(format!("GetMixFormat failed: {e}")))?;
|
||||
let (_default_period, min_period) = audio_client
|
||||
.get_periods()
|
||||
.map_err(|e| AudioError::Device(format!("GetDevicePeriod failed: {e}")))?;
|
||||
|
||||
// `convert = false`: we use the device's own mix format, so no on-the-fly
|
||||
// resampling/conversion is needed for a byte-accurate capture.
|
||||
audio_client
|
||||
.initialize_client(
|
||||
&format,
|
||||
min_period,
|
||||
&Direction::Capture,
|
||||
&ShareMode::Shared,
|
||||
false,
|
||||
)
|
||||
.map_err(|e| AudioError::Capture(format!("Initialize failed: {e}")))?;
|
||||
let event_handle = audio_client
|
||||
.set_get_eventhandle()
|
||||
.map_err(|e| AudioError::Capture(format!("SetEventHandle failed: {e}")))?;
|
||||
let capture_client = audio_client
|
||||
.get_audiocaptureclient()
|
||||
.map_err(|e| AudioError::Capture(format!("GetService(IAudioCaptureClient) failed: {e}")))?;
|
||||
|
||||
let spec = wav_spec_for(&format)?;
|
||||
let mut writer = WavWriter::create(wav_path, spec).map_err(|e| {
|
||||
AudioError::Capture(format!("could not create {}: {e}", wav_path.display()))
|
||||
})?;
|
||||
|
||||
audio_client
|
||||
.start_stream()
|
||||
.map_err(|e| AudioError::Capture(format!("start_stream failed: {e}")))?;
|
||||
|
||||
let mut resampler = Resampler::new(format.get_samplespersec());
|
||||
let mut queue: std::collections::VecDeque<u8> = std::collections::VecDeque::new();
|
||||
let mut frames_written: u64 = 0;
|
||||
|
||||
while running.load(Ordering::Relaxed) {
|
||||
// Short timeout so we periodically re-check `running` even with no data.
|
||||
let _ = event_handle.wait_for_event(100);
|
||||
capture_client
|
||||
.read_from_device_to_deque(&mut queue)
|
||||
.map_err(|e| AudioError::Capture(format!("GetBuffer failed: {e}")))?;
|
||||
if queue.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let bytes: Vec<u8> = queue.drain(..).collect();
|
||||
|
||||
// Must keep pulling WASAPI buffers even while paused (required to avoid
|
||||
// overrun); only skip persisting/forwarding the audio.
|
||||
if paused.load(Ordering::Relaxed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
frames_written += write_wav_bytes(&mut writer, &bytes, &format)?;
|
||||
let mono = decode_mono_f32(&bytes, &format)?;
|
||||
let resampled = resampler.process(&mono);
|
||||
if !resampled.is_empty() {
|
||||
let _ = sink.try_send(resampled); // drop on backpressure; disk write is unaffected
|
||||
}
|
||||
}
|
||||
|
||||
audio_client.stop_stream().ok();
|
||||
writer
|
||||
.finalize()
|
||||
.map_err(|e| AudioError::Capture(format!("wav finalize failed: {e}")))?;
|
||||
|
||||
let sample_rate = format.get_samplespersec();
|
||||
Ok(CaptureSummary {
|
||||
duration_ms: (frames_written * 1000) / sample_rate.max(1) as u64,
|
||||
sample_rate,
|
||||
channels: format.get_nchannels(),
|
||||
})
|
||||
}
|
||||
|
||||
fn wav_spec_for(format: &WaveFormat) -> Result<WavSpec, AudioError> {
|
||||
let sample_format = match format
|
||||
.get_subformat()
|
||||
.map_err(|e| AudioError::Device(format!("unrecognized mix format: {e}")))?
|
||||
{
|
||||
SampleType::Float => SampleFormat::Float,
|
||||
SampleType::Int => SampleFormat::Int,
|
||||
};
|
||||
Ok(WavSpec {
|
||||
channels: format.get_nchannels(),
|
||||
sample_rate: format.get_samplespersec(),
|
||||
bits_per_sample: format.get_bitspersample(),
|
||||
sample_format,
|
||||
})
|
||||
}
|
||||
|
||||
/// Write raw WASAPI capture bytes to the WAV writer in their native format.
|
||||
/// Returns the number of frames written. Only the two mix formats WASAPI shared
|
||||
/// mode actually produces in practice (32-bit float, 16-bit PCM) are supported;
|
||||
/// anything else is a loud error rather than a silently corrupt recording.
|
||||
fn write_wav_bytes(
|
||||
writer: &mut WavWriter<BufWriter<File>>,
|
||||
bytes: &[u8],
|
||||
format: &WaveFormat,
|
||||
) -> Result<u64, AudioError> {
|
||||
let sample_type = format
|
||||
.get_subformat()
|
||||
.map_err(|e| AudioError::Device(format!("unrecognized mix format: {e}")))?;
|
||||
let channels = format.get_nchannels() as u64;
|
||||
match (sample_type, format.get_bitspersample()) {
|
||||
(SampleType::Float, 32) => {
|
||||
for chunk in bytes.chunks_exact(4) {
|
||||
let v = f32::from_le_bytes(chunk.try_into().unwrap());
|
||||
writer
|
||||
.write_sample(v)
|
||||
.map_err(|e| AudioError::Capture(format!("wav write: {e}")))?;
|
||||
}
|
||||
Ok(bytes.len() as u64 / 4 / channels.max(1))
|
||||
}
|
||||
(SampleType::Int, 16) => {
|
||||
for chunk in bytes.chunks_exact(2) {
|
||||
let v = i16::from_le_bytes(chunk.try_into().unwrap());
|
||||
writer
|
||||
.write_sample(v)
|
||||
.map_err(|e| AudioError::Capture(format!("wav write: {e}")))?;
|
||||
}
|
||||
Ok(bytes.len() as u64 / 2 / channels.max(1))
|
||||
}
|
||||
(st, bits) => Err(AudioError::Device(format!(
|
||||
"unsupported capture format: {st} {bits}-bit"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Downmix raw WASAPI capture bytes to mono `f32` in `[-1.0, 1.0]`, at the
|
||||
/// device's native sample rate (resampling to 16kHz happens separately).
|
||||
fn decode_mono_f32(bytes: &[u8], format: &WaveFormat) -> Result<Vec<f32>, AudioError> {
|
||||
let sample_type = format
|
||||
.get_subformat()
|
||||
.map_err(|e| AudioError::Device(format!("unrecognized mix format: {e}")))?;
|
||||
let channels = format.get_nchannels() as usize;
|
||||
if channels == 0 {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let mut mono = Vec::new();
|
||||
match (sample_type, format.get_bitspersample()) {
|
||||
(SampleType::Float, 32) => {
|
||||
for frame in bytes.chunks_exact(4 * channels) {
|
||||
let sum: f32 = frame
|
||||
.chunks_exact(4)
|
||||
.map(|c| f32::from_le_bytes(c.try_into().unwrap()))
|
||||
.sum();
|
||||
mono.push(sum / channels as f32);
|
||||
}
|
||||
}
|
||||
(SampleType::Int, 16) => {
|
||||
for frame in bytes.chunks_exact(2 * channels) {
|
||||
let sum: f32 = frame
|
||||
.chunks_exact(2)
|
||||
.map(|c| i16::from_le_bytes(c.try_into().unwrap()) as f32 / i16::MAX as f32)
|
||||
.sum();
|
||||
mono.push(sum / channels as f32);
|
||||
}
|
||||
}
|
||||
(st, bits) => {
|
||||
return Err(AudioError::Device(format!(
|
||||
"unsupported capture format: {st} {bits}-bit"
|
||||
)))
|
||||
}
|
||||
}
|
||||
Ok(mono)
|
||||
}
|
||||
|
||||
const TARGET_SAMPLE_RATE: u32 = 16_000;
|
||||
|
||||
/// Streaming linear-interpolation resampler, mono f32 in -> mono f32 @ 16kHz out.
|
||||
/// Carries fractional position and unconsumed tail samples across calls so
|
||||
/// chunk boundaries don't introduce phase discontinuities.
|
||||
/// `// ponytail: linear resampler, ceiling is STT-grade quality; swap for a sinc
|
||||
/// resampler if transcription accuracy complaints trace back to aliasing.`
|
||||
struct Resampler {
|
||||
src_rate: u32,
|
||||
pos: f64,
|
||||
carry: Vec<f32>,
|
||||
}
|
||||
|
||||
impl Resampler {
|
||||
fn new(src_rate: u32) -> Self {
|
||||
Self {
|
||||
src_rate,
|
||||
pos: 0.0,
|
||||
carry: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn process(&mut self, mono_in: &[f32]) -> Vec<f32> {
|
||||
if mono_in.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
if self.src_rate == TARGET_SAMPLE_RATE {
|
||||
return mono_in.to_vec();
|
||||
}
|
||||
|
||||
let mut buf = std::mem::take(&mut self.carry);
|
||||
buf.extend_from_slice(mono_in);
|
||||
if buf.len() < 2 {
|
||||
self.carry = buf;
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let ratio = self.src_rate as f64 / TARGET_SAMPLE_RATE as f64;
|
||||
let mut out = Vec::new();
|
||||
while (self.pos as usize) + 1 < buf.len() {
|
||||
let i = self.pos as usize;
|
||||
let frac = (self.pos - i as f64) as f32;
|
||||
out.push(buf[i] * (1.0 - frac) + buf[i + 1] * frac);
|
||||
self.pos += ratio;
|
||||
}
|
||||
|
||||
let keep_from = (self.pos as usize).min(buf.len() - 1);
|
||||
self.pos -= keep_from as f64;
|
||||
self.carry = buf[keep_from..].to_vec();
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn wav_writer_roundtrip_produces_valid_header_and_duration() {
|
||||
let dir = std::env::temp_dir().join(format!("wa-test-{}", uuid::Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("audio.wav");
|
||||
|
||||
let spec = WavSpec {
|
||||
channels: 2,
|
||||
sample_rate: 48_000,
|
||||
bits_per_sample: 32,
|
||||
sample_format: SampleFormat::Float,
|
||||
};
|
||||
let mut writer = WavWriter::create(&path, spec).unwrap();
|
||||
let samples: Vec<f32> = (0..2000).map(|i| (i as f32 / 2000.0) - 0.5).collect();
|
||||
let bytes: Vec<u8> = samples.iter().flat_map(|s| s.to_le_bytes()).collect();
|
||||
|
||||
// format.get_subformat()/get_bitspersample() need a real WaveFormat; build one
|
||||
// via WaveFormat::new instead of a live device for this pure unit test.
|
||||
let format = WaveFormat::new(32, 32, &SampleType::Float, 48_000, 2, None);
|
||||
write_wav_bytes(&mut writer, &bytes, &format).unwrap();
|
||||
writer.finalize().unwrap();
|
||||
|
||||
let reader = hound::WavReader::open(&path).unwrap();
|
||||
let read_spec = reader.spec();
|
||||
assert_eq!(read_spec.channels, 2);
|
||||
assert_eq!(read_spec.sample_rate, 48_000);
|
||||
assert_eq!(read_spec.bits_per_sample, 32);
|
||||
let frame_count = reader.duration();
|
||||
assert_eq!(frame_count as usize, samples.len() / 2);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_mono_f32_downmixes_stereo() {
|
||||
let format = WaveFormat::new(32, 32, &SampleType::Float, 48_000, 2, None);
|
||||
// One frame: left=1.0, right=-1.0 -> mono average 0.0.
|
||||
let mut bytes = Vec::new();
|
||||
bytes.extend_from_slice(&1.0f32.to_le_bytes());
|
||||
bytes.extend_from_slice(&(-1.0f32).to_le_bytes());
|
||||
let mono = decode_mono_f32(&bytes, &format).unwrap();
|
||||
assert_eq!(mono, vec![0.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resampler_exact_integer_ratio_picks_aligned_samples() {
|
||||
// 48kHz -> 16kHz is an exact 3:1 ratio, so linear interpolation should land
|
||||
// exactly on the original samples at indices 0, 3, 6, ...
|
||||
let mut r = Resampler::new(48_000);
|
||||
let input: Vec<f32> = (0..30).map(|i| i as f32).collect();
|
||||
let out = r.process(&input);
|
||||
let expected: Vec<f32> = (0..10).map(|i| (i * 3) as f32).collect();
|
||||
assert_eq!(out, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resampler_continues_correctly_across_chunk_boundaries() {
|
||||
let mut r = Resampler::new(48_000);
|
||||
let first: Vec<f32> = (0..30).map(|i| i as f32).collect();
|
||||
let second: Vec<f32> = (30..60).map(|i| i as f32).collect();
|
||||
let mut out = r.process(&first);
|
||||
out.extend(r.process(&second));
|
||||
let expected: Vec<f32> = (0..20).map(|i| (i * 3) as f32).collect();
|
||||
assert_eq!(out, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resampler_passthrough_when_already_target_rate() {
|
||||
let mut r = Resampler::new(16_000);
|
||||
let input = vec![0.1f32, 0.2, 0.3];
|
||||
assert_eq!(r.process(&input), input);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,17 @@
|
||||
//! Contract: `docs/04-api-contracts.md`. Commands return promptly; long work
|
||||
//! is spawned and reported via events ("recording://*", "transcript://*", …).
|
||||
//!
|
||||
//! These are typed stubs. Each `todo!()` maps to a roadmap task and must be
|
||||
//! replaced with a real implementation that depends on the service traits.
|
||||
//! Recording/settings commands (Phase 1) are real implementations; everything
|
||||
//! else is still a typed `todo!()` stub mapped to its roadmap task.
|
||||
|
||||
use crate::audio::{AudioCapture, WasapiCapture};
|
||||
use crate::error::WaResult;
|
||||
use crate::models::*;
|
||||
use crate::transcription::{run_streaming_worker, Transcriber, WhisperTranscriber};
|
||||
use crate::{error::WaError, AppState, RecordingSession};
|
||||
use serde::Deserialize;
|
||||
use std::path::PathBuf;
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct StartRecordingArgs {
|
||||
@@ -18,40 +23,273 @@ pub struct StartRecordingArgs {
|
||||
pub record: bool,
|
||||
}
|
||||
|
||||
// ---- Phase 1 file-backed settings + storage paths ----
|
||||
// Full SQLite persistence is Phase 2 (`storage::Store`, still `todo!()`); Phase
|
||||
// 1 only needs `settings.json` (consent/default-retention, FR-REC-1/2) and the
|
||||
// `meetings/<id>/` folder layout from `docs/03-data-model.md`.
|
||||
|
||||
fn local_appdata() -> PathBuf {
|
||||
PathBuf::from(std::env::var("LOCALAPPDATA").unwrap_or_else(|_| ".".to_string()))
|
||||
}
|
||||
|
||||
fn wa_root() -> PathBuf {
|
||||
local_appdata().join("WhispAssist")
|
||||
}
|
||||
|
||||
fn settings_path() -> PathBuf {
|
||||
wa_root().join("settings.json")
|
||||
}
|
||||
|
||||
fn meetings_dir() -> PathBuf {
|
||||
wa_root().join("meetings")
|
||||
}
|
||||
|
||||
fn whisper_model_path() -> PathBuf {
|
||||
wa_root().join("models").join("ggml-base.en-q5_1.bin")
|
||||
}
|
||||
|
||||
fn default_settings() -> Settings {
|
||||
Settings {
|
||||
theme: "system".into(),
|
||||
storage_root: wa_root().display().to_string(),
|
||||
llm_provider: "ollama".into(),
|
||||
llm_endpoint: "http://localhost:11434".into(),
|
||||
llm_model: "llama3".into(),
|
||||
preferred_backend: "auto".into(),
|
||||
low_overhead: false,
|
||||
default_record: false,
|
||||
consent_acknowledged: false,
|
||||
sync_enabled: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn load_settings() -> Settings {
|
||||
std::fs::read_to_string(settings_path())
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_else(default_settings)
|
||||
}
|
||||
|
||||
fn save_settings(settings: &Settings) -> Result<(), WaError> {
|
||||
let path = settings_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| WaError::new("settings", e.to_string()))?;
|
||||
}
|
||||
let json = serde_json::to_string_pretty(settings)
|
||||
.map_err(|e| WaError::new("settings", e.to_string()))?;
|
||||
std::fs::write(path, json).map_err(|e| WaError::new("settings", e.to_string()))
|
||||
}
|
||||
|
||||
// ---- Recording lifecycle (Phase 1) ----
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn start_recording(_args: StartRecordingArgs) -> WaResult<MeetingId> {
|
||||
// T1.2/T1.3: create meeting row, start WASAPI capture, spawn transcription worker.
|
||||
todo!("Phase 1 — start_recording")
|
||||
pub async fn start_recording(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
args: StartRecordingArgs,
|
||||
) -> WaResult<MeetingId> {
|
||||
let mut guard = state.session.lock().await;
|
||||
if guard.is_some() {
|
||||
return Err(WaError::new(
|
||||
"recording",
|
||||
"a meeting is already in progress",
|
||||
));
|
||||
}
|
||||
|
||||
if args.record && !load_settings().consent_acknowledged {
|
||||
return Err(WaError::new(
|
||||
"consent",
|
||||
"recording consent has not been acknowledged yet",
|
||||
));
|
||||
}
|
||||
|
||||
let model_path = whisper_model_path();
|
||||
if !model_path.exists() {
|
||||
return Err(WaError::new(
|
||||
"transcription",
|
||||
format!(
|
||||
"whisper model not found at {}; run `npm run download-models` first",
|
||||
model_path.display()
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
let meeting_id = uuid::Uuid::new_v4().to_string();
|
||||
let meeting_dir = meetings_dir().join(&meeting_id);
|
||||
std::fs::create_dir_all(&meeting_dir).map_err(|e| WaError::new("storage", e.to_string()))?;
|
||||
let wav_path = meeting_dir.join("audio.wav");
|
||||
|
||||
// Bounded so a slow/stalled transcription worker can never back up the capture thread.
|
||||
let (frame_tx, frame_rx) = std::sync::mpsc::sync_channel::<Vec<f32>>(8);
|
||||
let capture = WasapiCapture
|
||||
.start(&wav_path, frame_tx)
|
||||
.map_err(|e| WaError::new("audio", e.to_string()))?;
|
||||
|
||||
let app_for_worker = app.clone();
|
||||
let meeting_id_for_worker = meeting_id.clone();
|
||||
let transcription_worker = std::thread::Builder::new()
|
||||
.name("wa-transcription".into())
|
||||
.spawn(
|
||||
move || match WhisperTranscriber::load(&model_path, BackendId::Cpu) {
|
||||
Ok(transcriber) => run_streaming_worker(&transcriber, frame_rx, |segment| {
|
||||
let _ = app_for_worker.emit(
|
||||
"transcript://segment",
|
||||
serde_json::json!({ "meetingId": meeting_id_for_worker, "segment": segment }),
|
||||
);
|
||||
}),
|
||||
Err(e) => tracing::error!("failed to load whisper model: {e}"),
|
||||
},
|
||||
)
|
||||
.map_err(|e| WaError::new("transcription", e.to_string()))?;
|
||||
|
||||
*guard = Some(RecordingSession {
|
||||
meeting_id: meeting_id.clone(),
|
||||
capture,
|
||||
retention: args.record,
|
||||
wav_path,
|
||||
started_at: std::time::Instant::now(),
|
||||
transcription_worker,
|
||||
});
|
||||
drop(guard);
|
||||
|
||||
crate::update_tray_tooltip(&app, "WhispAssist — recording");
|
||||
let _ = app.emit(
|
||||
"recording://state",
|
||||
serde_json::json!({ "meetingId": meeting_id, "state": "recording", "elapsedMs": 0 }),
|
||||
);
|
||||
Ok(meeting_id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn stop_recording(_meeting_id: MeetingId) -> WaResult<()> {
|
||||
// T1.3 + T4: finalize WAV, run diarization, persist, optional summary.
|
||||
todo!("Phase 1 — stop_recording")
|
||||
pub async fn stop_recording(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
meeting_id: MeetingId,
|
||||
) -> WaResult<()> {
|
||||
let mut guard = state.session.lock().await;
|
||||
let session = match guard.take() {
|
||||
Some(s) if s.meeting_id == meeting_id => s,
|
||||
Some(s) => {
|
||||
*guard = Some(s);
|
||||
return Err(WaError::new(
|
||||
"recording",
|
||||
"meeting_id does not match the active recording",
|
||||
));
|
||||
}
|
||||
None => {
|
||||
return Err(WaError::new(
|
||||
"recording",
|
||||
"no meeting is currently recording",
|
||||
))
|
||||
}
|
||||
};
|
||||
drop(guard);
|
||||
|
||||
let summary = WasapiCapture
|
||||
.stop(session.capture)
|
||||
.map_err(|e| WaError::new("audio", e.to_string()))?;
|
||||
// The capture thread has dropped its `FrameSink`; the transcription worker's
|
||||
// `recv()` now returns `Err` and the worker exits on its own — join to
|
||||
// guarantee it has fully drained the audio before we act on retention.
|
||||
let _ = session.transcription_worker.join();
|
||||
|
||||
let _ = app.emit(
|
||||
"transcript://finalized",
|
||||
serde_json::json!({ "meetingId": meeting_id, "segmentCount": serde_json::Value::Null }),
|
||||
);
|
||||
|
||||
// ADR-0009: delete the working WAV only after the transcript worker has
|
||||
// finished reading it, and only when retention is off.
|
||||
if !session.retention {
|
||||
let _ = std::fs::remove_file(&session.wav_path);
|
||||
}
|
||||
|
||||
crate::update_tray_tooltip(&app, "WhispAssist — idle");
|
||||
let _ = app.emit(
|
||||
"recording://state",
|
||||
serde_json::json!({ "meetingId": meeting_id, "state": "stopped", "elapsedMs": summary.duration_ms }),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn pause_recording(_meeting_id: MeetingId) -> WaResult<()> {
|
||||
todo!("Phase 1 — pause_recording")
|
||||
pub async fn pause_recording(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
meeting_id: MeetingId,
|
||||
) -> WaResult<()> {
|
||||
let guard = state.session.lock().await;
|
||||
let session = guard
|
||||
.as_ref()
|
||||
.filter(|s| s.meeting_id == meeting_id)
|
||||
.ok_or_else(|| WaError::new("recording", "no matching active recording"))?;
|
||||
WasapiCapture
|
||||
.pause(&session.capture)
|
||||
.map_err(|e| WaError::new("audio", e.to_string()))?;
|
||||
drop(guard);
|
||||
let _ = app.emit(
|
||||
"recording://state",
|
||||
serde_json::json!({ "meetingId": meeting_id, "state": "paused", "elapsedMs": 0 }),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn resume_recording(_meeting_id: MeetingId) -> WaResult<()> {
|
||||
todo!("Phase 1 — resume_recording")
|
||||
pub async fn resume_recording(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
meeting_id: MeetingId,
|
||||
) -> WaResult<()> {
|
||||
let guard = state.session.lock().await;
|
||||
let session = guard
|
||||
.as_ref()
|
||||
.filter(|s| s.meeting_id == meeting_id)
|
||||
.ok_or_else(|| WaError::new("recording", "no matching active recording"))?;
|
||||
WasapiCapture
|
||||
.resume(&session.capture)
|
||||
.map_err(|e| WaError::new("audio", e.to_string()))?;
|
||||
drop(guard);
|
||||
let _ = app.emit(
|
||||
"recording://state",
|
||||
serde_json::json!({ "meetingId": meeting_id, "state": "recording", "elapsedMs": 0 }),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Toggle audio retention mid-meeting (ADR-0009, FR-REC-1).
|
||||
#[tauri::command]
|
||||
pub async fn set_recording_retention(_meeting_id: MeetingId, _record: bool) -> WaResult<()> {
|
||||
todo!("Phase 1 — set_recording_retention")
|
||||
pub async fn set_recording_retention(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
meeting_id: MeetingId,
|
||||
record: bool,
|
||||
) -> WaResult<()> {
|
||||
if record && !load_settings().consent_acknowledged {
|
||||
return Err(WaError::new(
|
||||
"consent",
|
||||
"recording consent has not been acknowledged yet",
|
||||
));
|
||||
}
|
||||
let mut guard = state.session.lock().await;
|
||||
let session = guard
|
||||
.as_mut()
|
||||
.filter(|s| s.meeting_id == meeting_id)
|
||||
.ok_or_else(|| WaError::new("recording", "no matching active recording"))?;
|
||||
session.retention = record;
|
||||
drop(guard);
|
||||
let _ = app.emit(
|
||||
"recording://retention",
|
||||
serde_json::json!({ "meetingId": meeting_id, "record": record }),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Record the one-time recording-consent acknowledgment (FR-REC-2).
|
||||
#[tauri::command]
|
||||
pub async fn acknowledge_recording_consent() -> WaResult<()> {
|
||||
todo!("Phase 1 — acknowledge_recording_consent")
|
||||
let mut settings = load_settings();
|
||||
settings.consent_acknowledged = true;
|
||||
save_settings(&settings)
|
||||
}
|
||||
|
||||
// ---- Hardware (Phase 3) ----
|
||||
@@ -107,7 +345,10 @@ pub async fn set_llm_provider(_config: serde_json::Value) -> WaResult<()> {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn generate_summary(_meeting_id: MeetingId, _template_id: Option<String>) -> WaResult<()> {
|
||||
pub async fn generate_summary(
|
||||
_meeting_id: MeetingId,
|
||||
_template_id: Option<String>,
|
||||
) -> WaResult<()> {
|
||||
// Streams via "llm://token" / "llm://done".
|
||||
todo!("Phase 5 — generate_summary")
|
||||
}
|
||||
@@ -179,13 +420,18 @@ pub async fn sync_status(_meeting_id: Option<MeetingId>) -> WaResult<Vec<SyncJob
|
||||
// ---- Feature briefs + MCP server (Phase 10b, ADR-0011) ----
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn create_feature_brief(_meeting_id: MeetingId, _target_repo: Option<String>) -> WaResult<FeatureBrief> {
|
||||
pub async fn create_feature_brief(
|
||||
_meeting_id: MeetingId,
|
||||
_target_repo: Option<String>,
|
||||
) -> WaResult<FeatureBrief> {
|
||||
// T10.6: distill transcript → structured brief via the configured LlmProvider.
|
||||
todo!("Phase 10b — create_feature_brief")
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_feature_briefs(_meeting_id: Option<MeetingId>) -> WaResult<Vec<FeatureBriefInfo>> {
|
||||
pub async fn list_feature_briefs(
|
||||
_meeting_id: Option<MeetingId>,
|
||||
) -> WaResult<Vec<FeatureBriefInfo>> {
|
||||
todo!("Phase 10b — list_feature_briefs")
|
||||
}
|
||||
|
||||
@@ -207,7 +453,11 @@ pub async fn mcp_status() -> WaResult<serde_json::Value> {
|
||||
|
||||
/// Enable/disable the loopback MCP server; returns endpoint + token on enable (FR-MCP-1/6).
|
||||
#[tauri::command]
|
||||
pub async fn set_mcp_enabled(_enabled: bool, _transport: Option<String>, _port: Option<u16>) -> WaResult<serde_json::Value> {
|
||||
pub async fn set_mcp_enabled(
|
||||
_enabled: bool,
|
||||
_transport: Option<String>,
|
||||
_port: Option<u16>,
|
||||
) -> WaResult<serde_json::Value> {
|
||||
todo!("Phase 10b — set_mcp_enabled (loopback only, token)")
|
||||
}
|
||||
|
||||
@@ -225,12 +475,20 @@ pub async fn mcp_access_log(_limit: Option<u32>) -> WaResult<Vec<McpAccessEntry>
|
||||
// ---- Agent push / task-tracker handoff (Phase 10c, later) ----
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn run_agent(_brief_id: String, _tool: String, _repo_path: String) -> WaResult<serde_json::Value> {
|
||||
pub async fn run_agent(
|
||||
_brief_id: String,
|
||||
_tool: String,
|
||||
_repo_path: String,
|
||||
) -> WaResult<serde_json::Value> {
|
||||
todo!("Phase 10c — run_agent (claude|codex|opencode|copilot)")
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn create_issue_from_brief(_brief_id: String, _tracker: String, _assign_copilot: Option<bool>) -> WaResult<serde_json::Value> {
|
||||
pub async fn create_issue_from_brief(
|
||||
_brief_id: String,
|
||||
_tracker: String,
|
||||
_assign_copilot: Option<bool>,
|
||||
) -> WaResult<serde_json::Value> {
|
||||
todo!("Phase 10c — create_issue_from_brief")
|
||||
}
|
||||
|
||||
@@ -238,12 +496,22 @@ pub async fn create_issue_from_brief(_brief_id: String, _tracker: String, _assig
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_settings() -> WaResult<Settings> {
|
||||
todo!("Phase 2 — get_settings")
|
||||
Ok(load_settings())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_settings(_patch: serde_json::Value) -> WaResult<Settings> {
|
||||
todo!("Phase 2 — update_settings")
|
||||
pub async fn update_settings(patch: serde_json::Value) -> WaResult<Settings> {
|
||||
let mut current = serde_json::to_value(load_settings())
|
||||
.map_err(|e| WaError::new("settings", e.to_string()))?;
|
||||
if let (Some(base), Some(patch)) = (current.as_object_mut(), patch.as_object()) {
|
||||
for (key, value) in patch {
|
||||
base.insert(key.clone(), value.clone());
|
||||
}
|
||||
}
|
||||
let merged: Settings =
|
||||
serde_json::from_value(current).map_err(|e| WaError::new("settings", e.to_string()))?;
|
||||
save_settings(&merged)?;
|
||||
Ok(merged)
|
||||
}
|
||||
|
||||
/// Reports current egress + LLM endpoint so the UI can prove local-only handling (FR-SEC-2).
|
||||
|
||||
@@ -13,7 +13,10 @@ pub struct WaError {
|
||||
|
||||
impl WaError {
|
||||
pub fn new(kind: impl Into<String>, message: impl Into<String>) -> Self {
|
||||
Self { kind: kind.into(), message: message.into() }
|
||||
Self {
|
||||
kind: kind.into(),
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,17 +19,20 @@ pub mod storage;
|
||||
pub mod sync;
|
||||
pub mod transcription;
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::path::PathBuf;
|
||||
use std::thread::JoinHandle;
|
||||
use tauri::tray::TrayIcon;
|
||||
use tauri::Manager;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
/// Shared application state handed to every Tauri command via `tauri::State`.
|
||||
/// Service handles are `Arc`-wrapped so they can be cloned into worker tasks.
|
||||
///
|
||||
/// Phase 1 only needs the in-flight recording session. `hardware`/`store`/
|
||||
/// `llm`/`sync`/`mcp` service handles join this struct once their concrete
|
||||
/// implementations exist (Phase 2/3/5/9/10) — wiring them in ahead of that
|
||||
/// isn't possible yet (several don't have a concrete impl to construct) and
|
||||
/// would just be unused plumbing until then.
|
||||
pub struct AppState {
|
||||
pub hardware: Arc<dyn hardware::HardwareDetector>,
|
||||
pub store: Arc<dyn storage::Store>,
|
||||
pub llm: Arc<dyn llm::LlmProvider>,
|
||||
pub sync: Arc<dyn sync::SyncManager>,
|
||||
pub mcp: Arc<dyn mcp::McpServer>,
|
||||
/// The single in-flight recording, if any. Guarded so start/stop/pause are atomic.
|
||||
pub session: Mutex<Option<RecordingSession>>,
|
||||
}
|
||||
@@ -37,16 +40,35 @@ pub struct AppState {
|
||||
/// Tracks the currently-recording meeting and its worker handles.
|
||||
pub struct RecordingSession {
|
||||
pub meeting_id: models::MeetingId,
|
||||
// capture handle, transcription worker join handle, etc. — populated in Phase 1.
|
||||
pub capture: audio::CaptureHandle,
|
||||
/// Audio retention for this meeting (ADR-0009); toggle-able mid-meeting.
|
||||
pub retention: bool,
|
||||
pub wav_path: PathBuf,
|
||||
pub started_at: std::time::Instant,
|
||||
pub transcription_worker: JoinHandle<()>,
|
||||
}
|
||||
|
||||
/// Wraps the tray icon so it can be looked up from commands to update its
|
||||
/// tooltip on recording state changes (FR-CAP-4).
|
||||
pub struct TrayHandle(pub TrayIcon);
|
||||
|
||||
/// Build state, register commands/events, and run the app.
|
||||
pub fn run() {
|
||||
tracing_subscriber::fmt().with_env_filter("info").init();
|
||||
|
||||
// NOTE: concrete service implementations are constructed here once they exist.
|
||||
// The skeleton wires the command surface; `todo!()`s mark per-phase tasks.
|
||||
tauri::Builder::default()
|
||||
.manage(AppState {
|
||||
session: Mutex::new(None),
|
||||
})
|
||||
.setup(|app| {
|
||||
let icon = tauri::image::Image::from_bytes(include_bytes!("../icons/tray.png"))?;
|
||||
let tray = tauri::tray::TrayIconBuilder::new()
|
||||
.icon(icon)
|
||||
.tooltip("WhispAssist — idle")
|
||||
.build(app)?;
|
||||
app.manage(TrayHandle(tray));
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::start_recording,
|
||||
commands::stop_recording,
|
||||
@@ -91,3 +113,10 @@ pub fn run() {
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running WhispAssist");
|
||||
}
|
||||
|
||||
/// Used by `commands.rs` to keep the tray tooltip honest about capture state (FR-CAP-4).
|
||||
pub(crate) fn update_tray_tooltip(app: &tauri::AppHandle, text: &str) {
|
||||
if let Some(tray) = app.try_state::<TrayHandle>() {
|
||||
let _ = tray.0.set_tooltip(Some(text));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,7 +76,11 @@ pub trait McpServer: Send + Sync {
|
||||
/// Distills a transcript into an agent-ready spec (FR-MCP-4) using the configured LlmProvider.
|
||||
#[async_trait]
|
||||
pub trait FeatureBriefBuilder: Send + Sync {
|
||||
async fn build(&self, meeting_id: &MeetingId, target_repo: Option<&str>) -> Result<FeatureBrief, BriefError>;
|
||||
async fn build(
|
||||
&self,
|
||||
meeting_id: &MeetingId,
|
||||
target_repo: Option<&str>,
|
||||
) -> Result<FeatureBrief, BriefError>;
|
||||
}
|
||||
|
||||
/// Default rmcp-backed server (feature `mcp`).
|
||||
@@ -97,10 +101,22 @@ impl McpServer for RmcpServer {
|
||||
fn tools(&self) -> Vec<McpToolDescriptor> {
|
||||
// T10.5: the tools an agent can call. Tools-first for Copilot compatibility.
|
||||
vec![
|
||||
McpToolDescriptor { name: "list_recent_meetings", description: "Recent meetings (scoped)." },
|
||||
McpToolDescriptor { name: "get_transcript", description: "Transcript for a meeting (scoped)." },
|
||||
McpToolDescriptor { name: "get_action_items", description: "Action items for a meeting." },
|
||||
McpToolDescriptor { name: "get_feature_brief", description: "Agent-ready spec distilled from a meeting." },
|
||||
McpToolDescriptor {
|
||||
name: "list_recent_meetings",
|
||||
description: "Recent meetings (scoped).",
|
||||
},
|
||||
McpToolDescriptor {
|
||||
name: "get_transcript",
|
||||
description: "Transcript for a meeting (scoped).",
|
||||
},
|
||||
McpToolDescriptor {
|
||||
name: "get_action_items",
|
||||
description: "Action items for a meeting.",
|
||||
},
|
||||
McpToolDescriptor {
|
||||
name: "get_feature_brief",
|
||||
description: "Agent-ready spec distilled from a meeting.",
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,8 @@ pub trait NotesRenderer: Send + Sync {
|
||||
summary_md: Option<&str>,
|
||||
) -> String;
|
||||
|
||||
fn export(&self, markdown: &str, dest: &Path, fmt: ExportFormat) -> Result<PathBuf, NotesError>;
|
||||
fn export(&self, markdown: &str, dest: &Path, fmt: ExportFormat)
|
||||
-> Result<PathBuf, NotesError>;
|
||||
}
|
||||
|
||||
pub struct MarkdownNotes;
|
||||
@@ -48,7 +49,12 @@ impl NotesRenderer for MarkdownNotes {
|
||||
// T2.4: resolve speaker names, group dialogue, prepend summary section.
|
||||
todo!("Phase 2 — assemble Markdown")
|
||||
}
|
||||
fn export(&self, _markdown: &str, _dest: &Path, _fmt: ExportFormat) -> Result<PathBuf, NotesError> {
|
||||
fn export(
|
||||
&self,
|
||||
_markdown: &str,
|
||||
_dest: &Path,
|
||||
_fmt: ExportFormat,
|
||||
) -> Result<PathBuf, NotesError> {
|
||||
// T2.6 (.md/bundle); T8.4 (pdf/docx via local conversion).
|
||||
todo!("Phase 2/8 — export notes")
|
||||
}
|
||||
|
||||
@@ -31,7 +31,10 @@ pub struct Retention {
|
||||
pub trait Store: Send + Sync {
|
||||
async fn create_meeting(&self, m: NewMeeting) -> Result<MeetingId, StoreError>;
|
||||
async fn finalize_meeting(&self, id: &MeetingId) -> Result<(), StoreError>;
|
||||
async fn list_meetings(&self, query: Option<String>) -> Result<Vec<MeetingListItem>, StoreError>;
|
||||
async fn list_meetings(
|
||||
&self,
|
||||
query: Option<String>,
|
||||
) -> Result<Vec<MeetingListItem>, StoreError>;
|
||||
async fn delete_meeting(&self, id: &MeetingId) -> Result<(), StoreError>;
|
||||
async fn update_notes(&self, id: &MeetingId, markdown: &str) -> Result<(), StoreError>;
|
||||
/// Full-text search across transcripts + notes (Phase 8, FR-SEARCH-1).
|
||||
@@ -53,7 +56,10 @@ impl Store for SqliteStore {
|
||||
async fn finalize_meeting(&self, _id: &MeetingId) -> Result<(), StoreError> {
|
||||
todo!("Phase 2 — persist transcript.json + metadata")
|
||||
}
|
||||
async fn list_meetings(&self, _query: Option<String>) -> Result<Vec<MeetingListItem>, StoreError> {
|
||||
async fn list_meetings(
|
||||
&self,
|
||||
_query: Option<String>,
|
||||
) -> Result<Vec<MeetingListItem>, StoreError> {
|
||||
todo!("Phase 2 — list meetings")
|
||||
}
|
||||
async fn delete_meeting(&self, _id: &MeetingId) -> Result<(), StoreError> {
|
||||
|
||||
@@ -41,7 +41,12 @@ pub trait SyncTarget: Send + Sync {
|
||||
/// True if a file with this hash already exists remotely (skip-if-unchanged).
|
||||
async fn exists(&self, remote_path: &str, sha256: &str) -> Result<bool, SyncError>;
|
||||
/// Upload a file; resumable/chunked for large artifacts.
|
||||
async fn put(&self, local: &Path, remote_path: &str, prog: ProgressSink) -> Result<(), SyncError>;
|
||||
async fn put(
|
||||
&self,
|
||||
local: &Path,
|
||||
remote_path: &str,
|
||||
prog: ProgressSink,
|
||||
) -> Result<(), SyncError>;
|
||||
}
|
||||
|
||||
/// Owns the durable queue, retry/backoff, credential resolution, TLS enforcement,
|
||||
@@ -49,7 +54,11 @@ pub trait SyncTarget: Send + Sync {
|
||||
#[async_trait]
|
||||
pub trait SyncManager: Send + Sync {
|
||||
/// Enqueue a meeting's selected artifacts for one or all enabled targets.
|
||||
async fn enqueue_meeting(&self, meeting_id: &MeetingId, target_id: Option<&str>) -> Result<(), SyncError>;
|
||||
async fn enqueue_meeting(
|
||||
&self,
|
||||
meeting_id: &MeetingId,
|
||||
target_id: Option<&str>,
|
||||
) -> Result<(), SyncError>;
|
||||
/// Drive pending jobs (called on finalize, on startup, and on a low-frequency timer).
|
||||
async fn pump(&self) -> Result<(), SyncError>;
|
||||
async fn status(&self, meeting_id: Option<&MeetingId>) -> Result<Vec<SyncJobInfo>, SyncError>;
|
||||
@@ -89,7 +98,12 @@ impl SyncTarget for WebDavTarget {
|
||||
async fn exists(&self, _remote_path: &str, _sha256: &str) -> Result<bool, SyncError> {
|
||||
todo!("Phase 9 — WebDAV exists / hash compare")
|
||||
}
|
||||
async fn put(&self, _local: &Path, _remote_path: &str, _prog: ProgressSink) -> Result<(), SyncError> {
|
||||
async fn put(
|
||||
&self,
|
||||
_local: &Path,
|
||||
_remote_path: &str,
|
||||
_prog: ProgressSink,
|
||||
) -> Result<(), SyncError> {
|
||||
// T9.2/T9.5: PUT (chunked for large files); stream progress.
|
||||
todo!("Phase 9 — WebDAV upload")
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
use crate::models::{BackendId, TranscriptSegment};
|
||||
use std::path::Path;
|
||||
use std::sync::mpsc::{Receiver, Sender};
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum TrxError {
|
||||
@@ -22,7 +23,7 @@ pub struct AudioWindow {
|
||||
}
|
||||
|
||||
/// Where produced segments are delivered (interim then final).
|
||||
pub type SegmentSink = std::sync::mpsc::Sender<TranscriptSegment>;
|
||||
pub type SegmentSink = Sender<TranscriptSegment>;
|
||||
|
||||
pub trait Transcriber: Send + Sync {
|
||||
fn load(model: &Path, backend: BackendId) -> Result<Self, TrxError>
|
||||
@@ -36,22 +37,151 @@ pub trait Transcriber: Send + Sync {
|
||||
|
||||
/// whisper.cpp-backed transcriber (CPU baseline; GPU via Cargo features).
|
||||
#[cfg(feature = "cpu-transcription")]
|
||||
pub struct WhisperTranscriber;
|
||||
pub struct WhisperTranscriber {
|
||||
ctx: whisper_rs::WhisperContext,
|
||||
next_id: std::sync::atomic::AtomicU64,
|
||||
}
|
||||
|
||||
#[cfg(feature = "cpu-transcription")]
|
||||
impl Transcriber for WhisperTranscriber {
|
||||
fn load(_model: &Path, _backend: BackendId) -> Result<Self, TrxError> {
|
||||
// T1.5 / T3.3: init whisper-rs with the backend's acceleration features.
|
||||
todo!("Phase 1 — load whisper model")
|
||||
fn load(model: &Path, _backend: BackendId) -> Result<Self, TrxError> {
|
||||
let ctx = whisper_rs::WhisperContext::new_with_params(
|
||||
model,
|
||||
whisper_rs::WhisperContextParameters::default(),
|
||||
)
|
||||
.map_err(|e| TrxError::Load(e.to_string()))?;
|
||||
Ok(Self {
|
||||
ctx,
|
||||
next_id: std::sync::atomic::AtomicU64::new(0),
|
||||
})
|
||||
}
|
||||
fn transcribe_stream(&self, _audio: AudioWindow, _out: SegmentSink) -> Result<(), TrxError> {
|
||||
todo!("Phase 1 — streaming transcription")
|
||||
|
||||
fn transcribe_stream(&self, audio: AudioWindow, out: SegmentSink) -> Result<(), TrxError> {
|
||||
let mut state = self
|
||||
.ctx
|
||||
.create_state()
|
||||
.map_err(|e| TrxError::Inference(e.to_string()))?;
|
||||
|
||||
// best_of: 1 — fastest greedy decode, matching the CPU/typical-laptop
|
||||
// baseline (NFR-PERF-3); real-time windowed transcription favors low
|
||||
// latency per window over the extra accuracy of a wider search.
|
||||
let mut params =
|
||||
whisper_rs::FullParams::new(whisper_rs::SamplingStrategy::Greedy { best_of: 1 });
|
||||
params.set_n_threads(available_threads());
|
||||
params.set_translate(false);
|
||||
params.set_print_progress(false);
|
||||
params.set_print_realtime(false);
|
||||
params.set_print_timestamps(false);
|
||||
params.set_suppress_blank(true);
|
||||
// Each call already gets exactly one fixed-size window (not a full 30s
|
||||
// clip); without this, whisper.cpp's segment-boundary heuristic hits
|
||||
// "single timestamp ending" on short buffers and silently drops the
|
||||
// whole window even when decoding succeeded (verified against real
|
||||
// audio: text decoded correctly but zero segments came out until this
|
||||
// was set — see whisper.cpp's own `stream` example, which sets this
|
||||
// for the same reason).
|
||||
params.set_single_segment(true);
|
||||
|
||||
state
|
||||
.full(params, &audio.samples)
|
||||
.map_err(|e| TrxError::Inference(e.to_string()))?;
|
||||
|
||||
for seg in state.as_iter() {
|
||||
let text = seg.to_str_lossy().unwrap_or_default().trim().to_string();
|
||||
if text.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// Whisper timestamps are centiseconds (10ms units).
|
||||
let start_ms = audio.offset_ms + seg.start_timestamp().max(0) as u64 * 10;
|
||||
let end_ms = audio.offset_ms + seg.end_timestamp().max(0) as u64 * 10;
|
||||
let segment = TranscriptSegment {
|
||||
id: self
|
||||
.next_id
|
||||
.fetch_add(1, std::sync::atomic::Ordering::SeqCst),
|
||||
start_ms,
|
||||
end_ms,
|
||||
// Diarization lands in Phase 4; every segment is provisionally "S1" until then.
|
||||
speaker: "S1".to_string(),
|
||||
text,
|
||||
confidence: Some((1.0 - seg.no_speech_probability()).clamp(0.0, 1.0)),
|
||||
interim: false,
|
||||
};
|
||||
if out.send(segment).is_err() {
|
||||
break; // receiver gone (meeting stopped) — nothing left to do
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn transcribe_file(&self, _wav: &Path) -> Result<Vec<TranscriptSegment>, TrxError> {
|
||||
todo!("Phase 3 — batch transcription")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "cpu-transcription")]
|
||||
fn available_threads() -> std::ffi::c_int {
|
||||
std::thread::available_parallelism()
|
||||
.map(|n| n.get() as std::ffi::c_int)
|
||||
.unwrap_or(4)
|
||||
}
|
||||
|
||||
/// ONNX Runtime + DirectML transcriber for the NPU tier (Phase 3).
|
||||
#[cfg(feature = "directml")]
|
||||
pub struct OnnxNpuTranscriber;
|
||||
|
||||
/// Streaming window worker (Phase 1, T1.5/T1.6): accumulates raw 16kHz-mono
|
||||
/// chunks from the `audio` service into fixed-size, **non-overlapping** windows
|
||||
/// and runs one `transcribe_stream` pass per window as it fills, forwarding
|
||||
/// each produced segment to `on_segment` (e.g. a Tauri event emit).
|
||||
///
|
||||
/// `transcribe_stream` takes a `SegmentSink` per the `Transcriber` trait (so a
|
||||
/// future async/threaded engine can push mid-inference), but whisper.cpp's
|
||||
/// `full()` call is synchronous — by the time a window's `transcribe_stream`
|
||||
/// call returns, every segment it produced is already sitting in a fresh
|
||||
/// per-window channel, so this drains it inline rather than needing a second
|
||||
/// long-lived thread just to bridge segments out.
|
||||
///
|
||||
/// True incremental/partial-word streaming (and window overlap for continuity)
|
||||
/// are out of scope for Phase 1: whisper.cpp transcribes each window from
|
||||
/// scratch, so an overlapping window would re-emit the overlapped words a
|
||||
/// second time with no stitching logic to merge them — a worse rough edge for
|
||||
/// a live transcript than the occasional word clipped at a window boundary.
|
||||
/// "Near real time" (FR-TRX-2) is met by short (~4s) windows; `interim` stays
|
||||
/// `false` for every segment produced here.
|
||||
#[cfg(feature = "cpu-transcription")]
|
||||
pub fn run_streaming_worker<T, F>(transcriber: &T, frame_rx: Receiver<Vec<f32>>, mut on_segment: F)
|
||||
where
|
||||
T: Transcriber,
|
||||
F: FnMut(TranscriptSegment),
|
||||
{
|
||||
const SAMPLE_RATE: usize = 16_000;
|
||||
const WINDOW_SECS: f32 = 4.0;
|
||||
let window_len = (WINDOW_SECS * SAMPLE_RATE as f32) as usize;
|
||||
|
||||
let mut buf: Vec<f32> = Vec::new();
|
||||
let mut offset_ms: u64 = 0;
|
||||
|
||||
let run_window = |transcriber: &T, samples: Vec<f32>, offset_ms: u64, on_segment: &mut F| {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let window = AudioWindow { samples, offset_ms };
|
||||
if let Err(e) = transcriber.transcribe_stream(window, tx) {
|
||||
tracing::warn!("transcription window failed: {e}");
|
||||
}
|
||||
while let Ok(segment) = rx.try_recv() {
|
||||
on_segment(segment);
|
||||
}
|
||||
};
|
||||
|
||||
while let Ok(chunk) = frame_rx.recv() {
|
||||
buf.extend_from_slice(&chunk);
|
||||
while buf.len() >= window_len {
|
||||
let samples: Vec<f32> = buf.drain(..window_len).collect();
|
||||
run_window(transcriber, samples, offset_ms, &mut on_segment);
|
||||
offset_ms += (window_len as u64 * 1000) / SAMPLE_RATE as u64;
|
||||
}
|
||||
}
|
||||
// Final partial window on stop, if there's enough audio to be worth a pass.
|
||||
if buf.len() > SAMPLE_RATE / 2 {
|
||||
run_window(transcriber, buf, offset_ms, &mut on_segment);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,14 +5,40 @@
|
||||
import TranscriptNotes from "./lib/views/TranscriptNotes.svelte";
|
||||
import SummaryPanel from "./lib/views/SummaryPanel.svelte";
|
||||
import Settings from "./lib/views/Settings.svelte";
|
||||
import ConsentNotice from "./lib/components/ConsentNotice.svelte";
|
||||
import { recording } from "./lib/stores/recording.svelte";
|
||||
import { settings } from "./lib/stores/settings.svelte";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
let showSettings = $state(false);
|
||||
let showConsent = $state(false);
|
||||
|
||||
onMount(() => {
|
||||
recording.init();
|
||||
settings.load();
|
||||
});
|
||||
|
||||
function startRecording() {
|
||||
recording.start(undefined, settings.settings.default_record);
|
||||
}
|
||||
|
||||
// Mid-meeting retention toggle (FR-REC-1); gates on the same one-time
|
||||
// consent notice as the Settings default-record toggle (FR-REC-2).
|
||||
function onToggleRetention(e: Event) {
|
||||
const on = (e.target as HTMLInputElement).checked;
|
||||
if (on && !settings.settings.consent_acknowledged) {
|
||||
showConsent = true;
|
||||
(e.target as HTMLInputElement).checked = false;
|
||||
return;
|
||||
}
|
||||
recording.setRetention(on);
|
||||
}
|
||||
|
||||
async function acceptConsentAndRecord() {
|
||||
await settings.acknowledgeConsent();
|
||||
await recording.setRetention(true);
|
||||
showConsent = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="app" data-theme="system">
|
||||
@@ -21,14 +47,29 @@
|
||||
<span class="muted">local · private</span>
|
||||
<div class="spacer"></div>
|
||||
{#if recording.state === "idle"}
|
||||
<button onclick={() => recording.start()}>● Record</button>
|
||||
<button onclick={startRecording}>● Record</button>
|
||||
{:else}
|
||||
<button onclick={() => recording.stop()}>■ Stop</button>
|
||||
<span class="rec">Recording…</span>
|
||||
<label class="retention" title="Save audio as .wav for this meeting">
|
||||
<input type="checkbox" checked={recording.retention} onchange={onToggleRetention} />
|
||||
<span>{recording.retention ? "saving" : "not saved"}</span>
|
||||
</label>
|
||||
{/if}
|
||||
<button class="icon" aria-label="Settings" title="Settings" onclick={() => (showSettings = true)}>⚙</button>
|
||||
<button
|
||||
class="icon"
|
||||
aria-label="Settings"
|
||||
title="Settings"
|
||||
onclick={() => (showSettings = true)}>⚙</button
|
||||
>
|
||||
</header>
|
||||
|
||||
{#if showConsent}
|
||||
<div class="consent-overlay" role="dialog" aria-modal="true" aria-label="Recording consent">
|
||||
<ConsentNotice onAccept={acceptConsentAndRecord} onCancel={() => (showConsent = false)} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showSettings}
|
||||
<Settings onClose={() => (showSettings = false)} />
|
||||
{/if}
|
||||
@@ -54,15 +95,75 @@
|
||||
--muted: #9aa0a6;
|
||||
--border: #242832;
|
||||
}
|
||||
.app { display: flex; flex-direction: column; height: 100vh; background: var(--bg); color: var(--fg); font-family: system-ui, sans-serif; }
|
||||
.bar { display: flex; align-items: center; gap: 0.75rem; padding: 0.5rem 1rem; border-bottom: 1px solid var(--border); }
|
||||
.spacer { flex: 1; }
|
||||
.muted { color: var(--muted); font-size: 0.85rem; }
|
||||
.rec { color: #e11d48; font-size: 0.85rem; }
|
||||
.icon { background: transparent; border: 1px solid var(--border); color: var(--fg); border-radius: 6px; padding: 0.2rem 0.5rem; cursor: pointer; }
|
||||
.panes { display: grid; grid-template-columns: 260px 1fr 320px; flex: 1; min-height: 0; }
|
||||
.left, .right { border-color: var(--border); overflow: auto; }
|
||||
.left { border-right: 1px solid var(--border); }
|
||||
.right { border-left: 1px solid var(--border); }
|
||||
.center { overflow: auto; }
|
||||
.app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
.bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.rec {
|
||||
color: #e11d48;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.retention {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
.consent-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
z-index: 60;
|
||||
}
|
||||
.icon {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--fg);
|
||||
border-radius: 6px;
|
||||
padding: 0.2rem 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.panes {
|
||||
display: grid;
|
||||
grid-template-columns: 260px 1fr 320px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.left,
|
||||
.right {
|
||||
border-color: var(--border);
|
||||
overflow: auto;
|
||||
}
|
||||
.left {
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
.right {
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
.center {
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -126,8 +126,12 @@ export const api = {
|
||||
|
||||
llmStatus: () => invoke("llm_status"),
|
||||
// provider ∈ ollama|custom|anthropic|openai|off; apiKey (hosted) → OS credential store (ADR-0011).
|
||||
setLlmProvider: (config: { provider: string; endpoint?: string; model?: string; apiKey?: string }) =>
|
||||
invoke<void>("set_llm_provider", { config }),
|
||||
setLlmProvider: (config: {
|
||||
provider: string;
|
||||
endpoint?: string;
|
||||
model?: string;
|
||||
apiKey?: string;
|
||||
}) => invoke<void>("set_llm_provider", { config }),
|
||||
generateSummary: (meetingId: MeetingId, templateId?: string) =>
|
||||
invoke<void>("generate_summary", { meetingId, templateId }),
|
||||
|
||||
@@ -135,7 +139,8 @@ export const api = {
|
||||
|
||||
// Sync (ADR-0010) — off by default; secrets never returned by listSyncTargets.
|
||||
listSyncTargets: () => invoke<SyncTargetInfo[]>("list_sync_targets"),
|
||||
addSyncTarget: (config: SyncTargetConfig) => invoke<SyncTargetInfo>("add_sync_target", { config }),
|
||||
addSyncTarget: (config: SyncTargetConfig) =>
|
||||
invoke<SyncTargetInfo>("add_sync_target", { config }),
|
||||
updateSyncTarget: (config: SyncTargetConfig & { id: string }) =>
|
||||
invoke<SyncTargetInfo>("update_sync_target", { config }),
|
||||
removeSyncTarget: (id: string) => invoke<void>("remove_sync_target", { id }),
|
||||
@@ -164,13 +169,17 @@ export const api = {
|
||||
invoke<void>("set_mcp_scope", { expose, exposeRecordings }),
|
||||
mcpAccessLog: (limit?: number) => invoke<McpAccessEntry[]>("mcp_access_log", { limit }),
|
||||
// Layer 3 (later) push handoff.
|
||||
runAgent: (briefId: string, tool: "claude" | "codex" | "opencode" | "copilot", repoPath: string) =>
|
||||
invoke<{ ok: boolean }>("run_agent", { briefId, tool, repoPath }),
|
||||
runAgent: (
|
||||
briefId: string,
|
||||
tool: "claude" | "codex" | "opencode" | "copilot",
|
||||
repoPath: string,
|
||||
) => invoke<{ ok: boolean }>("run_agent", { briefId, tool, repoPath }),
|
||||
createIssueFromBrief: (briefId: string, tracker: "github", assignCopilot?: boolean) =>
|
||||
invoke<{ url: string }>("create_issue_from_brief", { briefId, tracker, assignCopilot }),
|
||||
|
||||
getSettings: () => invoke<AppSettings>("get_settings"),
|
||||
updateSettings: (patch: Partial<AppSettings>) => invoke<AppSettings>("update_settings", { patch }),
|
||||
updateSettings: (patch: Partial<AppSettings>) =>
|
||||
invoke<AppSettings>("update_settings", { patch }),
|
||||
privacySelfCheck: () => invoke("privacy_self_check"),
|
||||
};
|
||||
|
||||
@@ -178,18 +187,31 @@ export const api = {
|
||||
export const events = {
|
||||
onRecordingState: (cb: (p: unknown) => void): Promise<UnlistenFn> =>
|
||||
listen("recording://state", (e) => cb(e.payload)),
|
||||
onRetention: (cb: (p: { meetingId: string; record: boolean }) => void): Promise<UnlistenFn> =>
|
||||
listen("recording://retention", (e) => cb(e.payload as never)),
|
||||
onLevel: (cb: (p: unknown) => void): Promise<UnlistenFn> =>
|
||||
listen("recording://level", (e) => cb(e.payload)),
|
||||
onSegment: (cb: (p: { meetingId: string; segment: TranscriptSegment }) => void): Promise<UnlistenFn> =>
|
||||
listen("transcript://segment", (e) => cb(e.payload as never)),
|
||||
onSegment: (
|
||||
cb: (p: { meetingId: string; segment: TranscriptSegment }) => void,
|
||||
): Promise<UnlistenFn> => listen("transcript://segment", (e) => cb(e.payload as never)),
|
||||
onLlmToken: (cb: (p: { meetingId: string; text: string }) => void): Promise<UnlistenFn> =>
|
||||
listen("llm://token", (e) => cb(e.payload as never)),
|
||||
onSyncJob: (cb: (p: { jobId: string; meetingId: string; targetId: string; artifact: string; status: string; bytesSent: number; bytesTotal: number }) => void): Promise<UnlistenFn> =>
|
||||
listen("sync://job", (e) => cb(e.payload as never)),
|
||||
onSyncJob: (
|
||||
cb: (p: {
|
||||
jobId: string;
|
||||
meetingId: string;
|
||||
targetId: string;
|
||||
artifact: string;
|
||||
status: string;
|
||||
bytesSent: number;
|
||||
bytesTotal: number;
|
||||
}) => void,
|
||||
): Promise<UnlistenFn> => listen("sync://job", (e) => cb(e.payload as never)),
|
||||
onMcpAccess: (cb: (p: McpAccessEntry & { client?: string }) => void): Promise<UnlistenFn> =>
|
||||
listen("mcp://access", (e) => cb(e.payload as never)),
|
||||
onAgentProgress: (cb: (p: { briefId: string; tool: string; line: string }) => void): Promise<UnlistenFn> =>
|
||||
listen("agent://progress", (e) => cb(e.payload as never)),
|
||||
onAgentProgress: (
|
||||
cb: (p: { briefId: string; tool: string; line: string }) => void,
|
||||
): Promise<UnlistenFn> => listen("agent://progress", (e) => cb(e.payload as never)),
|
||||
onError: (cb: (p: { kind: string; message: string }) => void): Promise<UnlistenFn> =>
|
||||
listen("error", (e) => cb(e.payload as never)),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
<script lang="ts">
|
||||
// One-time recording-consent notice (ADR-0009, FR-REC-2). Shared by the
|
||||
// Settings "Record by default" toggle and the mid-meeting retention toggle
|
||||
// so both gate on the same copy and the same acknowledgment call.
|
||||
let { onAccept, onCancel }: { onAccept: () => void; onCancel: () => void } = $props();
|
||||
</script>
|
||||
|
||||
<div class="consent">
|
||||
<p>
|
||||
<strong>Before you record:</strong> recording conversations without the consent of participants may
|
||||
be illegal in your region. Check your local recording laws. This is a caution, not legal advice.
|
||||
</p>
|
||||
<div class="actions">
|
||||
<button class="primary" onclick={onAccept}>I understand — enable recording</button>
|
||||
<button onclick={onCancel}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.consent {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 0.8rem;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.6rem;
|
||||
}
|
||||
button.primary {
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 0.4rem 0.8rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
button {
|
||||
font: inherit;
|
||||
}
|
||||
</style>
|
||||
@@ -8,6 +8,8 @@ class RecordingStore {
|
||||
state = $state<"idle" | "recording" | "paused">("idle");
|
||||
elapsedMs = $state(0);
|
||||
segments = $state<TranscriptSegment[]>([]);
|
||||
/** Whether audio is being retained as .wav for the in-flight meeting (ADR-0009). */
|
||||
retention = $state(false);
|
||||
|
||||
async init() {
|
||||
await events.onRecordingState((p) => {
|
||||
@@ -15,6 +17,9 @@ class RecordingStore {
|
||||
this.state = e.state === "stopped" ? "idle" : e.state;
|
||||
this.elapsedMs = e.elapsedMs ?? this.elapsedMs;
|
||||
});
|
||||
await events.onRetention((p) => {
|
||||
this.retention = p.record;
|
||||
});
|
||||
await events.onSegment(({ segment }) => {
|
||||
// Replace an interim segment with the same id, else append.
|
||||
const i = this.segments.findIndex((s) => s.id === segment.id);
|
||||
@@ -23,9 +28,10 @@ class RecordingStore {
|
||||
});
|
||||
}
|
||||
|
||||
async start(title?: string) {
|
||||
async start(title?: string, record = false) {
|
||||
this.segments = [];
|
||||
this.meetingId = await api.startRecording(title);
|
||||
this.retention = record;
|
||||
this.meetingId = await api.startRecording(title, undefined, record);
|
||||
this.state = "recording";
|
||||
}
|
||||
|
||||
@@ -33,6 +39,13 @@ class RecordingStore {
|
||||
if (this.meetingId) await api.stopRecording(this.meetingId);
|
||||
this.state = "idle";
|
||||
}
|
||||
|
||||
/** Toggle audio retention mid-meeting (FR-REC-1); caller must have gated consent already. */
|
||||
async setRetention(record: boolean) {
|
||||
if (!this.meetingId) return;
|
||||
await api.setRecordingRetention(this.meetingId, record);
|
||||
this.retention = record;
|
||||
}
|
||||
}
|
||||
|
||||
export const recording = new RecordingStore();
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
// is fully demonstrable. Replace nothing here when Phase 9 lands — it just starts persisting.
|
||||
import { onMount } from "svelte";
|
||||
import { settings } from "../stores/settings.svelte";
|
||||
import ConsentNotice from "../components/ConsentNotice.svelte";
|
||||
import type { SyncKind, SyncTargetConfig } from "../api";
|
||||
|
||||
let { onClose }: { onClose: () => void } = $props();
|
||||
@@ -56,9 +57,6 @@
|
||||
form = blankForm(k);
|
||||
testResult = null;
|
||||
}
|
||||
function isThirdParty(k: SyncKind) {
|
||||
return k !== "webdav";
|
||||
}
|
||||
async function testConnection() {
|
||||
const r = await settings.test(form);
|
||||
testResult = (r.ok ? "✓ " : "✗ ") + r.message;
|
||||
@@ -79,21 +77,30 @@
|
||||
<header>
|
||||
<strong>Settings</strong>
|
||||
<nav>
|
||||
<button class:active={section === "recording"} onclick={() => (section = "recording")}>Recording</button>
|
||||
<button class:active={section === "recording"} onclick={() => (section = "recording")}
|
||||
>Recording</button
|
||||
>
|
||||
<button class:active={section === "sync"} onclick={() => (section = "sync")}>Sync</button>
|
||||
</nav>
|
||||
<button class="close" onclick={onClose} aria-label="Close">✕</button>
|
||||
</header>
|
||||
|
||||
{#if settings.backendStub}
|
||||
<p class="stub">Preview mode — the backend for these settings isn't implemented yet (Phase 9). Changes are kept in the UI only.</p>
|
||||
<p class="stub">
|
||||
Preview mode — the backend for these settings isn't implemented yet (Phase 9). Changes are
|
||||
kept in the UI only.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if section === "recording"}
|
||||
<section>
|
||||
<h3>Recording</h3>
|
||||
<label class="row">
|
||||
<input type="checkbox" checked={settings.settings.default_record} onchange={onToggleDefaultRecord} />
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.settings.default_record}
|
||||
onchange={onToggleDefaultRecord}
|
||||
/>
|
||||
<span>Record meetings by default <em>(save audio as <code>.wav</code>)</em></span>
|
||||
</label>
|
||||
<p class="muted">
|
||||
@@ -101,21 +108,13 @@
|
||||
the meeting is finalized. You can also toggle recording per meeting.
|
||||
</p>
|
||||
<p class="muted">
|
||||
Consent: {settings.settings.consent_acknowledged ? "acknowledged" : "not yet acknowledged"}.
|
||||
Consent: {settings.settings.consent_acknowledged
|
||||
? "acknowledged"
|
||||
: "not yet acknowledged"}.
|
||||
</p>
|
||||
|
||||
{#if showConsent}
|
||||
<div class="consent">
|
||||
<p>
|
||||
<strong>Before you record:</strong> recording conversations without the consent of
|
||||
participants may be illegal in your region. Check your local recording laws. This is a
|
||||
caution, not legal advice.
|
||||
</p>
|
||||
<div class="actions">
|
||||
<button class="primary" onclick={acceptConsent}>I understand — enable recording</button>
|
||||
<button onclick={() => (showConsent = false)}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
<ConsentNotice onAccept={acceptConsent} onCancel={() => (showConsent = false)} />
|
||||
{/if}
|
||||
</section>
|
||||
{:else}
|
||||
@@ -142,12 +141,20 @@
|
||||
{#each settings.targets as t (t.id)}
|
||||
<li>
|
||||
<label class="tg">
|
||||
<input type="checkbox" checked={t.enabled} onchange={() => settings.toggleTargetEnabled(t)} />
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={t.enabled}
|
||||
onchange={() => settings.toggleTargetEnabled(t)}
|
||||
/>
|
||||
<span class="name">{t.name || t.kind}</span>
|
||||
</label>
|
||||
<span class="badge" class:third={t.third_party}>{t.third_party ? "third-party" : "your server"}</span>
|
||||
<span class="badge" class:third={t.third_party}
|
||||
>{t.third_party ? "third-party" : "your server"}</span
|
||||
>
|
||||
<span class="host">{t.host ?? t.kind}</span>
|
||||
<button class="link danger" onclick={() => settings.removeTarget(t.id)}>Remove</button>
|
||||
<button class="link danger" onclick={() => settings.removeTarget(t.id)}
|
||||
>Remove</button
|
||||
>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
@@ -155,91 +162,297 @@
|
||||
|
||||
<h4>Add a target</h4>
|
||||
<div class="kinds">
|
||||
{#each ["webdav", "onedrive", "dropbox", "box"] as k}
|
||||
<button class:active={form.kind === k} onclick={() => setKind(k as SyncKind)}>{k}</button>
|
||||
{#each ["webdav", "onedrive", "dropbox", "box"] as k (k)}
|
||||
<button class:active={form.kind === k} onclick={() => setKind(k as SyncKind)}
|
||||
>{k}</button
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if form.kind === "webdav"}
|
||||
<p class="muted">WebDAV covers Nextcloud, ownCloud, Cloudreve, Seafile, and Synology. (Seafile: enable SeafDAV server-side.)</p>
|
||||
<p class="muted">
|
||||
WebDAV covers Nextcloud, ownCloud, Cloudreve, Seafile, and Synology. (Seafile: enable
|
||||
SeafDAV server-side.)
|
||||
</p>
|
||||
<div class="grid">
|
||||
<label>Name<input bind:value={form.name} placeholder="Home Nextcloud" /></label>
|
||||
<label>Provider
|
||||
<label
|
||||
>Provider
|
||||
<select bind:value={form.provider_hint}>
|
||||
{#each PROVIDERS as p}<option value={p}>{p}</option>{/each}
|
||||
{#each PROVIDERS as p (p)}<option value={p}>{p}</option>{/each}
|
||||
</select>
|
||||
</label>
|
||||
<label class="wide">Server URL<input bind:value={form.base_url} placeholder="https://cloud.example.com/remote.php/dav/files/me/" /></label>
|
||||
<label class="wide"
|
||||
>Server URL<input
|
||||
bind:value={form.base_url}
|
||||
placeholder="https://cloud.example.com/remote.php/dav/files/me/"
|
||||
/></label
|
||||
>
|
||||
<label>Remote folder<input bind:value={form.remote_base_path} /></label>
|
||||
<label>Username<input bind:value={form.username} /></label>
|
||||
<label>App password<input type="password" bind:value={form.secret} placeholder="stored in OS credential store" /></label>
|
||||
<label
|
||||
>App password<input
|
||||
type="password"
|
||||
bind:value={form.secret}
|
||||
placeholder="stored in OS credential store"
|
||||
/></label
|
||||
>
|
||||
</div>
|
||||
<fieldset class="artifacts">
|
||||
<legend>Upload</legend>
|
||||
<label><input type="checkbox" bind:checked={form.upload_transcript} /> transcript</label>
|
||||
<label><input type="checkbox" bind:checked={form.upload_transcript} /> transcript</label
|
||||
>
|
||||
<label><input type="checkbox" bind:checked={form.upload_notes} /> notes</label>
|
||||
<label><input type="checkbox" bind:checked={form.upload_summary} /> summary</label>
|
||||
<label><input type="checkbox" bind:checked={form.upload_recording} /> recording (.wav, if retained)</label>
|
||||
<label
|
||||
><input type="checkbox" bind:checked={form.upload_recording} /> recording (.wav, if retained)</label
|
||||
>
|
||||
</fieldset>
|
||||
<label class="row small"><input type="checkbox" bind:checked={form.allow_plaintext_lan} /> Allow plaintext http for a LAN address (not recommended)</label>
|
||||
<label class="row small"
|
||||
><input type="checkbox" bind:checked={form.allow_plaintext_lan} /> Allow plaintext http for
|
||||
a LAN address (not recommended)</label
|
||||
>
|
||||
<div class="actions">
|
||||
<button onclick={testConnection}>Test connection</button>
|
||||
<button class="primary" onclick={addTarget} disabled={!form.name || !form.base_url}>Add target</button>
|
||||
<button class="primary" onclick={addTarget} disabled={!form.name || !form.base_url}
|
||||
>Add target</button
|
||||
>
|
||||
{#if testResult}<span class="test">{testResult}</span>{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="banner">⚠ {form.kind} is a third-party cloud — uploading sends your data off your device to {form.kind}.</div>
|
||||
<div class="banner">
|
||||
⚠ {form.kind} is a third-party cloud — uploading sends your data off your device to {form.kind}.
|
||||
</div>
|
||||
<div class="grid">
|
||||
<label class="wide">Name<input bind:value={form.name} placeholder={form.kind} /></label>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="primary" onclick={linkOAuth}>Link {form.kind} account…</button>
|
||||
<span class="muted">Opens an OAuth sign-in (loopback redirect); the token is stored in your OS credential store.</span>
|
||||
<span class="muted"
|
||||
>Opens an OAuth sign-in (loopback redirect); the token is stored in your OS credential
|
||||
store.</span
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<p class="muted footnote">Credentials are never written to settings or the database — only the OS credential store. TLS is required for non-LAN targets.</p>
|
||||
<p class="muted footnote">
|
||||
Credentials are never written to settings or the database — only the OS credential store.
|
||||
TLS is required for non-LAN targets.
|
||||
</p>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.overlay { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.45); display: grid; place-items: center; z-index: 50; }
|
||||
.panel { background: var(--bg); color: var(--fg); width: min(720px, 92vw); max-height: 88vh; overflow: auto; border: 1px solid var(--border); border-radius: 10px; padding: 0 1.25rem 1.25rem; }
|
||||
header { position: sticky; top: 0; background: var(--bg); display: flex; align-items: center; gap: 1rem; padding: 0.9rem 0; border-bottom: 1px solid var(--border); }
|
||||
nav { display: flex; gap: 0.25rem; }
|
||||
nav button, .kinds button { background: transparent; border: 1px solid var(--border); color: var(--fg); padding: 0.3rem 0.7rem; border-radius: 6px; cursor: pointer; }
|
||||
nav button.active, .kinds button.active { background: var(--fg); color: var(--bg); }
|
||||
.close { margin-left: auto; background: transparent; border: none; color: var(--muted); cursor: pointer; font-size: 1rem; }
|
||||
h3 { margin: 1rem 0 0.5rem; }
|
||||
h4 { margin: 1.25rem 0 0.4rem; font-size: 0.9rem; }
|
||||
.row { display: flex; align-items: center; gap: 0.5rem; margin: 0.4rem 0; }
|
||||
.row.small { font-size: 0.85rem; color: var(--muted); }
|
||||
.muted { color: var(--muted); font-size: 0.85rem; line-height: 1.5; }
|
||||
.footnote { margin-top: 1rem; border-top: 1px solid var(--border); padding-top: 0.6rem; }
|
||||
em { color: var(--muted); font-style: normal; }
|
||||
code { background: var(--border); padding: 0 0.25rem; border-radius: 3px; }
|
||||
.stub { background: color-mix(in srgb, orange 18%, var(--bg)); border: 1px solid var(--border); padding: 0.5rem 0.7rem; border-radius: 6px; font-size: 0.82rem; }
|
||||
.consent { border: 1px solid var(--border); border-radius: 8px; padding: 0.8rem; margin-top: 0.6rem; }
|
||||
.actions { display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; margin-top: 0.6rem; }
|
||||
button.primary { background: #2563eb; color: #fff; border: none; padding: 0.4rem 0.8rem; border-radius: 6px; cursor: pointer; }
|
||||
button.primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.kinds { display: flex; gap: 0.4rem; margin: 0.5rem 0; }
|
||||
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.6rem; margin: 0.5rem 0; }
|
||||
.grid label { display: flex; flex-direction: column; font-size: 0.8rem; gap: 0.2rem; }
|
||||
.grid label.wide { grid-column: 1 / -1; }
|
||||
input, select { background: var(--bg); color: var(--fg); border: 1px solid var(--border); border-radius: 5px; padding: 0.35rem; }
|
||||
.artifacts { border: 1px solid var(--border); border-radius: 6px; display: flex; gap: 1rem; flex-wrap: wrap; font-size: 0.85rem; }
|
||||
.artifacts label { display: flex; align-items: center; gap: 0.3rem; }
|
||||
.banner { background: color-mix(in srgb, #e11d48 14%, var(--bg)); border: 1px solid var(--border); padding: 0.5rem 0.7rem; border-radius: 6px; font-size: 0.85rem; margin: 0.5rem 0; }
|
||||
ul.targets { list-style: none; padding: 0; margin: 0.3rem 0; }
|
||||
ul.targets li { display: flex; align-items: center; gap: 0.6rem; padding: 0.4rem 0; border-bottom: 1px solid var(--border); }
|
||||
.tg { display: flex; align-items: center; gap: 0.4rem; flex: 1; }
|
||||
.badge { font-size: 0.7rem; padding: 0.1rem 0.4rem; border: 1px solid var(--border); border-radius: 10px; color: var(--muted); }
|
||||
.badge.third { color: #e11d48; }
|
||||
.host { font-size: 0.75rem; color: var(--muted); }
|
||||
.link { background: none; border: none; cursor: pointer; color: var(--muted); }
|
||||
.link.danger { color: #e11d48; }
|
||||
.test { font-size: 0.82rem; color: var(--muted); }
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
z-index: 50;
|
||||
}
|
||||
.panel {
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
width: min(720px, 92vw);
|
||||
max-height: 88vh;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 0 1.25rem 1.25rem;
|
||||
}
|
||||
header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--bg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.9rem 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
nav {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
nav button,
|
||||
.kinds button {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--fg);
|
||||
padding: 0.3rem 0.7rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
nav button.active,
|
||||
.kinds button.active {
|
||||
background: var(--fg);
|
||||
color: var(--bg);
|
||||
}
|
||||
.close {
|
||||
margin-left: auto;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
}
|
||||
h3 {
|
||||
margin: 1rem 0 0.5rem;
|
||||
}
|
||||
h4 {
|
||||
margin: 1.25rem 0 0.4rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin: 0.4rem 0;
|
||||
}
|
||||
.row.small {
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.footnote {
|
||||
margin-top: 1rem;
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: 0.6rem;
|
||||
}
|
||||
em {
|
||||
color: var(--muted);
|
||||
font-style: normal;
|
||||
}
|
||||
code {
|
||||
background: var(--border);
|
||||
padding: 0 0.25rem;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.stub {
|
||||
background: color-mix(in srgb, orange 18%, var(--bg));
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.5rem 0.7rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.6rem;
|
||||
}
|
||||
button.primary {
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 0.4rem 0.8rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
button.primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.kinds {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.6rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
.grid label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: 0.8rem;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
.grid label.wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
input,
|
||||
select {
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 5px;
|
||||
padding: 0.35rem;
|
||||
}
|
||||
.artifacts {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.artifacts label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.banner {
|
||||
background: color-mix(in srgb, #e11d48 14%, var(--bg));
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.5rem 0.7rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
ul.targets {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0.3rem 0;
|
||||
}
|
||||
ul.targets li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.4rem 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.tg {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
flex: 1;
|
||||
}
|
||||
.badge {
|
||||
font-size: 0.7rem;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.badge.third {
|
||||
color: #e11d48;
|
||||
}
|
||||
.host {
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
.link {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--muted);
|
||||
}
|
||||
.link.danger {
|
||||
color: #e11d48;
|
||||
}
|
||||
.test {
|
||||
font-size: 0.82rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -8,6 +8,11 @@ export default defineConfig({
|
||||
server: {
|
||||
port: 1420,
|
||||
strictPort: true,
|
||||
watch: {
|
||||
// Without this, Vite's watcher races cargo writing target/debug/deps/*.dll
|
||||
// on Windows and dies with EBUSY mid-build.
|
||||
ignored: ["**/src-tauri/**"],
|
||||
},
|
||||
},
|
||||
build: {
|
||||
target: "esnext",
|
||||
|
||||