2 Commits

Author SHA1 Message Date
2ca01e7c9d feat(export): prefer native save dialog over browser blob fallback
Some checks failed
check / cargo check (macos-latest) (push) Has been cancelled
check / cargo check (ubuntu-22.04) (push) Has been cancelled
check / cargo check (windows-latest) (push) Has been cancelled
check / svelte build + lint (push) Has been cancelled
DictationPage + FilesPage handleExport() now use Tauri save() +
write_text_file_cmd when in the desktop runtime; browser blob path
remains as fallback when hasTauriRuntime() is false. saveMarkdown.ts
surfaces dialog errors via toasts. Adds txt to the extension map.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 08:26:19 +01:00
1d71e8e361 refactor(llm): remove GBNF grammar, switch to JSON-envelope extractor
extract_content_tags now generates with grammar=None and parses the
response via a manual brace-counting JSON envelope extractor that
handles Qwen <think>...</think> prefixes and trailing stop tokens.
Five new unit tests. Bumps llama-cpp-2 to 0.1.146. Explicit
features=[] on tauri dependency (no-op).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 08:25:55 +01:00
6 changed files with 192 additions and 30 deletions

View File

@@ -19,7 +19,7 @@ openmp = ["llama-cpp-2/openmp"]
magnotia-core = { path = "../core" } magnotia-core = { path = "../core" }
encoding_rs = "0.8" encoding_rs = "0.8"
futures-util = "0.3" futures-util = "0.3"
llama-cpp-2 = { version = "0.1.144", default-features = false } llama-cpp-2 = { version = "0.1.146", default-features = false }
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream"] } reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"

View File

