From 3649d72c067394a0df19b60ddc4c7b37ec460b0f Mon Sep 17 00:00:00 2001 From: nikazzio Date: Sat, 6 Jun 2026 00:58:02 +0200 Subject: [PATCH 1/2] refactor all memory phrase --- docs/ARCHITECTURE.md | 56 ++- src-tauri/src/lib.rs | 3 +- src-tauri/src/llm/pipeline.rs | 84 ++++ src-tauri/src/vector/embedding.rs | 189 ++------ src/components/document/MemoryTab.tsx | 227 ++++++---- src/components/library/PromptTemplatesTab.tsx | 58 +-- .../pipeline/PhraseMemoryConfig.test.tsx | 163 +++---- .../pipeline/PhraseMemoryConfig.tsx | 356 ++++++--------- src/components/pipeline/PipelineConfig.tsx | 9 +- src/components/pipeline/SettingsTabPanel.tsx | 22 +- .../PhraseMemoryPresetManager.test.tsx | 119 ----- .../settings/PhraseMemoryPresetManager.tsx | 189 -------- src/components/settings/PresetForm.test.tsx | 90 ---- src/components/settings/PresetForm.tsx | 159 ------- src/components/settings/index.ts | 2 - .../workspace/MemoryExtractorSettings.tsx | 210 +++++++++ .../workspace/WorkspaceSettingsModal.tsx | 39 +- src/constants.ts | 16 + src/hooks/usePhraseMemoryAutoSearch.test.ts | 96 ++-- src/hooks/usePhraseMemoryAutoSearch.ts | 83 +++- src/hooks/usePhraseMemoryMatches.test.ts | 1 + src/hooks/usePipeline.test.ts | 2 + src/hooks/useSaveToMemory.test.ts | 28 +- src/hooks/useSaveToMemory.ts | 11 +- src/i18n/en.json | 19 +- src/i18n/it.json | 17 +- .../__tests__/phraseMemoryService.test.ts | 257 ++++++----- src/services/backupService.ts | 9 +- src/services/dbService.test.ts | 20 +- src/services/dbService.ts | 95 ++-- src/services/llmService.ts | 3 +- src/services/phraseMemoryInjection.test.ts | 2 +- .../phraseMemoryPresetService.test.ts | 44 -- src/services/phraseMemoryPresetService.ts | 109 ----- src/services/phraseMemoryService.ts | 421 +++++++++--------- src/services/pipelineService.test.ts | 37 +- src/services/pipelineService.ts | 44 +- src/services/promptTemplateService.ts | 9 +- src/services/workspaceService.test.ts | 1 - src/services/workspaceService.ts | 65 ++- .../__tests__/phraseMemoryStore.test.ts | 1 + src/stores/phraseMemoryStore.ts | 2 + src/stores/pipelineStore.ts | 4 + src/stores/projectStore.test.ts | 10 +- src/stores/promptTemplateStore.ts | 6 +- src/stores/workspaceStore.ts | 5 +- src/types.ts | 37 +- src/utils/memoryPreLaunchCheck.test.ts | 4 +- 48 files changed, 1524 insertions(+), 1909 deletions(-) delete mode 100644 src/components/settings/PhraseMemoryPresetManager.test.tsx delete mode 100644 src/components/settings/PhraseMemoryPresetManager.tsx delete mode 100644 src/components/settings/PresetForm.test.tsx delete mode 100644 src/components/settings/PresetForm.tsx create mode 100644 src/components/workspace/MemoryExtractorSettings.tsx delete mode 100644 src/services/phraseMemoryPresetService.test.ts delete mode 100644 src/services/phraseMemoryPresetService.ts diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e0dac55..2d27983 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -66,7 +66,7 @@ | `components/pipeline/StageCard.tsx` | Visualizza singolo stage (token, retry info) | | `components/document/ConfigDrawer.tsx` | Drawer config pipeline: mode, lingue, stage, persona, glossary | | `components/layout/Header.tsx` | Project/pipeline selector | -| `components/workspace/WorkspaceHome.tsx` | Dashboard workspace: switch/create/config workspace, progetti, preset Phrase Memory workspace | +| `components/workspace/WorkspaceHome.tsx` | Dashboard workspace: switch/create/config workspace, progetti, configurazione extractor Phrase Memory | | `components/workspace/WorkspaceWizard.tsx` | Primo avvio: crea il primo workspace reale | --- @@ -78,8 +78,8 @@ Glossa 2.0 separa tre livelli: | Livello | Dove si configura | Cosa contiene | |---|---|---| | App | `SettingsModal` | Provider/API key, Ollama, segmentazione default, layout, backup/pricing | -| Workspace traduzioni | `WorkspaceHome` | Progetti di traduzione, modello embedding, preset Phrase Memory, memoria condivisa | -| Pipeline/progetto | `ConfigDrawer` | Lingue, persona, stage, prompt, glossario assegnato, toggle Phrase Memory | +| Workspace traduzioni | `WorkspaceHome` | Progetti di traduzione, modello embedding, extractor Phrase Memory, memoria condivisa | +| Pipeline/progetto | `ConfigDrawer` | Lingue, persona, stage, prompt, glossario assegnato, toggle/search Phrase Memory | Il workspace attuale è specifico per l'area **Traduzioni**. Biblioteca e Trascrizioni sono future macro-aree separate; non devono condividere implicitamente la Phrase Memory delle traduzioni. @@ -204,7 +204,7 @@ flushPendingTokenBatch() → un solo setState per frame (O(1) chunk update) | File | Responsabilità | |---|---| | `src-tauri/src/lib.rs` | Entry point Tauri, registrazione comandi, StreamRegistry state | -| `src-tauri/src/llm/pipeline.rs` | Comandi Tauri: run_stage, run_stage_stream, judge_translation, run_coherence_for_chunk, preflight_pipeline, compute_blobs, cancel_stream | +| `src-tauri/src/llm/pipeline.rs` | Comandi Tauri: run_stage, run_stage_stream, judge_translation, run_coherence_for_chunk, preflight_pipeline, compute_blobs, extract_phrase_memory_pairs, cancel_stream | | `src-tauri/src/llm/blobs.rs` | Algoritmo assegnazione blob (globale vs finestre) | | `src-tauri/src/llm/prompts.rs` | Costruzione prompt 3-block, glossario, markdown rules, persona | | `src-tauri/src/llm/provider.rs` | Trait LlmProvider, struct LlmRequest | @@ -216,6 +216,38 @@ flushPendingTokenBatch() → un solo setState per frame (O(1) chunk update) --- +## Phrase Memory + +**Configurazione:** +- Workspace: `memoryExtractorProvider`, `memoryExtractorModel`, `memoryExtractorPrompt`; prompt templates con context `memory`. +- Pipeline: `usePhraseMemory`, `autoSearchPhraseMemory`, `phraseMemorySimilarityThreshold`, `phraseMemoryMaxResults`. + +**Salvataggio memoria:** +``` +Chunk originale + draft/traduzione finale + ↓ +phraseMemoryService.savePhrasePairs() + ↓ +Tauri: extract_phrase_memory_pairs(provider, model, prompt, sourceText, targetText, languages) + ↓ +LLM JSON mode → { pairs: [{ sourcePhrase, targetPhrase, confidence }] } + ↓ +Validazione verbatim frontend + backend su source e target + ↓ +Embedding solo su sourcePhrase + ↓ +vec_save_locked_phrases(..., confidence) +``` + +Non esiste fallback locale: se extractor, JSON parsing o validazione falliscono, il chunk non salva coppie. Le coppie vecchie e i preset vengono purgati dal bump schema perché il formato precedente non è compatibile. + +**Ricerca memoria:** +- Auto-search parte solo se `usePhraseMemory` è attivo e `autoSearchPhraseMemory !== false`. +- Il tab Memory può sempre lanciare refresh manuale per il chunk corrente quando la memoria è abilitata. +- La query embedding usa solo il testo sorgente del chunk; i match selezionati sono gli unici iniettati nel prompt di run/rerun. + +--- + ## Schema DB (SQLite) ``` @@ -226,6 +258,7 @@ projects workspaces id, name, description, embedding_model, created_at + memory_extractor_provider, memory_extractor_model, memory_extractor_prompt active_workspace_id vive in app_settings pipelines ← multi-pipeline per progetto (feat/multi-pipeline) @@ -237,6 +270,8 @@ pipelines ← multi-pipeline per progetto (feat/multi-pipeline) persona, custom_source_language, custom_target_language blob_budget_tokens, blob_overlap review_provider_options JSON + use_phrase_memory, auto_search_phrase_memory + phrase_memory_similarity_threshold, phrase_memory_max_results run_status ('idle'|'running'|'completed'|'interrupted') last_run_config JSON (fingerprint per resume) created_at, updated_at @@ -256,7 +291,7 @@ glossaries / glossary_entries / project_glossaries CRUD standard, many-to-many project↔glossary prompt_templates - id, name, prompt, context ('stage'|'audit'|'persona') + id, name, prompt, context ('stage'|'audit'|'persona'|'memory') default_model, default_provider operation_logs @@ -272,11 +307,7 @@ app_settings phrase_memory id, workspace_id FK, source_phrase, target_phrase source_language, target_language, author, work, domain, tags, notes - chunk_id, project_id, embedding, created_at - -phrase_memory_presets - id, workspace_id nullable, name, is_builtin, config JSON, created_at - built-in globali + custom scoped al workspace + chunk_id, project_id, confidence, embedding, created_at source_phrase_embeddings id, project_id, chunk_id, source_phrase, embedding, created_at @@ -284,7 +315,7 @@ source_phrase_embeddings **Persistito vs in-memory:** - ✅ Persistito: source, config, stage_results, translations, run_status, operation_logs -- ✅ Persistito workspace: progetti, preset Phrase Memory custom, memoria frasi +- ✅ Persistito workspace: progetti, configurazione extractor Phrase Memory, memoria frasi - ❌ Solo in-memory: token stream real-time (ricostruito da stage_results su resume) --- @@ -316,7 +347,6 @@ source_phrase_embeddings | Area | Descrizione | Priorità | |---|---|---| | `hooks/usePipeline.ts` | 3 blocchi blob assembler identici da estrarre in helper condiviso | bassa (cosmesi) | -| `services/phraseMemoryService.ts` → `savePhrasePairs()` | Zip silenzioso source↔target: se lo splitter produce lunghezze diverse, le frasi extra vengono scartate senza notifica all'utente. Da segnalare in UI o loggare nel dettaglio. | media | | `src-tauri/src/llm/providers/anthropic.rs` | Supporto reasoning da rivedere (verifica integrazione, parametri, formato risposta) | alta | | `src-tauri/src/llm/providers/deepseek.rs` | Supporto reasoning da rivedere (verifica integrazione, parametri, formato risposta) | alta | | `src-tauri/src/llm/providers/gemini.rs` | Supporto reasoning da rivedere (verifica integrazione, parametri, formato risposta) | alta | @@ -324,4 +354,4 @@ source_phrase_embeddings --- -*Ultimo aggiornamento: 2026-06-04 — branch feat/phrase-memory* +*Ultimo aggiornamento: 2026-06-06 — branch feat/phrase-memory* diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index bebbdd5..e114de8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -73,6 +73,7 @@ pub fn run() { llm::pipeline::test_provider_connection, llm::pipeline::preflight_pipeline, llm::pipeline::run_coherence_for_chunk, + llm::pipeline::extract_phrase_memory_pairs, keystore::save_api_key, keystore::get_api_key_status, keystore::delete_api_key, @@ -85,8 +86,6 @@ pub fn run() { documents::extract_pdf_text, vector::vec_ping, vector::embedding::get_embeddings, - vector::embedding::split_phrases_llm, - vector::embedding::vec_upsert_source_phrase, vector::embedding::vec_list_phrase_memory, vector::embedding::vec_delete_phrase_memory, vector::embedding::vec_update_phrase_memory, diff --git a/src-tauri/src/llm/pipeline.rs b/src-tauri/src/llm/pipeline.rs index 7ece424..87eb486 100644 --- a/src-tauri/src/llm/pipeline.rs +++ b/src-tauri/src/llm/pipeline.rs @@ -48,6 +48,20 @@ pub struct StageResult { pub cache_miss_input_tokens: Option, } +#[derive(serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MemoryExtractorPair { + pub source_phrase: String, + pub target_phrase: String, + pub confidence: f64, +} + +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MemoryExtractorResponse { + pub pairs: Vec, +} + #[tauri::command] #[allow(clippy::too_many_arguments)] pub async fn run_stage( @@ -354,6 +368,76 @@ pub async fn refine_prompt( prov.call(&client, &req).await.map(|r| r.content) } +#[tauri::command] +#[allow(clippy::too_many_arguments)] +pub async fn extract_phrase_memory_pairs( + app: AppHandle, + provider: String, + model: String, + prompt: String, + source_text: String, + target_text: String, + source_language: String, + target_language: String, + ollama_base_url: Option, +) -> Result { + let prov = get_provider(&provider, ollama_base_url)?; + prov.preflight(&model).await?; + let api_key = get_api_key(&app, &provider)?; + let client = prov.http_client()?; + let structured = crate::llm::types::StructuredPrompt { + system: vec![crate::llm::types::PromptBlock { + text: prompt, + cacheable: false, + }], + user: format!( + "Source language: {source_language}\nTarget language: {target_language}\n\nOriginal source chunk:\n<<>>\n\nFinal/current translation:\n<<>>\n\nReturn JSON only with key \"pairs\"." + ), + }; + let req = LlmRequest { + model: &model, + structured: &structured, + api_key: &api_key, + json_mode: true, + provider_options: None, + }; + + let result_text = prov.call(&client, &req).await?.content; + let sanitized = sanitize_llm_json_output(&result_text); + let parsed: serde_json::Value = serde_json::from_str(sanitized) + .map_err(|e| format!("Failed to parse phrase memory JSON: {e}"))?; + let pairs = parsed["pairs"] + .as_array() + .ok_or_else(|| "Phrase memory extractor returned JSON without a pairs array".to_string())?; + + let validated = pairs + .iter() + .filter_map(|entry| { + let source_phrase = entry["sourcePhrase"].as_str()?.trim(); + let target_phrase = entry["targetPhrase"].as_str()?.trim(); + if source_phrase.is_empty() || target_phrase.is_empty() { + return None; + } + if !source_text.contains(source_phrase) || !target_text.contains(target_phrase) { + log::warn!( + "phrase_memory.extract_pairs.discard_non_verbatim source_chars={} target_chars={}", + source_phrase.len(), + target_phrase.len() + ); + return None; + } + let confidence = entry["confidence"].as_f64()?.clamp(0.0, 1.0); + Some(MemoryExtractorPair { + source_phrase: source_phrase.to_string(), + target_phrase: target_phrase.to_string(), + confidence, + }) + }) + .collect(); + + Ok(MemoryExtractorResponse { pairs: validated }) +} + #[tauri::command] pub async fn run_coherence_for_chunk( app: AppHandle, diff --git a/src-tauri/src/vector/embedding.rs b/src-tauri/src/vector/embedding.rs index 42809ec..4d54d2b 100644 --- a/src-tauri/src/vector/embedding.rs +++ b/src-tauri/src/vector/embedding.rs @@ -64,6 +64,7 @@ fn ensure_phrase_memory_schema(conn: &rusqlite::Connection) -> Result<(), Embedd workspace_id TEXT NOT NULL REFERENCES workspaces(id), source_phrase TEXT NOT NULL, target_phrase TEXT NOT NULL, + confidence REAL NOT NULL DEFAULT 1.0, source_language TEXT NOT NULL, target_language TEXT NOT NULL, author TEXT, @@ -77,7 +78,17 @@ fn ensure_phrase_memory_schema(conn: &rusqlite::Connection) -> Result<(), Embedd created_at TEXT NOT NULL );", ) - .map_err(|e| EmbeddingError::Http(e.to_string())) + .map_err(|e| EmbeddingError::Http(e.to_string()))?; + if let Err(err) = conn.execute( + "ALTER TABLE phrase_memory ADD COLUMN confidence REAL NOT NULL DEFAULT 1.0", + [], + ) { + let message = err.to_string(); + if !message.contains("duplicate column") && !message.contains("already exists") { + return Err(EmbeddingError::Http(message)); + } + } + Ok(()) } // ── OpenAI response types ──────────────────────────────────────────── @@ -170,137 +181,13 @@ pub async fn get_embeddings( Ok(parsed.data.into_iter().map(|o| o.embedding).collect()) } -#[tauri::command] -pub async fn split_phrases_llm( - app: tauri::AppHandle, - source_text: String, -) -> Result, EmbeddingError> { - let request_started = Instant::now(); - log::debug!( - "phrase_memory.split_phrases_llm.start source_chars={}", - source_text.len() - ); - let api_key = - crate::keystore::get_api_key(&app, "openai").map_err(|_| EmbeddingError::MissingApiKey)?; - if api_key.is_empty() { - return Err(EmbeddingError::MissingApiKey); - } - - let prompt = format!( - "Split the following text into individual sentences or meaningful phrases. \ - Return a JSON object with a single key \"phrases\" whose value is an array of strings. \ - Each string must be an exact verbatim copy from the source text — no paraphrasing, \ - no added punctuation. \ - Example: {{\"phrases\": [\"First sentence.\", \"Second phrase\"]}}\n\nText:\n{source_text}" - ); - - let body = serde_json::json!({ - "model": "gpt-4o-mini", - "messages": [{"role": "user", "content": prompt}], - "temperature": 0, - "response_format": {"type": "json_object"} - }); - - let response = openai_client()? - .post("https://api.openai.com/v1/chat/completions") - .bearer_auth(&api_key) - .json(&body) - .send() - .await - .map_err(|e| { - log::warn!( - "phrase_memory.split_phrases_llm.request_failed source_chars={} elapsed_ms={} error={e}", - source_text.len(), - request_started.elapsed().as_millis() - ); - EmbeddingError::Http(e.to_string()) - })?; - - if !response.status().is_success() { - let status = response.status(); - let text = response.text().await.unwrap_or_default(); - let preview: String = text.chars().take(500).collect(); - log::warn!( - "phrase_memory.split_phrases_llm.http_error source_chars={} elapsed_ms={} status={status} body_preview={preview:?}", - source_text.len(), - request_started.elapsed().as_millis() - ); - return Err(EmbeddingError::Http(format!("{status}: {text}"))); - } - - let json: serde_json::Value = response.json().await.map_err(|e| { - log::warn!( - "phrase_memory.split_phrases_llm.parse_failed source_chars={} elapsed_ms={} error={e}", - source_text.len(), - request_started.elapsed().as_millis() - ); - EmbeddingError::Parse(e.to_string()) - })?; - - let content = json["choices"][0]["message"]["content"] - .as_str() - .ok_or_else(|| EmbeddingError::Parse("missing content".into()))?; - - let parsed: serde_json::Value = - serde_json::from_str(content).map_err(|e| EmbeddingError::Parse(e.to_string()))?; - - let arr = parsed - .get("phrases") - .and_then(|v| v.as_array()) - .ok_or_else(|| EmbeddingError::Parse("expected object with \"phrases\" array".into()))?; - - let validated: Vec = arr - .iter() - .filter_map(|v| v.as_str()) - .filter(|phrase| { - let ok = source_text.contains(*phrase); - if !ok { - log::warn!("split_phrases_llm: discarding non-verbatim phrase: {phrase:?}"); - } - ok - }) - .map(|s| s.to_string()) - .collect(); - - log::debug!( - "phrase_memory.split_phrases_llm.done source_chars={} phrase_count={} elapsed_ms={}", - source_text.len(), - validated.len(), - request_started.elapsed().as_millis() - ); - Ok(validated) -} - -#[tauri::command] -pub async fn vec_upsert_source_phrase( - app: tauri::AppHandle, - project_id: String, - chunk_id: String, - phrase: String, - embedding: Vec, -) -> Result<(), EmbeddingError> { - let path = db_path(&app)?; - let conn = crate::vector::open_vec_connection(&path) - .map_err(|e| EmbeddingError::Http(e.to_string()))?; - ensure_source_phrase_embeddings_schema(&conn)?; - - conn.execute( - "INSERT OR REPLACE INTO source_phrase_embeddings \ - (id, project_id, chunk_id, source_phrase, embedding, created_at) \ - VALUES (lower(hex(randomblob(16))), ?1, ?2, ?3, ?4, datetime('now'))", - rusqlite::params![project_id, chunk_id, phrase, floats_to_blob(&embedding)], - ) - .map_err(|e| EmbeddingError::Http(e.to_string()))?; - - Ok(()) -} - #[derive(Debug, Serialize, Deserialize)] pub struct PhraseMatchResult { pub phrase_memory_id: String, pub source_phrase: String, pub target_phrase: String, pub distance: f64, + pub confidence: f64, } #[derive(Debug, Serialize, Deserialize)] @@ -309,6 +196,7 @@ pub struct PhraseMemoryEntryResult { pub workspace_id: String, pub source_phrase: String, pub target_phrase: String, + pub confidence: f64, pub source_language: String, pub target_language: String, pub author: Option, @@ -333,7 +221,7 @@ pub async fn vec_list_phrase_memory( let mut stmt = conn .prepare( - "SELECT id, workspace_id, source_phrase, target_phrase, source_language, target_language, \ + "SELECT id, workspace_id, source_phrase, target_phrase, confidence, source_language, target_language, \ author, work, domain, tags, notes, chunk_id, project_id, created_at \ FROM phrase_memory \ WHERE workspace_id = ?1 \ @@ -348,16 +236,17 @@ pub async fn vec_list_phrase_memory( workspace_id: row.get(1)?, source_phrase: row.get(2)?, target_phrase: row.get(3)?, - source_language: row.get(4)?, - target_language: row.get(5)?, - author: row.get(6)?, - work: row.get(7)?, - domain: row.get(8)?, - tags: row.get(9)?, - notes: row.get(10)?, - chunk_id: row.get(11)?, - project_id: row.get(12)?, - created_at: row.get(13)?, + confidence: row.get(4)?, + source_language: row.get(5)?, + target_language: row.get(6)?, + author: row.get(7)?, + work: row.get(8)?, + domain: row.get(9)?, + tags: row.get(10)?, + notes: row.get(11)?, + chunk_id: row.get(12)?, + project_id: row.get(13)?, + created_at: row.get(14)?, }) }) .map_err(|e| EmbeddingError::Http(e.to_string()))? @@ -437,12 +326,12 @@ pub async fn vec_search_phrase_memory( let mut stmt = conn .prepare( "WITH ranked AS ( \ - SELECT pm.id, pm.source_phrase, pm.target_phrase, \ + SELECT pm.id, pm.source_phrase, pm.target_phrase, pm.confidence, \ vec_distance_cosine(pm.embedding, ?1) AS distance \ FROM phrase_memory pm \ WHERE pm.workspace_id = ?2 \ ) \ - SELECT id, source_phrase, target_phrase, distance \ + SELECT id, source_phrase, target_phrase, confidence, distance \ FROM ranked \ WHERE distance < ?3 \ ORDER BY distance ASC \ @@ -458,7 +347,8 @@ pub async fn vec_search_phrase_memory( phrase_memory_id: row.get(0)?, source_phrase: row.get(1)?, target_phrase: row.get(2)?, - distance: row.get(3)?, + confidence: row.get(3)?, + distance: row.get(4)?, }) }, ) @@ -473,6 +363,7 @@ pub async fn vec_search_phrase_memory( pub struct PhrasePair { pub source_phrase: String, pub target_phrase: String, + pub confidence: f64, pub source_embedding: Vec, } @@ -484,13 +375,12 @@ pub async fn vec_save_locked_phrases( project_id: String, chunk_id: String, pairs: Vec, - min_phrase_length: u32, source_language: String, target_language: String, ) -> Result { let save_started = Instant::now(); log::debug!( - "phrase_memory.vec_save_locked_phrases.start workspace_id={workspace_id} project_id={project_id} chunk_id={chunk_id} pair_count={} min_phrase_length={min_phrase_length}", + "phrase_memory.vec_save_locked_phrases.start workspace_id={workspace_id} project_id={project_id} chunk_id={chunk_id} pair_count={}", pairs.len() ); if pairs.is_empty() { @@ -555,31 +445,26 @@ pub async fn vec_save_locked_phrases( let mut saved: u32 = 0; let mut attempted: u32 = 0; - let mut skipped_short: u32 = 0; let tx = conn.transaction().map_err(|e| { log::warn!("phrase_memory.vec_save_locked_phrases.transaction_failed error={e}"); EmbeddingError::Http(e.to_string()) })?; for (index, pair) in pairs.iter().enumerate() { - if (pair.source_phrase.len() as u32) < min_phrase_length { - skipped_short += 1; - continue; - } - attempted += 1; let rows = tx .execute( "INSERT OR IGNORE INTO phrase_memory \ (id, workspace_id, project_id, chunk_id, source_phrase, target_phrase, \ - source_language, target_language, embedding, created_at) \ - VALUES (lower(hex(randomblob(16))), ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, datetime('now'))", + confidence, source_language, target_language, embedding, created_at) \ + VALUES (lower(hex(randomblob(16))), ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, datetime('now'))", rusqlite::params![ &workspace_id, &project_id, &chunk_id, pair.source_phrase, pair.target_phrase, + pair.confidence.clamp(0.0, 1.0), &source_language, &target_language, floats_to_blob(&pair.source_embedding) @@ -598,7 +483,7 @@ pub async fn vec_save_locked_phrases( saved += rows as u32; } log::debug!( - "phrase_memory.vec_save_locked_phrases.insert_loop_done pair_count={} attempted={attempted} skipped_short={skipped_short} saved={saved}", + "phrase_memory.vec_save_locked_phrases.insert_loop_done pair_count={} attempted={attempted} saved={saved}", pairs.len() ); @@ -622,7 +507,7 @@ pub async fn vec_save_locked_phrases( })?; log::info!( - "phrase_memory.vec_save_locked_phrases.done workspace_id={workspace_id} project_id={project_id} chunk_id={chunk_id} pair_count={} attempted={attempted} skipped_short={skipped_short} saved={saved} elapsed_ms={}", + "phrase_memory.vec_save_locked_phrases.done workspace_id={workspace_id} project_id={project_id} chunk_id={chunk_id} pair_count={} attempted={attempted} saved={saved} elapsed_ms={}", pairs.len(), save_started.elapsed().as_millis() ); diff --git a/src/components/document/MemoryTab.tsx b/src/components/document/MemoryTab.tsx index 6df0e09..f8a902d 100644 --- a/src/components/document/MemoryTab.tsx +++ b/src/components/document/MemoryTab.tsx @@ -1,10 +1,13 @@ import { AlertCircle, BookPlus, Brain, Check, Clipboard, Database, Loader2, RefreshCcw } from 'lucide-react'; +import type { ReactNode } from 'react'; import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { toast } from 'sonner'; +import { usePhraseMemoryAutoSearch } from '../../hooks/usePhraseMemoryAutoSearch'; import { usePhraseMemoryMatches } from '../../hooks/usePhraseMemoryMatches'; import { useSaveToMemory } from '../../hooks/useSaveToMemory'; import { usePhraseMemoryStore, type PhraseMemoryMatch } from '../../stores/phraseMemoryStore'; +import { IconButton, PillButton, SectionLabel } from '../ui'; import { ExtractTermDialog } from './ExtractTermDialog'; interface MemoryTabProps { @@ -20,8 +23,18 @@ export function MemoryTab({ panelId, labelledBy, currentChunkId, onRerun }: Memo usePhraseMemoryMatches(currentChunkId); const [extractingMatch, setExtractingMatch] = useState(null); const { saveToMemory, isSaving, progress } = useSaveToMemory(); + const { runSearchForChunk } = usePhraseMemoryAutoSearch({ auto: false }); const searchStatus = usePhraseMemoryStore((s) => s.searchStatus); + const handleRefresh = async () => { + if (!currentChunkId) return; + try { + await runSearchForChunk(currentChunkId); + } catch { + toast.error(t('memory.searchFailed')); + } + }; + const handleSaveToMemory = async () => { try { const savedCount = await saveToMemory(currentChunkId ? [currentChunkId] : []); @@ -35,88 +48,80 @@ export function MemoryTab({ panelId, labelledBy, currentChunkId, onRerun }: Memo } }; - const saveButton = ( - - ); - const searchStatusRow = searchStatus === 'searching' ? ( -
- - {t('memory.searching')} -
+ } label={t('memory.searching')} /> ) : searchStatus === 'error' ? ( -
- - {t('memory.searchFailed')} -
+ } label={t('memory.searchFailed')} tone="error" /> ) : null; - if (!hasMatches) { - return ( -
- -

- {t('memory.coldStartTitle')} -

-

- {t('memory.coldStartBody')} -

- {searchStatusRow} - {saveButton} -
- ); - } - return ( -
-
+
+
+
+ +
+ void handleRefresh()} + disabled={!currentChunkId || searchStatus === 'searching'} + > + + +
+
{searchStatusRow} -

