Two new Settings → AI knobs that compose cleanly with what already shipped (aiTier, LLM model, translator prompt framing). **B.1 #15 — Named cleanup presets.** LlmPromptPreset enum (Default / Email / Notes / Code) appends a short context hint onto the CLEANUP_PROMPT just before generation. Presets shape tone and structure ("email paragraph", "bulleted meeting notes", "preserve technical terms") without licensing the content-editing the translator-not-editor framing forbids. cleanup_transcript_text_cmd now takes `preset: Option<String>` which runs through the new LlmPromptPreset::parse (normalises aliases like "meeting-notes", collapses unknown values to Default). **A.1 #28 — Sequential-GPU guard.** New LocalEngine::unload drops the backend + model_id so a subsequent load actually reclaims VRAM. load_llm_model, load_model, and load_parakeet_model Tauri commands grow an optional `concurrent: bool` argument. When concurrent is Some(false), loading LLM first unloads whisper+parakeet, and vice versa — prevents VRAM OOM on tight-VRAM setups. Default is the previous parallel behaviour so nothing changes for multi-GB cards. Transcribe-in-progress paths (transcribe_pcm, transcribe_file, live) pass None, so mid-dictation model loads don't accidentally tear down the LLM. Settings UI (AI section): - Cleanup preset segmented button + descriptive copy for each option. - GPU concurrency segmented button with explicit trade-off text ("faster transitions vs fits in tight VRAM"). Frontend wiring: - settings.llmPromptPreset flows from DictationPage's cleanupTranscriptIfEnabled into the Tauri command. - settings.aiGpuConcurrency flows from both DictationPage (auto-load on record) and SettingsPage (manual load/unload buttons) as `concurrent: "parallel" === true` to the load commands. Tests: three new preset cases in crates/ai-formatting/src/llm_client.rs (parse aliases, suffix non-empty for non-default, default suffix empty). All 139 existing lib tests still pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,6 @@ pub mod pipeline;
|
||||
pub mod rule_based;
|
||||
|
||||
pub use correction_learning::extract_corrections;
|
||||
pub use llm_client::cleanup_text as llm_cleanup_text;
|
||||
pub use llm_client::{cleanup_text as llm_cleanup_text, LlmPromptPreset};
|
||||
pub use pipeline::{post_process_segments, FormatMode, PostProcessOptions};
|
||||
pub use rule_based::{format_text, is_hallucination, remove_fillers, to_british_english};
|
||||
|
||||
@@ -67,19 +67,95 @@ pub fn format_dictionary_suffix(terms: &[String]) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
/// Named cleanup-style presets (brief item B.1 #15). Each preset adds a
|
||||
/// short additional instruction to the translation contract so the same
|
||||
/// underlying translator behaviour produces output appropriate for the
|
||||
/// user's current context (email vs. meeting notes vs. code).
|
||||
///
|
||||
/// Deliberately narrow set — four presets is small enough to pick from a
|
||||
/// dropdown without becoming its own cognitive load. Users wanting more
|
||||
/// nuance edit `profile.initial_prompt` instead; presets layer on top of
|
||||
/// whatever the active profile specifies.
|
||||
///
|
||||
/// The translator-not-editor framing from CLEANUP_PROMPT still governs —
|
||||
/// presets shape tone and structure, never licence content editing.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LlmPromptPreset {
|
||||
/// No additional guidance beyond the profile's initial_prompt.
|
||||
Default,
|
||||
/// Format as an email paragraph — tight sentences, natural
|
||||
/// paragraph breaks at topic shifts, no markdown.
|
||||
Email,
|
||||
/// Format as bulleted meeting notes. Lead action items with an
|
||||
/// imperative verb; keep informational sentences as prose.
|
||||
Notes,
|
||||
/// Software-dictation mode. Preserve technical terms, variable
|
||||
/// names, file paths, and symbols exactly as spoken. Do not reword
|
||||
/// technical phrasing.
|
||||
Code,
|
||||
}
|
||||
|
||||
impl LlmPromptPreset {
|
||||
/// Parse a frontend-serialised preset identifier. Unknown or empty
|
||||
/// strings collapse to Default so an outdated frontend can never
|
||||
/// produce an unhandled enum variant — the user just sees baseline
|
||||
/// behaviour.
|
||||
pub fn parse(value: &str) -> Self {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"email" => Self::Email,
|
||||
"notes" | "meeting" | "meeting-notes" => Self::Notes,
|
||||
"code" | "software" => Self::Code,
|
||||
_ => Self::Default,
|
||||
}
|
||||
}
|
||||
|
||||
/// Extra instruction appended to the system prompt. Empty string
|
||||
/// for Default — no whitespace or leading newline — so the concat
|
||||
/// with the dictionary suffix stays clean.
|
||||
pub fn suffix(self) -> &'static str {
|
||||
match self {
|
||||
Self::Default => "",
|
||||
Self::Email => concat!(
|
||||
"\n\n",
|
||||
"Context: the speaker is dictating an email. Produce a single ",
|
||||
"coherent email paragraph (or two if the topic clearly shifts). ",
|
||||
"Tight sentences, no markdown, no salutation or signature unless ",
|
||||
"the speaker explicitly dictates one.",
|
||||
),
|
||||
Self::Notes => concat!(
|
||||
"\n\n",
|
||||
"Context: the speaker is dictating meeting notes. Where the text ",
|
||||
"contains a list of items or action items, render them as a ",
|
||||
"markdown bullet list ('- '). Action items should lead with an ",
|
||||
"imperative verb. Preserve prose informational sentences as prose; ",
|
||||
"don't force bullets where narrative is clearer.",
|
||||
),
|
||||
Self::Code => concat!(
|
||||
"\n\n",
|
||||
"Context: the speaker is dictating about software. Preserve ",
|
||||
"technical terms, variable names, file paths, CLI flags, and ",
|
||||
"symbols exactly as spoken. Do not reword technical phrasing or ",
|
||||
"'translate' identifiers into natural English.",
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cleanup_text(
|
||||
engine: &LlmEngine,
|
||||
transcript: &str,
|
||||
dictionary_terms: &[String],
|
||||
preset: LlmPromptPreset,
|
||||
) -> Result<String, EngineError> {
|
||||
if transcript.trim().is_empty() {
|
||||
return Ok(String::new());
|
||||
}
|
||||
|
||||
let system_prompt = format!(
|
||||
"{}{}",
|
||||
"{}{}{}",
|
||||
CLEANUP_PROMPT,
|
||||
format_dictionary_suffix(dictionary_terms),
|
||||
preset.suffix(),
|
||||
);
|
||||
engine.cleanup_text(&system_prompt, transcript)
|
||||
}
|
||||
@@ -134,14 +210,39 @@ mod tests {
|
||||
#[test]
|
||||
fn cleanup_empty_returns_empty_string() {
|
||||
let engine = LlmEngine::new();
|
||||
let result = cleanup_text(&engine, "", &[]);
|
||||
let result = cleanup_text(&engine, "", &[], LlmPromptPreset::Default);
|
||||
assert!(matches!(result, Ok(cleaned) if cleaned.is_empty()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_unloaded_returns_not_loaded_error() {
|
||||
let engine = LlmEngine::new();
|
||||
let result = cleanup_text(&engine, "um hi there", &[]);
|
||||
let result = cleanup_text(&engine, "um hi there", &[], LlmPromptPreset::Default);
|
||||
assert!(matches!(result, Err(EngineError::NotLoaded)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preset_parse_normalises_aliases() {
|
||||
assert_eq!(LlmPromptPreset::parse("email"), LlmPromptPreset::Email);
|
||||
assert_eq!(LlmPromptPreset::parse("EMAIL"), LlmPromptPreset::Email);
|
||||
assert_eq!(LlmPromptPreset::parse("notes"), LlmPromptPreset::Notes);
|
||||
assert_eq!(LlmPromptPreset::parse("meeting"), LlmPromptPreset::Notes);
|
||||
assert_eq!(LlmPromptPreset::parse("meeting-notes"), LlmPromptPreset::Notes);
|
||||
assert_eq!(LlmPromptPreset::parse("code"), LlmPromptPreset::Code);
|
||||
assert_eq!(LlmPromptPreset::parse("software"), LlmPromptPreset::Code);
|
||||
// Unknown values and explicit default fall back safely.
|
||||
assert_eq!(LlmPromptPreset::parse("default"), LlmPromptPreset::Default);
|
||||
assert_eq!(LlmPromptPreset::parse(""), LlmPromptPreset::Default);
|
||||
assert_eq!(LlmPromptPreset::parse("random-unknown"), LlmPromptPreset::Default);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preset_suffix_shapes_tone_without_editing_licence() {
|
||||
// Each non-default preset must add something; the Default must
|
||||
// be empty so it composes cleanly with dictionary suffix.
|
||||
assert!(LlmPromptPreset::Default.suffix().is_empty());
|
||||
assert!(LlmPromptPreset::Email.suffix().contains("email"));
|
||||
assert!(LlmPromptPreset::Notes.suffix().to_lowercase().contains("bullet"));
|
||||
assert!(LlmPromptPreset::Code.suffix().contains("technical"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,7 +76,17 @@ pub fn post_process_segments(
|
||||
.join(" ");
|
||||
|
||||
if !joined.is_empty() {
|
||||
match llm_client::cleanup_text(engine, &joined, &options.dictionary_terms) {
|
||||
// Pipeline-internal cleanup (used by file-based + live
|
||||
// transcribe paths) runs with the Default preset. The
|
||||
// named-preset UX (B.1 #15) flows through the explicit
|
||||
// cleanup_transcript_text_cmd path instead, where the
|
||||
// frontend decides which preset the user has selected.
|
||||
match llm_client::cleanup_text(
|
||||
engine,
|
||||
&joined,
|
||||
&options.dictionary_terms,
|
||||
llm_client::LlmPromptPreset::Default,
|
||||
) {
|
||||
Ok(cleaned) if !cleaned.trim().is_empty() => {
|
||||
replace_segments_with_cleaned(segments, cleaned.trim());
|
||||
}
|
||||
|
||||
@@ -56,6 +56,23 @@ impl LocalEngine {
|
||||
*id_guard = Some(model_id);
|
||||
}
|
||||
|
||||
/// Drop the loaded model and free its backing resources (GPU VRAM,
|
||||
/// CPU memory, mmap'd GGML tensors). Used by the sequential-GPU
|
||||
/// guard (brief item A.1 #28) so loading the LLM on a tight-VRAM
|
||||
/// system first frees the transcription engine, and vice versa.
|
||||
///
|
||||
/// No-op when nothing is loaded. Thread-safe — the internal Mutex
|
||||
/// serialises against concurrent transcribe_sync calls.
|
||||
pub fn unload(&self) {
|
||||
let mut guard = self.engine.lock().unwrap_or_else(|e| e.into_inner());
|
||||
*guard = None;
|
||||
let mut id_guard = self
|
||||
.loaded_model_id
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
*id_guard = None;
|
||||
}
|
||||
|
||||
pub fn name(&self) -> &EngineName {
|
||||
&self.engine_name
|
||||
}
|
||||
|
||||
@@ -237,7 +237,9 @@ pub async fn start_live_transcription_session(
|
||||
"[live] starting session: engine={}, model={}, language={:?}, save_audio={}",
|
||||
config.engine, model_id, config.language, config.save_audio
|
||||
);
|
||||
ensure_model_loaded(&state, &config.engine, &model_id).await?;
|
||||
// None: live-transcription model loads don't enforce sequential-GPU
|
||||
// mode. The Settings-level load flow owns that guard.
|
||||
ensure_model_loaded(&state, &config.engine, &model_id, None).await?;
|
||||
|
||||
let session_id = live_state
|
||||
.next_session_id
|
||||
|
||||
@@ -2,7 +2,7 @@ use tauri::{Emitter, State};
|
||||
|
||||
use crate::commands::power::PowerAssertion;
|
||||
use crate::AppState;
|
||||
use kon_ai_formatting::llm_cleanup_text;
|
||||
use kon_ai_formatting::{llm_cleanup_text, LlmPromptPreset};
|
||||
use kon_core::hardware;
|
||||
use kon_llm::model_manager::{self, model_info};
|
||||
use kon_llm::LlmModelId;
|
||||
@@ -86,6 +86,7 @@ pub async fn load_llm_model(
|
||||
state: State<'_, AppState>,
|
||||
model_id: String,
|
||||
use_gpu: Option<bool>,
|
||||
concurrent: Option<bool>,
|
||||
) -> Result<(), String> {
|
||||
let id = parse_model_id(model_id)?;
|
||||
let path = model_manager::model_path(id);
|
||||
@@ -93,6 +94,15 @@ pub async fn load_llm_model(
|
||||
return Err("Model not downloaded — call download_llm_model first".to_string());
|
||||
}
|
||||
|
||||
// Sequential-GPU guard (brief item A.1 #28): when the user has opted
|
||||
// out of concurrent GPU residency, free the transcription engines
|
||||
// before loading the LLM. Prevents VRAM OOM on tight-GPU setups.
|
||||
// concurrent=None or Some(true) preserves legacy parallel behaviour.
|
||||
if concurrent == Some(false) {
|
||||
state.whisper_engine.unload();
|
||||
state.parakeet_engine.unload();
|
||||
}
|
||||
|
||||
let engine = state.llm_engine.clone();
|
||||
let use_gpu = use_gpu.unwrap_or(true);
|
||||
tokio::task::spawn_blocking(move || engine.load_model(id, &path, use_gpu))
|
||||
@@ -335,6 +345,7 @@ pub async fn cleanup_transcript_text_cmd(
|
||||
state: State<'_, AppState>,
|
||||
transcript: String,
|
||||
profile_id: Option<String>,
|
||||
preset: Option<String>,
|
||||
) -> Result<String, String> {
|
||||
let resolved_profile_id =
|
||||
profile_id.unwrap_or_else(|| kon_storage::DEFAULT_PROFILE_ID.to_string());
|
||||
@@ -346,13 +357,21 @@ pub async fn cleanup_transcript_text_cmd(
|
||||
.map(|term| term.term)
|
||||
.collect();
|
||||
|
||||
// Named preset (brief item B.1 #15): Email / Notes / Code shape the
|
||||
// output tone + structure without changing the translator-not-editor
|
||||
// contract. None or unknown → Default (no additional guidance).
|
||||
let resolved_preset = preset
|
||||
.as_deref()
|
||||
.map(LlmPromptPreset::parse)
|
||||
.unwrap_or(LlmPromptPreset::Default);
|
||||
|
||||
let engine = state.llm_engine.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
// macOS: pin a power assertion for the duration of the LLM
|
||||
// generation so App Nap can't decide to throttle us mid-token.
|
||||
// No-op on every other OS. Item #9.
|
||||
let _power_guard = PowerAssertion::begin("kon LLM cleanup");
|
||||
llm_cleanup_text(&engine, &transcript, &profile_terms)
|
||||
llm_cleanup_text(&engine, &transcript, &profile_terms, resolved_preset)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
|
||||
@@ -112,6 +112,7 @@ pub async fn ensure_model_loaded(
|
||||
state: &AppState,
|
||||
engine_name: &str,
|
||||
model_id: &str,
|
||||
concurrent: Option<bool>,
|
||||
) -> Result<(), String> {
|
||||
let model_id = ModelId::new(model_id);
|
||||
let entry = model_registry::find_model(&model_id)
|
||||
@@ -143,6 +144,15 @@ pub async fn ensure_model_loaded(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Sequential-GPU guard (brief item A.1 #28): if the user opts out
|
||||
// of concurrent GPU residency, free the LLM before bringing the
|
||||
// transcription engine on. None / Some(true) leaves the LLM
|
||||
// untouched (legacy parallel behaviour, safe on multi-GB VRAM
|
||||
// setups). Inverse guard lives in commands::llm::load_llm_model.
|
||||
if concurrent == Some(false) && state.llm_engine.is_loaded() {
|
||||
state.llm_engine.unload().map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
let engine_clone = engine.clone();
|
||||
let model_id_clone = model_id.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
@@ -510,9 +520,13 @@ pub fn list_models() -> Result<Vec<String>, String> {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn load_model(state: tauri::State<'_, AppState>, size: String) -> Result<String, String> {
|
||||
pub async fn load_model(
|
||||
state: tauri::State<'_, AppState>,
|
||||
size: String,
|
||||
concurrent: Option<bool>,
|
||||
) -> Result<String, String> {
|
||||
let id = whisper_model_id(&size);
|
||||
ensure_model_loaded(&state, "whisper", id.as_str()).await?;
|
||||
ensure_model_loaded(&state, "whisper", id.as_str(), concurrent).await?;
|
||||
Ok(format!("Model {} loaded", size))
|
||||
}
|
||||
|
||||
@@ -561,9 +575,10 @@ pub fn list_parakeet_models() -> Result<Vec<String>, String> {
|
||||
pub async fn load_parakeet_model(
|
||||
state: tauri::State<'_, AppState>,
|
||||
name: String,
|
||||
concurrent: Option<bool>,
|
||||
) -> Result<String, String> {
|
||||
let id = parakeet_model_id(&name);
|
||||
ensure_model_loaded(&state, "parakeet", id.as_str()).await?;
|
||||
ensure_model_loaded(&state, "parakeet", id.as_str(), concurrent).await?;
|
||||
Ok(format!("Parakeet model {} loaded", name))
|
||||
}
|
||||
|
||||
|
||||
@@ -261,7 +261,9 @@ pub async fn transcribe_file(
|
||||
let engine_name = engine.unwrap_or_else(|| "whisper".to_string());
|
||||
let model_id =
|
||||
model_id.unwrap_or_else(|| default_model_id_for_engine(&engine_name).to_string());
|
||||
ensure_model_loaded(&state, &engine_name, &model_id).await?;
|
||||
// None: transcribe paths don't enforce sequential-GPU mode. That's
|
||||
// owned by the Settings-level load flows (see models.rs).
|
||||
ensure_model_loaded(&state, &engine_name, &model_id, None).await?;
|
||||
|
||||
let engine = pick_engine(&state, &engine_name)?;
|
||||
let options = TranscriptionOptions {
|
||||
|
||||
@@ -239,7 +239,13 @@
|
||||
try {
|
||||
const status = await invoke("check_llm_model", { modelId: settings.llmModelId });
|
||||
if (status?.downloaded && !status.loaded) {
|
||||
await invoke("load_llm_model", { modelId: settings.llmModelId });
|
||||
await invoke("load_llm_model", {
|
||||
modelId: settings.llmModelId,
|
||||
// Sequential-GPU guard (brief item A.1 #28): frees whisper
|
||||
// before loading the LLM on tight-VRAM setups. "parallel"
|
||||
// keeps both resident (default, safe on multi-GB cards).
|
||||
concurrent: settings.aiGpuConcurrency === "parallel",
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("ensureLlmModelLoaded failed", err);
|
||||
@@ -257,10 +263,15 @@
|
||||
error = "";
|
||||
|
||||
try {
|
||||
// concurrent flag mirrors settings.aiGpuConcurrency (brief A.1 #28).
|
||||
const concurrent = settings.aiGpuConcurrency === "parallel";
|
||||
if (settings.engine === "parakeet") {
|
||||
await invoke("load_parakeet_model", { name: "ctc-int8" });
|
||||
await invoke("load_parakeet_model", { name: "ctc-int8", concurrent });
|
||||
} else {
|
||||
await invoke("load_model", { size: settings.modelSize.toLowerCase() });
|
||||
await invoke("load_model", {
|
||||
size: settings.modelSize.toLowerCase(),
|
||||
concurrent,
|
||||
});
|
||||
}
|
||||
await refreshRuntimeCapabilities();
|
||||
modelReady = true;
|
||||
@@ -460,6 +471,7 @@
|
||||
const cleaned = await invoke("cleanup_transcript_text_cmd", {
|
||||
transcript: text,
|
||||
profileId: profilesStore.activeProfileId,
|
||||
preset: settings.llmPromptPreset,
|
||||
});
|
||||
markGenerationDone(true);
|
||||
return cleaned?.trim() ? cleaned.trim() : text;
|
||||
|
||||
@@ -531,7 +531,11 @@
|
||||
const modelId = selectedLlmModelId();
|
||||
llmStatus = "Loading...";
|
||||
try {
|
||||
await invoke("load_llm_model", { modelId });
|
||||
await invoke("load_llm_model", {
|
||||
modelId,
|
||||
// Sequential-GPU guard (brief item A.1 #28).
|
||||
concurrent: settings.aiGpuConcurrency === "parallel",
|
||||
});
|
||||
await refreshLlmStatus();
|
||||
await refreshGlobalLlmStatus(settings.aiTier);
|
||||
} catch (err) {
|
||||
@@ -740,7 +744,10 @@
|
||||
engineStatus = "Loading...";
|
||||
engineOk = false;
|
||||
try {
|
||||
await invoke("load_model", { size });
|
||||
await invoke("load_model", {
|
||||
size,
|
||||
concurrent: settings.aiGpuConcurrency === "parallel",
|
||||
});
|
||||
await refreshRuntimeCapabilities();
|
||||
engineOk = true;
|
||||
engineStatus = `${settings.modelSize} model loaded`;
|
||||
@@ -796,7 +803,10 @@
|
||||
parakeetStatus = "Loading...";
|
||||
parakeetOk = false;
|
||||
try {
|
||||
await invoke("load_parakeet_model", { name: "ctc-int8" });
|
||||
await invoke("load_parakeet_model", {
|
||||
name: "ctc-int8",
|
||||
concurrent: settings.aiGpuConcurrency === "parallel",
|
||||
});
|
||||
await refreshRuntimeCapabilities();
|
||||
parakeetOk = true;
|
||||
parakeetStatus = "Model loaded";
|
||||
@@ -1505,6 +1515,48 @@
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Cleanup preset (brief item B.1 #15). Shapes the tone /
|
||||
structure of LLM cleanup output; composes on top of the
|
||||
active profile's initial prompt. -->
|
||||
<div class="mt-5">
|
||||
<p class="text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-2">Cleanup preset</p>
|
||||
<SegmentedButton
|
||||
options={["default", "email", "notes", "code"]}
|
||||
bind:value={settings.llmPromptPreset}
|
||||
/>
|
||||
<p class="text-[11px] text-text-tertiary mt-2">
|
||||
{#if settings.llmPromptPreset === "email"}
|
||||
Formats output as an email paragraph — tight sentences, no markdown, no auto-added greeting or signoff.
|
||||
{:else if settings.llmPromptPreset === "notes"}
|
||||
Formats action items as a bullet list led by imperative verbs; keeps prose informational sentences as prose.
|
||||
{:else if settings.llmPromptPreset === "code"}
|
||||
Preserves technical terms, variable names, file paths, and symbols exactly as spoken. No translation of identifiers.
|
||||
{:else}
|
||||
No preset — the active profile's prompt governs tone alone.
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Sequential-GPU guard (brief item A.1 #28). On a tight-
|
||||
VRAM single-GPU system, loading LLM + Whisper together
|
||||
can OOM. Sequential mode unloads one before loading the
|
||||
other; adds reload latency between transcribe and
|
||||
cleanup phases. -->
|
||||
<div class="mt-5">
|
||||
<p class="text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-2">GPU concurrency</p>
|
||||
<SegmentedButton
|
||||
options={["parallel", "sequential"]}
|
||||
bind:value={settings.aiGpuConcurrency}
|
||||
/>
|
||||
<p class="text-[11px] text-text-tertiary mt-2">
|
||||
{#if settings.aiGpuConcurrency === "sequential"}
|
||||
On tight-VRAM cards (≤6 GB), loading Whisper + LLM together OOMs. Sequential mode frees the other model before loading; adds a small reload pause between transcribe and cleanup.
|
||||
{:else}
|
||||
Both models stay resident in GPU memory. Faster transitions, but needs enough VRAM to hold both at once.
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -61,6 +61,8 @@ const defaults: SettingsState = {
|
||||
fontSize: 14,
|
||||
aiTier: "cleanup",
|
||||
llmModelId: null,
|
||||
llmPromptPreset: "default",
|
||||
aiGpuConcurrency: "parallel",
|
||||
saveAudio: false,
|
||||
outputFolder: "",
|
||||
globalHotkey: "Ctrl+Shift+R",
|
||||
|
||||
@@ -12,6 +12,8 @@ export type WhisperModelSize =
|
||||
| "Distil-L";
|
||||
export type AiTier = "off" | "cleanup" | "tasks";
|
||||
export type LlmModelIdStr = "qwen3_1_7b" | "qwen3_4b_instruct_2507" | "qwen3_14b";
|
||||
export type LlmPromptPreset = "default" | "email" | "notes" | "code";
|
||||
export type AiGpuConcurrency = "parallel" | "sequential";
|
||||
export type TaskBucket = "inbox" | "today" | "soon" | "later";
|
||||
export type ToastSeverity = "info" | "success" | "warn" | "error";
|
||||
|
||||
@@ -47,6 +49,8 @@ export interface SettingsState {
|
||||
fontSize: number;
|
||||
aiTier: AiTier;
|
||||
llmModelId: LlmModelIdStr | null;
|
||||
llmPromptPreset: LlmPromptPreset;
|
||||
aiGpuConcurrency: AiGpuConcurrency;
|
||||
saveAudio: boolean;
|
||||
outputFolder: string;
|
||||
globalHotkey: string;
|
||||
|
||||
Reference in New Issue
Block a user