Compare commits
2 Commits
b0d4ac8de1
...
pre-lumoti
| Author | SHA1 | Date | |
|---|---|---|---|
| 2ca01e7c9d | |||
| 1d71e8e361 |
@@ -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"
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
try {
|
||||||
|
path = await save({
|
||||||
title: "Save transcript as Markdown",
|
title: "Save transcript as Markdown",
|
||||||
defaultPath,
|
defaultPath,
|
||||||
filters: [{ name: "Markdown", extensions: ["md"] }],
|
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;
|
||||||
|
try {
|
||||||
|
dir = await open({
|
||||||
directory: true,
|
directory: true,
|
||||||
multiple: false,
|
multiple: false,
|
||||||
title: "Choose export folder",
|
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>();
|
||||||
|
|||||||
Reference in New Issue
Block a user