- {t('memory.selectionHint')} +

+ {hasMatches ? t('memory.selectionHint') : t('memory.coldStartBody')}

-
- {matches.map((match) => ( - toggleEnabled(match.id)} - onExtractTerm={() => setExtractingMatch(match)} - /> - ))} -
-
-
+ ) : ( +
+ +

+ {t('memory.coldStartTitle')} +

+
+ )} + +
+ onRerun(selectedMatches)} disabled={selectedMatches.length === 0} - className="w-full flex items-center justify-center gap-2 rounded-full border border-editorial-accent bg-editorial-accent/10 px-4 py-2 text-sm font-medium text-editorial-accent transition-colors hover:bg-editorial-accent/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-editorial-accent disabled:cursor-not-allowed disabled:opacity-30" - aria-label={t('memory.rerunButton')} + variant="accent" + className="flex w-full items-center justify-center gap-2 py-2" > {t('memory.rerunButton')} - -
{saveButton}
+
+
+ void handleSaveToMemory()} + disabled={isSaving || !currentChunkId} + variant="secondary" + className="inline-flex items-center justify-center gap-2" + > + {isSaving + ? + : } + {isSaving && progress + ? `${progress.done}/${progress.total}` + : t('memory.saveToMemoryButton')} + +
{extractingMatch && ( @@ -131,6 +136,26 @@ export function MemoryTab({ panelId, labelledBy, currentChunkId, onRerun }: Memo ); } +function StatusRow({ + icon, + label, + tone = 'neutral', +}: { + icon: ReactNode; + label: string; + tone?: 'neutral' | 'error'; +}) { + const colorClass = tone === 'error' + ? 'border-editorial-accent/35 bg-editorial-accent/8 text-editorial-accent' + : 'border-editorial-border bg-editorial-textbox/35 text-editorial-muted'; + return ( +
+ {icon} + {label} +
+ ); +} + interface MatchCardProps { match: PhraseMemoryMatch; enabled: boolean; @@ -142,7 +167,7 @@ function MatchCard({ match, enabled, onToggle, onExtractTerm }: MatchCardProps) const { t } = useTranslation(); const [copied, setCopied] = useState(false); - const handleApply = async () => { + const handleCopy = async () => { try { await navigator.clipboard.writeText(match.targetPhrase); setCopied(true); @@ -154,54 +179,60 @@ function MatchCard({ match, enabled, onToggle, onExtractTerm }: MatchCardProps) }; return ( -
+
-
+
- {(match.author ?? match.work) && ( - - {[match.author, match.work].filter(Boolean).join(' — ')} + + {t('memory.enableMatch')} - )} + + + {Math.round(match.score * 100)}% +
-
- {match.sourcePhrase} +
+

+ {t('memory.sourcePhraseLabel')} +

+
+ {match.sourcePhrase} +
-
- {match.targetPhrase} +
+

+ {t('glossary.translation')} +

+
+ {match.targetPhrase} +
-
- - +
); diff --git a/src/components/library/PromptTemplatesTab.tsx b/src/components/library/PromptTemplatesTab.tsx index 9a6510d..7d6e2d4 100644 --- a/src/components/library/PromptTemplatesTab.tsx +++ b/src/components/library/PromptTemplatesTab.tsx @@ -1,17 +1,18 @@ import React, { useEffect, useState } from 'react'; -import { Trash2, BookmarkPlus, Check, X, Wand2, Loader2, LayoutGrid, Languages, Scale, Bot } from 'lucide-react'; +import { Trash2, BookmarkPlus, Check, X, Wand2, Loader2, LayoutGrid, Languages, Scale, Bot, Brain } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { toast } from 'sonner'; import { confirm } from '../../stores/confirmStore'; import { usePromptTemplateStore } from '../../stores/promptTemplateStore'; import { useUiStore } from '../../stores/uiStore'; import { usePipelineStore } from '../../stores/pipelineStore'; -import type { ModelProvider } from '../../types'; +import type { ModelProvider, PromptTemplateContext } from '../../types'; import { llmService } from '../../services/llmService'; import { getSelectableModelIds, MODEL_PROVIDER_ORDER } from '../../models/catalog'; import { canRefineWithProvider, formatProviderModelLabel, useProviderKeyStatus } from '../../hooks/useProviderKeyStatus'; +import { IconButton } from '../ui'; -const FILTER_OPTIONS = ['all', 'stage', 'audit', 'persona'] as const; +const FILTER_OPTIONS = ['all', 'stage', 'audit', 'persona', 'memory'] as const; type FilterValue = (typeof FILTER_OPTIONS)[number]; const FILTER_ICONS: Record = { @@ -19,6 +20,7 @@ const FILTER_ICONS: Record = { stage: , audit: , persona: , + memory: , }; export function PromptTemplatesTab() { @@ -28,7 +30,7 @@ export function PromptTemplatesTab() { const { config } = usePipelineStore(); const [newName, setNewName] = useState(''); const [newPrompt, setNewPrompt] = useState(''); - const [newContext, setNewContext] = useState<'stage' | 'audit' | 'persona'>('stage'); + const [newContext, setNewContext] = useState('stage'); const [creating, setCreating] = useState(false); const [filterContext, setFilterContext] = useState('all'); const [isRefining, setIsRefining] = useState(false); @@ -51,18 +53,21 @@ export function PromptTemplatesTab() { if (value === 'all') return t('common.all'); if (value === 'stage') return t('pipeline.tabStages'); if (value === 'audit') return t('pipeline.tabAudit'); + if (value === 'memory') return t('workspace.settings.memoryTab'); return t('pipeline.tabPersona'); }; - const contextLabel = (context: 'stage' | 'audit' | 'persona') => { + const contextLabel = (context: PromptTemplateContext) => { if (context === 'audit') return t('pipeline.tabAudit'); if (context === 'persona') return t('pipeline.tabPersona'); + if (context === 'memory') return t('workspace.settings.memoryTab'); return t('pipeline.tabStages'); }; - const contextBadgeClass = (context: 'stage' | 'audit' | 'persona') => { + const contextBadgeClass = (context: PromptTemplateContext) => { if (context === 'audit') return 'bg-editorial-warning/20 text-editorial-warning'; if (context === 'persona') return 'bg-editorial-textbox/60 text-editorial-muted'; + if (context === 'memory') return 'bg-editorial-success/15 text-editorial-success'; return 'bg-editorial-accent/20 text-editorial-accent'; }; @@ -134,19 +139,16 @@ export function PromptTemplatesTab() { {FILTER_OPTIONS.map((ctx) => { const isActive = filterContext === ctx; return ( - + ); })}
- +
{creating && ( @@ -176,25 +177,28 @@ export function PromptTemplatesTab() { />
-

+

{newContext === 'audit' ? t('library.templateAuditHint') : newContext === 'persona' ? t('library.templatePersonaHint') + : newContext === 'memory' + ? t('library.templateMemoryHint') : t('library.templateStageHint')}

- + {t('pipeline.prompt')}
@@ -205,7 +209,7 @@ export function PromptTemplatesTab() { setRefineProvider(p); setRefineModel(getProviderModels(p)[0] ?? ''); }} - className="rounded-full border border-editorial-border bg-editorial-bg px-3 py-1.5 text-[10px] font-mono outline-none focus-visible:ring-2 focus-visible:ring-editorial-accent" + className="rounded-full border border-editorial-border bg-editorial-bg px-3 py-1.5 text-xs font-mono outline-none focus-visible:ring-2 focus-visible:ring-editorial-accent" > {MODEL_PROVIDER_ORDER.map((p) => ( @@ -214,7 +218,7 @@ export function PromptTemplatesTab() { handlePresetChange(e.target.value)} - disabled={disabled || presets.length === 0} - className="w-full appearance-none rounded-[12px] border border-editorial-border bg-editorial-bg/80 px-3 py-2 text-xs font-mono outline-none focus-visible:ring-2 focus-visible:ring-editorial-accent disabled:opacity-40" - > - {presets.map((p) => ( - - ))} - -
- -
- - - {showAdvanced && ( -
-
-

- Splitter frasi -

-
- {(['regex', 'llm', 'none'] as PhraseMemorySplitter[]).map((v) => ( - - ))} -
-
- -
- - patchOverride('similarityThreshold', parseFloat(e.target.value))} - disabled={disabled} - className="w-full accent-editorial-accent disabled:opacity-40" - aria-label="soglia similarità" - /> -
- -
- - - patchOverride('maxResults', Math.max(1, parseInt(e.target.value) || 1)) - } - disabled={disabled} - className="w-32 rounded-[12px] border border-editorial-border bg-editorial-bg/80 px-3 py-2 text-xs font-mono outline-none focus-visible:ring-2 focus-visible:ring-editorial-accent disabled:opacity-40" - /> -
- -
- - - patchOverride('minPhraseLength', Math.max(1, parseInt(e.target.value) || 1)) - } - disabled={disabled} - className="w-32 rounded-[12px] border border-editorial-border bg-editorial-bg/80 px-3 py-2 text-xs font-mono outline-none focus-visible:ring-2 focus-visible:ring-editorial-accent disabled:opacity-40" - /> -
+ onChange={() => emit({ usePhraseMemory: !usePhraseMemory })} + /> + + {usePhraseMemory && ( + <> + } + label="Auto-search" + checked={autoSearchPhraseMemory} + disabled={disabled} + onChange={() => emit({ autoSearchPhraseMemory: !autoSearchPhraseMemory })} + /> + +
+ + emit({ phraseMemorySimilarityThreshold: parseFloat(e.target.value) })} + disabled={disabled} + className="w-full accent-editorial-accent disabled:opacity-40" + aria-label="Similarity threshold" + /> +
+ +
+ + + emit({ phraseMemoryMaxResults: Math.max(1, parseInt(e.target.value, 10) || 1) }) + } + disabled={disabled} + className="w-32 rounded-[12px] border border-editorial-border bg-editorial-bg/80 px-3 py-2 text-xs font-mono outline-none focus-visible:ring-2 focus-visible:ring-editorial-accent disabled:opacity-40" + /> +
+ + {!autoSearchPhraseMemory && ( +
+ + Manual refresh remains available in the chunk Memory panel.
)} -
-
- )} + + )} +
+
+ ); +} + +function ToggleRow({ + icon, + label, + checked, + disabled, + onChange, +}: { + icon: ReactNode; + label: string; + checked: boolean; + disabled: boolean; + onChange: () => void; +}) { + return ( +
+
+ {icon} + {label} +
+
); } diff --git a/src/components/pipeline/PipelineConfig.tsx b/src/components/pipeline/PipelineConfig.tsx index 70a91ee..f964169 100644 --- a/src/components/pipeline/PipelineConfig.tsx +++ b/src/components/pipeline/PipelineConfig.tsx @@ -449,10 +449,11 @@ export function PipelineConfig({ keyStatusLoading={keyStatusLoading} missingRefineProviders={missingRefineProviders} usePhraseMemory={config.usePhraseMemory ?? false} - phraseMemoryPresetId={config.phraseMemoryPresetId ?? null} - phraseMemoryOverrides={config.phraseMemoryOverrides ?? null} - onPhraseMemoryChange={({ usePhraseMemory, phraseMemoryPresetId, phraseMemoryOverrides }) => - setConfig((prev) => ({ ...prev, usePhraseMemory, phraseMemoryPresetId, phraseMemoryOverrides })) + autoSearchPhraseMemory={config.autoSearchPhraseMemory !== false} + phraseMemorySimilarityThreshold={config.phraseMemorySimilarityThreshold ?? 0.75} + phraseMemoryMaxResults={config.phraseMemoryMaxResults ?? 10} + onPhraseMemoryChange={(memoryConfig) => + setConfig((prev) => ({ ...prev, ...memoryConfig })) } /> )} diff --git a/src/components/pipeline/SettingsTabPanel.tsx b/src/components/pipeline/SettingsTabPanel.tsx index 9674a9d..e46366c 100644 --- a/src/components/pipeline/SettingsTabPanel.tsx +++ b/src/components/pipeline/SettingsTabPanel.tsx @@ -1,7 +1,7 @@ import { ArrowRightLeft, FileText, Globe, KeyRound, Languages, Layers, ShieldCheck, Wand2 } from 'lucide-react'; import type { Dispatch, SetStateAction } from 'react'; import { useTranslation } from 'react-i18next'; -import type { PipelineConfig, PipelineMode, PromptTemplate, PhraseMemoryOverrides } from '../../types'; +import type { PipelineConfig, PipelineMode, PromptTemplate } from '../../types'; import type { SaveTemplateFn } from '../../stores/promptTemplateStore'; import { LANGUAGES } from '../../constants'; import { IconButton, SectionLabel } from '../ui'; @@ -24,12 +24,14 @@ interface SettingsTabPanelProps { keyStatusLoading: boolean; missingRefineProviders: string[]; usePhraseMemory: boolean; - phraseMemoryPresetId: string | null | undefined; - phraseMemoryOverrides: PhraseMemoryOverrides | null | undefined; + autoSearchPhraseMemory: boolean; + phraseMemorySimilarityThreshold: number; + phraseMemoryMaxResults: number; onPhraseMemoryChange: (value: { usePhraseMemory: boolean; - phraseMemoryPresetId: string | null; - phraseMemoryOverrides: PhraseMemoryOverrides | null; + autoSearchPhraseMemory: boolean; + phraseMemorySimilarityThreshold: number; + phraseMemoryMaxResults: number; }) => void; } @@ -49,8 +51,9 @@ export function SettingsTabPanel({ keyStatusLoading, missingRefineProviders, usePhraseMemory, - phraseMemoryPresetId, - phraseMemoryOverrides, + autoSearchPhraseMemory, + phraseMemorySimilarityThreshold, + phraseMemoryMaxResults, onPhraseMemoryChange, }: SettingsTabPanelProps) { const { t } = useTranslation(); @@ -202,8 +205,9 @@ export function SettingsTabPanel({ diff --git a/src/components/settings/PhraseMemoryPresetManager.test.tsx b/src/components/settings/PhraseMemoryPresetManager.test.tsx deleted file mode 100644 index 265d9b4..0000000 --- a/src/components/settings/PhraseMemoryPresetManager.test.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -vi.mock('../../services/phraseMemoryPresetService', () => ({ - listPresets: vi.fn(), - createCustomPreset: vi.fn(), - updateCustomPreset: vi.fn(), - deleteCustomPreset: vi.fn(), - clonePreset: vi.fn(), -})); - -vi.mock('../../stores/workspaceStore', () => ({ - useWorkspaceStore: vi.fn((sel) => sel({ activeWorkspace: { id: 'ws-test', name: 'Test', embeddingModel: 'text-embedding-3-small', createdAt: '' } })), -})); - -import * as presetService from '../../services/phraseMemoryPresetService'; -import type { PhraseMemoryPreset } from '../../types'; -import { PhraseMemoryPresetManager } from './PhraseMemoryPresetManager'; - -const builtInPreset: PhraseMemoryPreset = { - id: 'preset-default', - name: 'Predefinito', - isBuiltin: true, - config: { splitter: 'regex', similarityThreshold: 0.75, maxResults: 10, minPhraseLength: 3 }, - createdAt: '2026-01-01T00:00:00Z', -}; - -const customPreset: PhraseMemoryPreset = { - id: 'preset-custom-1', - name: 'Mio preset', - isBuiltin: false, - config: { splitter: 'llm', similarityThreshold: 0.8, maxResults: 5, minPhraseLength: 4 }, - createdAt: '2026-01-01T00:00:00Z', -}; - -describe('PhraseMemoryPresetManager', () => { - beforeEach(() => { - vi.clearAllMocks(); - vi.mocked(presetService.listPresets).mockResolvedValue([builtInPreset, customPreset]); - }); - - it('mostra i preset caricati dal servizio', async () => { - render(); - await waitFor(() => { - expect(screen.getByText('Predefinito')).toBeInTheDocument(); - expect(screen.getByText('Mio preset')).toBeInTheDocument(); - }); - }); - - it('mostra badge Built-in sul preset built-in', async () => { - render(); - await waitFor(() => expect(screen.getByText('Built-in')).toBeInTheDocument()); - }); - - it('mostra bottone Clona sul built-in, non Elimina', async () => { - render(); - await waitFor(() => { - expect(screen.getByRole('button', { name: /clona/i })).toBeInTheDocument(); - }); - const deleteButtons = screen.queryAllByRole('button', { name: /elimina/i }); - expect(deleteButtons).toHaveLength(1); - }); - - it('clonare un preset built-in chiama clonePreset e ricarica la lista', async () => { - vi.mocked(presetService.clonePreset).mockResolvedValue('preset-cloned'); - vi.mocked(presetService.listPresets) - .mockResolvedValueOnce([builtInPreset, customPreset]) - .mockResolvedValueOnce([ - builtInPreset, - customPreset, - { ...customPreset, id: 'preset-cloned', name: 'Predefinito (copia)' }, - ]); - render(); - await waitFor(() => screen.getByText('Predefinito')); - fireEvent.click(screen.getByRole('button', { name: /clona/i })); - await waitFor(() => - expect(presetService.clonePreset).toHaveBeenCalledWith('preset-default', 'ws-test'), - ); - }); - - it('eliminare un preset custom chiama deleteCustomPreset e ricarica', async () => { - vi.mocked(presetService.deleteCustomPreset).mockResolvedValue(undefined); - render(); - await waitFor(() => screen.getByText('Mio preset')); - fireEvent.click(screen.getByRole('button', { name: /elimina/i })); - await waitFor(() => - expect(presetService.deleteCustomPreset).toHaveBeenCalledWith('preset-custom-1', 'ws-test'), - ); - }); - - it('mostra il form di creazione quando si clicca su "Nuovo preset"', async () => { - render(); - await waitFor(() => screen.getByText('Predefinito')); - fireEvent.click(screen.getByRole('button', { name: /nuovo preset/i })); - expect(screen.getByLabelText(/nome/i)).toBeInTheDocument(); - }); - - it('crea un preset custom e ricarica la lista', async () => { - vi.mocked(presetService.createCustomPreset).mockResolvedValue({ - id: 'preset-new', - name: 'Tecnico', - isBuiltin: false, - config: { splitter: 'regex', similarityThreshold: 0.75, maxResults: 10, minPhraseLength: 3 }, - createdAt: '2026-01-01T00:00:00Z', - }); - render(); - await waitFor(() => screen.getByText('Predefinito')); - fireEvent.click(screen.getByRole('button', { name: /nuovo preset/i })); - fireEvent.change(screen.getByLabelText(/nome/i), { target: { value: 'Tecnico' } }); - fireEvent.click(screen.getByRole('button', { name: /salva/i })); - await waitFor(() => - expect(presetService.createCustomPreset).toHaveBeenCalledWith( - 'Tecnico', - expect.objectContaining({ splitter: expect.any(String) }), - 'ws-test', - ), - ); - }); -}); diff --git a/src/components/settings/PhraseMemoryPresetManager.tsx b/src/components/settings/PhraseMemoryPresetManager.tsx deleted file mode 100644 index ea110b0..0000000 --- a/src/components/settings/PhraseMemoryPresetManager.tsx +++ /dev/null @@ -1,189 +0,0 @@ -import { useCallback, useEffect, useState } from 'react'; -import { Copy, Pencil, Plus, Trash2 } from 'lucide-react'; -import { toast } from 'sonner'; -import { - clonePreset, - createCustomPreset, - deleteCustomPreset, - listPresets, - updateCustomPreset, -} from '../../services/phraseMemoryPresetService'; -import { useWorkspaceStore } from '../../stores/workspaceStore'; -import type { PhraseMemoryPreset, PhraseMemoryPresetConfig } from '../../types'; -import { IconButton } from '../ui'; -import { PresetForm } from './PresetForm'; - -const DEFAULT_CONFIG: PhraseMemoryPresetConfig = { - splitter: 'regex', - similarityThreshold: 0.75, - maxResults: 10, - minPhraseLength: 3, -}; - -type FormMode = - | { type: 'closed' } - | { type: 'create' } - | { type: 'edit'; preset: PhraseMemoryPreset }; - -export function PhraseMemoryPresetManager() { - const [presets, setPresets] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const [formMode, setFormMode] = useState({ type: 'closed' }); - const activeWorkspace = useWorkspaceStore((s) => s.activeWorkspace); - - const reload = useCallback(async () => { - if (!activeWorkspace) return; - try { - const data = await listPresets(activeWorkspace.id); - setPresets(data); - } catch (err: unknown) { - toast.error('Errore caricamento preset', { - description: err instanceof Error ? err.message : String(err), - }); - } finally { - setIsLoading(false); - } - }, [activeWorkspace]); - - useEffect(() => { void reload(); }, [reload]); - - const handleClone = async (preset: PhraseMemoryPreset) => { - if (!activeWorkspace) return; - try { - await clonePreset(preset.id, activeWorkspace.id); - await reload(); - toast.success(`"${preset.name}" clonato`); - } catch (err: unknown) { - toast.error('Clonazione fallita', { - description: err instanceof Error ? err.message : String(err), - }); - } - }; - - const handleDelete = async (preset: PhraseMemoryPreset) => { - if (!activeWorkspace) return; - try { - await deleteCustomPreset(preset.id, activeWorkspace.id); - await reload(); - toast.success(`"${preset.name}" eliminato`); - } catch (err: unknown) { - toast.error('Eliminazione fallita', { - description: err instanceof Error ? err.message : String(err), - }); - } - }; - - const handleCreate = async (name: string, config: PhraseMemoryPresetConfig) => { - if (!activeWorkspace) return; - try { - await createCustomPreset(name, config, activeWorkspace.id); - setFormMode({ type: 'closed' }); - await reload(); - toast.success(`Preset "${name}" creato`); - } catch (err: unknown) { - toast.error('Creazione fallita', { - description: err instanceof Error ? err.message : String(err), - }); - } - }; - - const handleEdit = async (name: string, config: PhraseMemoryPresetConfig) => { - if (formMode.type !== 'edit' || !activeWorkspace) return; - try { - await updateCustomPreset(formMode.preset.id, name, config, activeWorkspace.id); - setFormMode({ type: 'closed' }); - await reload(); - toast.success(`Preset "${name}" aggiornato`); - } catch (err: unknown) { - toast.error('Aggiornamento fallito', { - description: err instanceof Error ? err.message : String(err), - }); - } - }; - - return ( -
- {isLoading ? ( -

Caricamento…

- ) : ( -
- {presets.map((preset) => ( -
-
- - {preset.name} - - {preset.isBuiltin && ( - - Built-in - - )} -
-
- {preset.isBuiltin ? ( - handleClone(preset)} - aria-label="clona" - > - - - ) : ( - <> - setFormMode({ type: 'edit', preset })} - aria-label="modifica" - > - - - handleDelete(preset)} - aria-label="elimina" - > - - - - )} -
-
- ))} -
- )} - - {formMode.type !== 'closed' && ( -
-

- {formMode.type === 'create' ? 'Nuovo preset' : 'Modifica preset'} -

- setFormMode({ type: 'closed' })} - /> -
- )} - - {formMode.type === 'closed' && ( - - )} -
- ); -} diff --git a/src/components/settings/PresetForm.test.tsx b/src/components/settings/PresetForm.test.tsx deleted file mode 100644 index 76943a2..0000000 --- a/src/components/settings/PresetForm.test.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import { render, screen, fireEvent } from '@testing-library/react'; -import { describe, it, expect, vi } from 'vitest'; -import { PresetForm } from './PresetForm'; -import type { PhraseMemoryPresetConfig } from '../../types'; - -const defaultConfig: PhraseMemoryPresetConfig = { - splitter: 'regex', - similarityThreshold: 0.75, - maxResults: 10, - minPhraseLength: 3, -}; - -describe('PresetForm', () => { - it('renders with initial values', () => { - render( - , - ); - expect(screen.getByDisplayValue('My Preset')).toBeInTheDocument(); - expect(screen.getByRole('radio', { name: /regex/i })).toBeChecked(); - expect(screen.getByDisplayValue('10')).toBeInTheDocument(); - expect(screen.getByDisplayValue('3')).toBeInTheDocument(); - }); - - it('calls onSubmit with updated values when form is submitted', () => { - const onSubmit = vi.fn(); - render( - , - ); - fireEvent.change(screen.getByLabelText(/nome/i), { target: { value: 'Nuovo preset' } }); - fireEvent.click(screen.getByRole('radio', { name: /llm/i })); - fireEvent.click(screen.getByRole('button', { name: /salva/i })); - expect(onSubmit).toHaveBeenCalledWith( - 'Nuovo preset', - expect.objectContaining({ splitter: 'llm' }), - ); - }); - - it('does not submit when name is empty', () => { - const onSubmit = vi.fn(); - render( - , - ); - fireEvent.click(screen.getByRole('button', { name: /salva/i })); - expect(onSubmit).not.toHaveBeenCalled(); - expect(screen.getByText(/nome obbligatorio/i)).toBeInTheDocument(); - }); - - it('calls onCancel when cancel button is clicked', () => { - const onCancel = vi.fn(); - render( - , - ); - fireEvent.click(screen.getByRole('button', { name: /annulla/i })); - expect(onCancel).toHaveBeenCalled(); - }); - - it('enforces similarityThreshold between 0.5 and 1.0', () => { - render( - , - ); - const slider = screen.getByRole('slider'); - expect(slider).toHaveAttribute('min', '0.5'); - expect(slider).toHaveAttribute('max', '1'); - }); -}); diff --git a/src/components/settings/PresetForm.tsx b/src/components/settings/PresetForm.tsx deleted file mode 100644 index e90386b..0000000 --- a/src/components/settings/PresetForm.tsx +++ /dev/null @@ -1,159 +0,0 @@ -import { useState } from 'react'; -import type { PhraseMemoryPresetConfig, PhraseMemorySplitter } from '../../types'; - -interface PresetFormProps { - initialName: string; - initialConfig: PhraseMemoryPresetConfig; - onSubmit: (name: string, config: PhraseMemoryPresetConfig) => void; - onCancel: () => void; -} - -const SPLITTER_OPTIONS: Array<{ value: PhraseMemorySplitter; label: string }> = [ - { value: 'regex', label: 'Regex' }, - { value: 'llm', label: 'LLM' }, - { value: 'none', label: 'Nessuno' }, -]; - -export function PresetForm({ initialName, initialConfig, onSubmit, onCancel }: PresetFormProps) { - const [name, setName] = useState(initialName); - const [nameError, setNameError] = useState(null); - const [config, setConfig] = useState(initialConfig); - - const handleSubmit = () => { - if (!name.trim()) { - setNameError('Nome obbligatorio'); - return; - } - onSubmit(name.trim(), config); - }; - - const update = ( - key: K, - value: PhraseMemoryPresetConfig[K], - ) => setConfig((prev) => ({ ...prev, [key]: value })); - - return ( -
-
- - { - setName(e.target.value); - if (e.target.value.trim()) setNameError(null); - }} - className="w-full rounded-[14px] border border-editorial-border bg-editorial-bg/80 px-3 py-2 text-sm font-mono outline-none focus-visible:ring-2 focus-visible:ring-editorial-accent" - placeholder="Es. Terminologia tecnica" - /> - {nameError && ( -

{nameError}

- )} -
- -
-

- Splitter frasi -

-
- {SPLITTER_OPTIONS.map(({ value, label }) => ( - - ))} -
-
- -
- - update('similarityThreshold', parseFloat(e.target.value))} - className="w-full accent-editorial-accent" - /> -
- 0.50 - 1.00 -
-
- -
- - update('maxResults', Math.max(1, parseInt(e.target.value) || 1))} - className="w-full rounded-[14px] border border-editorial-border bg-editorial-bg/80 px-3 py-2 text-sm font-mono outline-none focus-visible:ring-2 focus-visible:ring-editorial-accent" - /> -
- -
- - update('minPhraseLength', Math.max(1, parseInt(e.target.value) || 1))} - className="w-full rounded-[14px] border border-editorial-border bg-editorial-bg/80 px-3 py-2 text-sm font-mono outline-none focus-visible:ring-2 focus-visible:ring-editorial-accent" - /> -
- -
- - -
-
- ); -} diff --git a/src/components/settings/index.ts b/src/components/settings/index.ts index a481e77..13a9a7a 100644 --- a/src/components/settings/index.ts +++ b/src/components/settings/index.ts @@ -1,4 +1,2 @@ export { SettingsModal } from './SettingsModal'; export { ApiKeyInput } from './ApiKeyInput'; -export { PhraseMemoryPresetManager } from './PhraseMemoryPresetManager'; -export { PresetForm } from './PresetForm'; diff --git a/src/components/workspace/MemoryExtractorSettings.tsx b/src/components/workspace/MemoryExtractorSettings.tsx new file mode 100644 index 0000000..2afb27f --- /dev/null +++ b/src/components/workspace/MemoryExtractorSettings.tsx @@ -0,0 +1,210 @@ +import { useEffect, useMemo, useState } from 'react'; +import { BookmarkPlus, Brain, Check, Cpu, Loader2, RotateCcw, Wand2 } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'sonner'; +import { DEFAULT_MEMORY_EXTRACTOR_MODEL, DEFAULT_MEMORY_EXTRACTOR_PROMPT, DEFAULT_MEMORY_EXTRACTOR_PROVIDER } from '../../constants'; +import { useProviderKeyStatus, canRefineWithProvider, formatProviderModelLabel } from '../../hooks/useProviderKeyStatus'; +import { getModelStatus, getSelectableModelIds, MODEL_PROVIDER_ORDER } from '../../models/catalog'; +import { llmService } from '../../services/llmService'; +import { usePromptTemplateStore } from '../../stores/promptTemplateStore'; +import { useUiStore } from '../../stores/uiStore'; +import type { ModelProvider, PromptTemplate } from '../../types'; +import { IconButton, PillButton, SectionLabel } from '../ui'; + +interface MemoryExtractorSettingsProps { + provider: ModelProvider; + model: string; + prompt: string; + onProviderChange: (provider: ModelProvider, model: string) => void; + onModelChange: (model: string) => void; + onPromptChange: (prompt: string) => void; +} + +export function MemoryExtractorSettings({ + provider, + model, + prompt, + onProviderChange, + onModelChange, + onPromptChange, +}: MemoryExtractorSettingsProps) { + const { t } = useTranslation(); + const ollamaModels = useUiStore((s) => s.ollamaModels); + const { templates, isLoaded, loadTemplates, saveTemplate } = usePromptTemplateStore(); + const { statuses: keyStatuses } = useProviderKeyStatus(); + const [isRefining, setIsRefining] = useState(false); + const [templateName, setTemplateName] = useState(''); + + useEffect(() => { + if (!isLoaded) void loadTemplates(); + }, [isLoaded, loadTemplates]); + + const modelOptions = getSelectableModelIds(provider, ollamaModels); + const memoryTemplates = useMemo( + () => templates.filter((template) => template.context === 'memory'), + [templates], + ); + const canRefine = canRefineWithProvider(provider, keyStatuses); + const refineLabel = formatProviderModelLabel(provider, model); + + const handleProviderChange = (nextProvider: ModelProvider) => { + const nextModels = getSelectableModelIds(nextProvider, ollamaModels); + onProviderChange(nextProvider, nextModels[0] ?? ''); + }; + + const handleRefine = async () => { + if (!prompt.trim() || !model.trim()) return; + setIsRefining(true); + try { + const refined = await llmService.refinePrompt(prompt, provider, model, 'memory'); + onPromptChange(refined); + toast.success(t('pipeline.refined')); + } catch (err: unknown) { + toast.error(t('pipeline.refineFailed'), { + description: err instanceof Error ? err.message : String(err), + }); + } finally { + setIsRefining(false); + } + }; + + const handleSaveTemplate = async () => { + const name = templateName.trim(); + if (!name || !prompt.trim()) return; + try { + await saveTemplate(name, prompt, 'memory', model, provider); + setTemplateName(''); + toast.success(t('pipeline.templates.saved')); + } catch (err: unknown) { + toast.error(t('errors.somethingWentWrong'), { + description: err instanceof Error ? err.message : String(err), + }); + } + }; + + const handleApplyTemplate = (template: PromptTemplate) => { + onPromptChange(template.prompt); + if (template.defaultProvider) { + const nextProvider = template.defaultProvider as ModelProvider; + onProviderChange(nextProvider, template.defaultModel ?? getSelectableModelIds(nextProvider, ollamaModels)[0] ?? ''); + return; + } + if (template.defaultModel) onModelChange(template.defaultModel); + }; + + return ( +
+
+ +
+ + {modelOptions.length > 0 ? ( + + ) : ( + onModelChange(e.target.value)} + placeholder={t('ollama.modelPlaceholder')} + className="rounded-[12px] border border-editorial-border bg-editorial-textbox/50 px-3 py-2 text-xs font-mono text-editorial-ink outline-none focus-visible:ring-2 focus-visible:ring-editorial-accent" + aria-label={t('workspace.memoryExtractorModel')} + /> + )} +
+
+ +
+
+ +
+ void handleRefine()} + disabled={isRefining || !prompt.trim() || !canRefine} + > + {isRefining ? : } + + { + onProviderChange(DEFAULT_MEMORY_EXTRACTOR_PROVIDER, DEFAULT_MEMORY_EXTRACTOR_MODEL); + onPromptChange(DEFAULT_MEMORY_EXTRACTOR_PROMPT); + }} + > + + +
+
+