@@ -212,6 +212,10 @@ impl LlmEngine {
generated.push_str(&piece); generated.push_str(&piece);
sampler.accept(next); sampler.accept(next);
if config.grammar.is_some() && json_envelope_complete(&generated) {
break;
}
if let Some(stop_index) = first_stop_index(&generated, &config.stop_sequences) { if let Some(stop_index) = first_stop_index(&generated, &config.stop_sequences) {
generated.truncate(stop_index); generated.truncate(stop_index);
break; break;
@@ -296,13 +300,12 @@ impl LlmEngine {
} }
/// Phase 9 content-tag extraction. Emits a single (topic, intent) /// Phase 9 content-tag extraction. Emits a single (topic, intent)
/// pair under the `CONTENT_TAGS_GRAMMAR` GBNF. Truncates to the /// pair as JSON. Truncates to the trailing 2000 chars of the
/// trailing 2000 chars of the transcript so the prompt budget /// transcript so the prompt budget stays well under any model's
/// stays well under any model's context window. Determinism is /// context window. Determinism is enforced by temperature 0.0;
/// enforced by temperature 0.0 and the closed-set intent grammar /// the parsed intent is re-validated with `is_valid_intent` and
/// rule; on the rare case the model emits a parse-able-but-out-of- /// invalid JSON bubbles as `InvalidJson` so the frontend toasts a
/// set intent, we re-validate with `is_valid_intent` and bubble /// clear error.
/// `InvalidJson` so the frontend toasts a clear error.
pub fn extract_content_tags( pub fn extract_content_tags(
&self, &self,
transcript: &str, transcript: &str,
@@ -338,12 +341,11 @@ impl LlmEngine {
max_tokens: 96, max_tokens: 96,
temperature: 0.0, temperature: 0.0,
stop_sequences: vec!["<|im_end|>".to_string(), "<|im_end_of_text|>".to_string()], stop_sequences: vec!["<|im_end|>".to_string(), "<|im_end_of_text|>".to_string()],
grammar: Some(grammars::CONTENT_TAGS_GRAMMAR.to_string()), grammar: None,
}, },
)?; )?;
let tags: prompts::ContentTags = serde_json::from_str(raw.trim()) let tags: prompts::ContentTags = parse_json_payload(&raw)?;
.map_err(|e| EngineError::InvalidJson(format!("{e}: raw={raw:?}")))?;
if !prompts::is_valid_intent(&tags.intent) { if !prompts::is_valid_intent(&tags.intent) {
return Err(EngineError::InvalidJson(format!( return Err(EngineError::InvalidJson(format!(
"intent out of closed set: {}", "intent out of closed set: {}",
@@ -459,6 +461,62 @@ fn first_stop_index(text: &str, stop_sequences: &[String]) -> Option<usize> {
.min() .min()
} }
fn json_envelope_complete(text: &str) -> bool {
extract_json_envelope(text) == Some(text.trim())
}
fn extract_json_envelope(text: &str) -> Option<&str> {
let start = text
.char_indices()
.find_map(|(idx, ch)| (ch == '{' || ch == '[').then_some(idx))?;
let mut chars = text[start..].char_indices();
let (_, first) = chars.next()?;
let mut stack = vec![match first {
'{' => '}',
'[' => ']',
_ => unreachable!(),
}];
let mut in_string = false;
let mut escaped = false;
while let Some((offset, ch)) = chars.next() {
if in_string {
if escaped {
escaped = false;
} else if ch == '\\' {
escaped = true;
} else if ch == '"' {
in_string = false;
}
continue;
}
match ch {
'"' => in_string = true,
'{' => stack.push('}'),
'[' => stack.push(']'),
'}' | ']' => {
if stack.pop() != Some(ch) {
return None;
}
if stack.is_empty() {
let end = start + offset + ch.len_utf8();
return Some(&text[start..end]);
}
}
_ => {}
}
}
None
}
fn parse_json_payload<T: for<'de> Deserialize<'de>>(raw: &str) -> Result<T, EngineError> {
let payload = extract_json_envelope(raw).unwrap_or(raw.trim());
serde_json::from_str(payload).map_err(|e| EngineError::InvalidJson(format!("{e}: raw={raw:?}")))
}
fn render_chat_prompt( fn render_chat_prompt(
model: &LlamaModel, model: &LlamaModel,
messages: &[(&str, &str)], messages: &[(&str, &str)],
@@ -548,6 +606,47 @@ mod tests {
assert_eq!(index, Some(5)); assert_eq!(index, Some(5));
} }
#[test]
fn json_envelope_complete_detects_finished_object() {
assert!(json_envelope_complete(
r#"{"topic":"meeting","intent":"planning"}"#
));
}
#[test]
fn json_envelope_complete_detects_finished_array() {
assert!(json_envelope_complete(r#"["Call plumber","Buy milk"]"#));
}
#[test]
fn json_envelope_complete_ignores_braces_inside_strings() {
assert!(!json_envelope_complete(r#"{"topic":"literal } brace""#));
}
#[test]
fn json_envelope_complete_rejects_prefixes_and_trailing_text() {
assert!(!json_envelope_complete(r#"{"topic":"meeting""#));
assert!(!json_envelope_complete(r#"{"topic":"meeting"} extra"#));
}
#[test]
fn extract_json_envelope_skips_qwen_thinking_prefix() {
let raw =
"<think>\n\n</think>\n\n{\"topic\":\"grant-application\",\"intent\":\"planning\"}";
assert_eq!(
extract_json_envelope(raw),
Some("{\"topic\":\"grant-application\",\"intent\":\"planning\"}"),
);
}
#[test]
fn extract_json_envelope_handles_arrays_and_trailing_stop_text() {
assert_eq!(
extract_json_envelope("prefix [\"Call plumber\",\"Buy milk\"]<|im_end|>"),
Some("[\"Call plumber\",\"Buy milk\"]"),
);
}
#[test] #[test]
fn prompt_preflight_rejects_oversized_prompt_tokens() { fn prompt_preflight_rejects_oversized_prompt_tokens() {
let err = preflight_context_window(7_105, 1_024).unwrap_err(); let err = preflight_context_window(7_105, 1_024).unwrap_err();

View File

@@ -41,7 +41,7 @@ magnotia-llm = { path = "../crates/llm" }
# autostart plugins are desktop-only — gated below under # autostart plugins are desktop-only — gated below under
# cfg(not(target_os = "android")). The dialog, opener, and notification # cfg(not(target_os = "android")). The dialog, opener, and notification
# plugins all support Android natively so live in the unconditional list. # plugins all support Android natively so live in the unconditional list.
tauri = { version = "2" } tauri = { version = "2", features = [] }
tauri-plugin-opener = "2" tauri-plugin-opener = "2"
tauri-plugin-dialog = "2" tauri-plugin-dialog = "2"
# Phase 6 nudges: OS-native notifications via the frontend-owned nudge # Phase 6 nudges: OS-native notifications via the frontend-owned nudge

View File

@@ -637,17 +637,38 @@
activeTemplate = ""; activeTemplate = "";
} }
function handleExport(format) { async function handleExport(format) {
if (!transcript) return; if (!transcript) return;
showExportMenu = false; showExportMenu = false;
const content = exportTranscript(transcript, segments, format); const content = exportTranscript(transcript, segments, format);
const extMap = { vtt: "vtt", srt: "srt", md: "md", csv: "csv", html: "html" }; const extMap = { txt: "txt", vtt: "vtt", srt: "srt", md: "md", csv: "csv", html: "html" };
const ext = extMap[format] || "txt"; const ext = extMap[format] || "txt";
const defaultPath = `magnotia-${new Date().toISOString().slice(0, 10)}.${ext}`;
if (hasTauriRuntime()) {
try {
const { save } = await import("@tauri-apps/plugin-dialog");
const path = await save({
title: `Export ${format.toUpperCase()} transcript`,
defaultPath,
filters: [{ name: format.toUpperCase(), extensions: [ext] }],
});
if (!path) return;
await invoke("write_text_file_cmd", { path, contents: content });
toasts.success(`Exported ${format.toUpperCase()} transcript`);
return;
} catch (err) {
console.error("dictation export failed", err);
error = `Export failed: ${typeof err === "string" ? err : err.message || err}`;
return;
}
}
const blob = new Blob([content], { type: "text/plain" }); const blob = new Blob([content], { type: "text/plain" });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement("a"); const a = document.createElement("a");
a.href = url; a.href = url;
a.download = `magnotia-${new Date().toISOString().slice(0, 10)}.${ext}`; a.download = defaultPath;
a.click(); a.click();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} }

View File

@@ -8,6 +8,7 @@
import Card from "$lib/components/Card.svelte"; import Card from "$lib/components/Card.svelte";
import EmptyState from "$lib/components/EmptyState.svelte"; import EmptyState from "$lib/components/EmptyState.svelte";
import { exportTranscript } from "$lib/utils/export.js"; import { exportTranscript } from "$lib/utils/export.js";
import { hasTauriRuntime } from "$lib/utils/runtime";
import { Upload } from 'lucide-svelte'; import { Upload } from 'lucide-svelte';
let fileTranscript = $state(""); let fileTranscript = $state("");
@@ -132,17 +133,37 @@
} }
} }
function handleExport(format) { async function handleExport(format) {
if (!fileTranscript) return; if (!fileTranscript) return;
showExportMenu = false; showExportMenu = false;
const content = exportTranscript(fileTranscript, segments, format); const content = exportTranscript(fileTranscript, segments, format);
const extMap = { vtt: "vtt", srt: "srt", md: "md", csv: "csv", html: "html" }; const extMap = { txt: "txt", vtt: "vtt", srt: "srt", md: "md", csv: "csv", html: "html" };
const ext = extMap[format] || "txt"; const ext = extMap[format] || "txt";
const defaultPath = `magnotia-file-${new Date().toISOString().slice(0, 10)}.${ext}`;
if (hasTauriRuntime()) {
try {
const { save } = await import("@tauri-apps/plugin-dialog");
const path = await save({
title: `Export ${format.toUpperCase()} transcript`,
defaultPath,
filters: [{ name: format.toUpperCase(), extensions: [ext] }],
});
if (!path) return;
await invoke("write_text_file_cmd", { path, contents: content });
return;
} catch (err) {
console.error("file transcript export failed", err);
error = `Export failed: ${typeof err === "string" ? err : err.message || err}`;
return;
}
}
const blob = new Blob([content], { type: "text/plain" }); const blob = new Blob([content], { type: "text/plain" });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement("a"); const a = document.createElement("a");
a.href = url; a.href = url;
a.download = `magnotia-file-${new Date().toISOString().slice(0, 10)}.${ext}`; a.download = defaultPath;
a.click(); a.click();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} }

View File

@@ -33,14 +33,24 @@ export function suggestedFilename(item: TranscriptEntry): string {
export async function saveTranscriptAsMarkdown( export async function saveTranscriptAsMarkdown(
item: TranscriptEntry, item: TranscriptEntry,
): Promise<string | null> { ): Promise<string | null> {
if (!hasTauriRuntime()) return null; if (!hasTauriRuntime()) {
toasts.error("Export unavailable", "Native save dialogs are only available in the desktop app.");
return null;
}
const md = buildMarkdown(item); const md = buildMarkdown(item);
const defaultPath = suggestedFilename(item); const defaultPath = suggestedFilename(item);
const path = await save({ let path: string | null = null;
title: "Save transcript as Markdown", try {
defaultPath, path = await save({
filters: [{ name: "Markdown", extensions: ["md"] }], title: "Save transcript as Markdown",
}); defaultPath,
filters: [{ name: "Markdown", extensions: ["md"] }],
});
} catch (err) {
console.error("saveTranscriptAsMarkdown dialog failed", err);
toasts.error("Couldn't open save dialog", String(err));
return null;
}
if (!path) return null; if (!path) return null;
try { try {
await invoke("write_text_file_cmd", { path, contents: md }); await invoke("write_text_file_cmd", { path, contents: md });
@@ -64,13 +74,24 @@ export async function saveTranscriptAsMarkdown(
export async function exportTranscriptsToDir( export async function exportTranscriptsToDir(
items: TranscriptEntry[], items: TranscriptEntry[],
): Promise<number> { ): Promise<number> {
if (!hasTauriRuntime() || items.length === 0) return 0; if (items.length === 0) return 0;
if (!hasTauriRuntime()) {
toasts.error("Export unavailable", "Native folder dialogs are only available in the desktop app.");
return 0;
}
const dir = await open({ let dir: string | string[] | null = null;
directory: true, try {
multiple: false, dir = await open({
title: "Choose export folder", directory: true,
}); multiple: false,
title: "Choose export folder",
});
} catch (err) {
console.error("exportTranscriptsToDir dialog failed", err);
toasts.error("Couldn't open export folder dialog", String(err));
return 0;
}
if (!dir || typeof dir !== "string") return 0; if (!dir || typeof dir !== "string") return 0;
const used = new Set<string>(); const used = new Set<string>();