diff --git a/avatar/assistant.py b/avatar/assistant.py
index 5f49d3d..7bbc48a 100644
--- a/avatar/assistant.py
+++ b/avatar/assistant.py
@@ -14,6 +14,7 @@ from .audio import Microphone, Player
from .engines.anthropic_engine import AnthropicEngine
from .engines.claude_code import ClaudeCodeEngine
from .engines.openai_compat import OpenAICompatEngine
+from .engines.mlx_engine import MLXEngine
from .settings import CONFIG_DIR, Settings
from .stt import Transcriber
from .tts import SentenceSplitter, clean_for_speech, make_voice
@@ -180,14 +181,18 @@ class Assistant:
s = self.settings
provider = s.get("provider")
self.name, self.user_name = _identity()
- key = (provider, s.get("effort"), s.get("local_base_url"), s.get("local_model"), s.get("search_api_key"),
+ key = (provider, s.get("effort"), s.get("local_base_url"), s.get("local_model"), s.get("mlx_model"), s.get("search_api_key"),
s.get("claudecode_model"), s.get("claudecode_access"), s.get("claudecode_config_dir"),
s.get("claudecode_path"), self.name, self.user_name, s.get_secret("anthropic_api_key")[-6:],
s.get_secret("local_api_key")[-4:])
if self._engine and self._engine_key == key:
return self._engine
- self._engine, self._engine_key = None, key
- if provider == "local":
+ old, self._engine, self._engine_key = self._engine, None, key
+ if old is not None and hasattr(old, "close"):
+ old.close() # libera il modello interno dalla memoria
+ if provider == "mlx":
+ self._engine = MLXEngine(s.get("mlx_model") or "", s.get("search_api_key"), self.name, self.user_name)
+ elif provider == "local":
if s.get("local_base_url") and s.get("local_model"):
self._engine = OpenAICompatEngine(s.get("local_base_url"), s.get("local_model"), s.get_secret("local_api_key"),
s.get("search_api_key"), self.name, self.user_name)
diff --git a/avatar/engines/mlx_engine.py b/avatar/engines/mlx_engine.py
new file mode 100644
index 0000000..0478e9d
--- /dev/null
+++ b/avatar/engines/mlx_engine.py
@@ -0,0 +1,414 @@
+"""Motore interno: carica un modello MLX (mlx-lm) dentro l'app, senza server esterni.
+
+Modelli da mlx-community (Hugging Face), scaricati nella cache locale al primo uso. Gli strumenti (memoria,
+plugin, ricerca web) passano dal template di chat del modello nel formato Hermes/Qwen:
+{"name": "...", "arguments": {...}}
+"""
+from __future__ import annotations
+
+import copy
+import json
+import re
+import threading
+from pathlib import Path
+
+from avatar.memory_tools import memory_prompt, openai_tools, parse_args, run_tool
+from avatar.plugins import registry
+from avatar.websearch import brave_search
+from .base import Emit, History, persona_text, today_label, user_block
+from .openai_compat import SEARCH_TOOL, Aborted
+
+MAX_ROUNDS = 8
+MAX_HISTORY = 30
+MAX_TOKENS = 3000
+DEFAULT_MODEL = "mlx-community/Qwen3-30B-A3B-Instruct-2507-4bit"
+HUB = Path.home() / ".cache" / "huggingface" / "hub"
+_EXCLUDE = ("whisper", "tts", "embed", "rerank", "clip", "vl-", "-vl", "vision", "diffusion", "parakeet")
+
+_lock = threading.Lock()
+_loaded: dict = {"name": None, "model": None, "tokenizer": None, "cache": None, "tokens": [], "snap": None}
+
+
+def cached_models() -> list[str]:
+ """Modelli MLX già scaricati nella cache di Hugging Face (esclusi voce/visione)."""
+ out = []
+ if HUB.exists():
+ for d in sorted(HUB.glob("models--*")):
+ name = d.name[len("models--"):].replace("--", "/")
+ low = name.lower()
+ if "mlx" in low and not any(x in low for x in _EXCLUDE) and (d / "snapshots").exists():
+ out.append(name)
+ return out
+
+
+def load_model(name: str):
+ """Carica (o riusa) il modello. Un solo modello in memoria per volta."""
+ with _lock:
+ if _loaded["name"] == name and _loaded["model"] is not None:
+ return _loaded["model"], _loaded["tokenizer"]
+ unload()
+ from mlx_lm import load
+ model, tokenizer = load(name)
+ _loaded.update(name=name, model=model, tokenizer=tokenizer, cache=None, tokens=[], snap=None)
+ return model, tokenizer
+
+
+def unload() -> None:
+ if _loaded["model"] is not None:
+ _loaded.update(name=None, model=None, tokenizer=None, cache=None, tokens=[], snap=None)
+ try:
+ import gc
+ import mlx.core as mx
+ gc.collect()
+ mx.clear_cache()
+ except Exception:
+ pass
+
+
+class TagFilter:
+ """Separa il testo visibile dai blocchi racchiusi tra `start` e `end` (pensieri o chiamate di strumenti)."""
+
+ def __init__(self, start: str, end: str, keep: bool) -> None:
+ self.start, self.end, self.keep = start, end, keep
+ self.inside = False
+ self.strip_next = False # dopo un blocco chiuso, via gli spazi iniziali del testo che segue
+ self.carry = ""
+ self.buf = ""
+ self.blocks: list[str] = []
+
+ def _emit(self, piece: str) -> str:
+ if self.strip_next:
+ piece = piece.lstrip()
+ if piece:
+ self.strip_next = False
+ return piece
+
+ def _partial(self, text: str, tag: str) -> int:
+ """Lunghezza del pezzo finale di `text` che potrebbe essere l'inizio di `tag`."""
+ for n in range(min(len(tag) - 1, len(text)), 0, -1):
+ if text.endswith(tag[:n]):
+ return n
+ return 0
+
+ def push(self, delta: str) -> str:
+ text, self.carry, out = self.carry + delta, "", ""
+ while text:
+ if self.inside:
+ e = text.find(self.end)
+ if e < 0:
+ n = self._partial(text, self.end)
+ self.buf += text[: len(text) - n] if n else text
+ self.carry = text[len(text) - n:] if n else ""
+ return out
+ self.buf += text[:e]
+ if self.keep:
+ self.blocks.append(self.buf.strip())
+ self.buf, self.inside, self.strip_next = "", False, True
+ text = text[e + len(self.end):]
+ else:
+ st = text.find(self.start)
+ if st < 0:
+ n = self._partial(text, self.start)
+ out += self._emit(text[: len(text) - n] if n else text)
+ self.carry = text[len(text) - n:] if n else ""
+ return out
+ out += self._emit(text[:st])
+ text = text[st + len(self.start):]
+ self.inside = True
+ return out
+
+ def flush(self) -> str:
+ if self.inside:
+ if self.keep and (self.buf + self.carry).strip():
+ self.blocks.append((self.buf + self.carry).strip())
+ self.buf = self.carry = ""
+ self.inside = False
+ return ""
+ c, self.carry = self.carry, ""
+ return self._emit(c)
+
+
+# Formati di chiamata: Hermes/Qwen (JSON in ) e Gemma 4 (call:nome{chiave:<|"|>testo<|"|>,...}).
+FORMATS = {
+ "hermes": {"think": ("", ""), "call": ("", "")},
+ "gemma4": {"think": ("<|channel>", ""), "call": ("<|tool_call>", "")},
+}
+GEMMA_ESC = '<|"|>'
+
+
+def detect_format(tokenizer) -> str:
+ try:
+ vocab = tokenizer.get_vocab()
+ except Exception:
+ vocab = {}
+ return "gemma4" if "<|tool_call>" in vocab else "hermes"
+
+
+def _gemma_to_json(body: str) -> str:
+ """Converte gli argomenti nel formato Gemma in JSON: stringhe tra <|"|>, chiavi nude."""
+ parts = body.split(GEMMA_ESC)
+ out = []
+ for i, part in enumerate(parts):
+ if i % 2 == 1:
+ out.append(json.dumps(part, ensure_ascii=False))
+ else:
+ out.append(re.sub(r'([{,\[]\s*)([A-Za-z_][\w.-]*)\s*:', r'\1"\2":', part))
+ return "".join(out)
+
+
+def _parse_call(raw: str, fmt: str) -> tuple[str, dict, str] | None:
+ """(nome, argomenti, argomenti grezzi come li ha scritti il modello)."""
+ raw = raw.strip()
+ if fmt == "gemma4" or raw.startswith("call:"):
+ m = re.match(r"call:\s*([\w.-]+)\s*(\{.*\})?\s*$", raw, re.S)
+ if not m:
+ return None
+ body = m.group(2) or "{}"
+ try:
+ args = json.loads(_gemma_to_json(body))
+ except Exception:
+ return None
+ return m.group(1), dict(args or {}), body[1:-1]
+ try:
+ d = json.loads(raw)
+ except Exception:
+ m = re.search(r"\{.*\}", raw, re.S)
+ if not m:
+ return None
+ try:
+ d = json.loads(m.group(0))
+ except Exception:
+ return None
+ if not isinstance(d, dict) or not d.get("name"):
+ return None
+ args = d.get("arguments", d.get("parameters", {}))
+ if isinstance(args, str):
+ args = parse_args(args)
+ return str(d["name"]), dict(args or {}), json.dumps(args or {}, ensure_ascii=False)
+
+
+class MLXEngine:
+ name = "mlx"
+
+ def __init__(self, model_name: str, search_api_key: str, assistant_name: str, user_name: str) -> None:
+ self.model_name = model_name or DEFAULT_MODEL
+ self.search_api_key = search_api_key
+ self.assistant_name, self.user_name = assistant_name, user_name
+ self.history = History("mlx")
+ self.model = self.tokenizer = None
+ self.fmt = "hermes"
+ self.load_error: str | None = None
+ threading.Thread(target=self._preload, daemon=True).start()
+
+ def _preload(self) -> None:
+ try:
+ self.model, self.tokenizer = load_model(self.model_name)
+ self.fmt = detect_format(self.tokenizer)
+ self._warm()
+ except Exception as err:
+ self.load_error = str(err)
+ print(f"[mlx] caricamento fallito: {err}")
+
+ def _warm(self) -> None:
+ """Pre-calcola la parte stabile del prompt (persona, memoria, strumenti, cronologia) e la fotografa."""
+ if self.model is None:
+ return
+ with _lock:
+ if _loaded["snap"] is not None:
+ return
+ saved = list(self.history.messages)
+ try:
+ renders = []
+ for dummy in ("a", "b"):
+ self.history.messages = saved + [{"role": "user", "content": dummy}]
+ renders.append(list(self.tokenizer.encode(self._prompt())))
+ finally:
+ self.history.messages = saved
+ n = 0
+ while n < min(len(renders[0]), len(renders[1])) and renders[0][n] == renders[1][n]:
+ n += 1
+ self._prefill(renders[0][:n], n)
+
+ def _prefill(self, tokens: list[int], cut: int):
+ """Cache che contiene esattamente `tokens`, riusando la fotografia precedente se ne è un'estensione.
+ La nuova fotografia viene scattata a `cut` (fine dell'ultimo messaggio): il turno successivo riparte da lì."""
+ from mlx_lm import stream_generate
+ from mlx_lm.models.cache import make_prompt_cache, trim_prompt_cache, can_trim_prompt_cache
+ snap, cache, fed = _loaded["snap"], None, 0
+ if snap and len(tokens) >= len(snap["tokens"]) and tokens[: len(snap["tokens"])] == snap["tokens"]:
+ cache, fed = copy.deepcopy(snap["cache"]), len(snap["tokens"])
+ elif _loaded["cache"] is not None and _loaded["tokens"]:
+ # cache viva del turno precedente: ritaglia al prefisso comune (solo se il modello lo consente)
+ old = _loaded["tokens"]
+ n = 0
+ while n < min(len(old), len(tokens)) and old[n] == tokens[n]:
+ n += 1
+ if n > 0 and can_trim_prompt_cache(_loaded["cache"]):
+ trim_prompt_cache(_loaded["cache"], len(old) - n)
+ cache, fed = _loaded["cache"], n
+ _loaded.update(cache=None, tokens=[])
+ if cache is None:
+ cache = make_prompt_cache(self.model)
+ # max_tokens=1: il token campionato non entra nella cache, che contiene quindi esattamente i token passati
+ if cut > fed:
+ for _ in stream_generate(self.model, self.tokenizer, tokens[fed:cut], max_tokens=1, prompt_cache=cache):
+ pass
+ _loaded["snap"] = {"tokens": list(tokens[:cut]), "cache": copy.deepcopy(cache)}
+ fed = cut
+ if len(tokens) > fed:
+ for _ in stream_generate(self.model, self.tokenizer, tokens[fed:], max_tokens=1, prompt_cache=cache):
+ pass
+ return cache
+
+ def close(self) -> None:
+ self.model = self.tokenizer = None
+ unload()
+
+ def reset(self) -> None:
+ self.history.clear()
+
+ def _ensure_loaded(self, emit: Emit) -> None:
+ if self.model is not None:
+ return
+ emit({"type": "status", "status": "working", "detail": "carico il modello"})
+ self.load_error = None
+ self.model, self.tokenizer = load_model(self.model_name)
+ self.fmt = detect_format(self.tokenizer)
+
+ def _system(self) -> str:
+ note = ("\n\nRegole sugli strumenti (obbligatorie):\n"
+ "- Quando l'utente ti chiede di ricordare qualcosa, o ti dice un fatto importante su di sé, DEVI chiamare salva_memoria prima di rispondere. Non dire mai di aver salvato senza averlo chiamato davvero.\n"
+ "- Per cancellare una memoria chiama dimentica_memoria; per cercarne una non presente nel prompt chiama cerca_memoria.\n"
+ "- Per azioni sul Mac (per esempio il calendario) usa gli strumenti dedicati. Se uno risponde con [CONFIRMATION_PENDING], chiedi all'utente di confermare sul pannello e non dire che è fatto.\n"
+ "- Non inventare mai dati reali (meteo, ora, calendario, mail, messaggi, file, stato dei server): se esiste uno strumento che li fornisce, chiamalo prima di rispondere.\n"
+ "- Chiama gli strumenti solo nel formato previsto dal tuo template e mai descrivendoli a parole.")
+ note += ("\n- Per informazioni aggiornate chiama cerca_web e rispondi in base ai risultati." if self.search_api_key
+ else "\n- Non hai accesso al web: se ti chiedono informazioni aggiornate, dillo chiaramente.")
+ return f"{persona_text(self.assistant_name)}\n\n{user_block(self.user_name, memory_prompt())}{note}"
+
+ def _recent(self) -> list:
+ msgs = self.history.messages
+ if len(msgs) <= MAX_HISTORY:
+ return msgs
+ start = len(msgs) - MAX_HISTORY
+ while start < len(msgs) and msgs[start].get("role") != "user":
+ start += 1
+ return msgs[start:]
+
+ def _tools(self) -> list:
+ return openai_tools() + registry.openai_tools() + ([SEARCH_TOOL] if self.search_api_key else [])
+
+ def _prompt(self, generation: bool = True) -> str:
+ """Prompt completo; con generation=False si ferma alla fine dell'ultimo messaggio (parte stabile)."""
+ messages = [{"role": "system", "content": self._system()}, *self._recent()]
+ try:
+ return self.tokenizer.apply_chat_template(messages, tools=self._tools(), add_generation_prompt=generation, tokenize=False, enable_thinking=False)
+ except TypeError:
+ # Template senza supporto strumenti: li descrive nel sistema.
+ messages[0]["content"] += "\n\nStrumenti disponibili (JSON):\n" + json.dumps(self._tools(), ensure_ascii=False) + \
+ "\nPer usarne uno scrivi solo: {\"name\": \"…\", \"arguments\": {…}}"
+ return self.tokenizer.apply_chat_template(messages, add_generation_prompt=generation, tokenize=False, enable_thinking=False)
+
+ def _prompt_tokens(self) -> tuple[list[int], int]:
+ """Token del prompt e lunghezza della parte stabile (comune al turno successivo)."""
+ tokens = list(self.tokenizer.encode(self._prompt()))
+ stable = list(self.tokenizer.encode(self._prompt(generation=False)))
+ cut = 0
+ while cut < min(len(tokens), len(stable)) and tokens[cut] == stable[cut]:
+ cut += 1
+ return tokens, cut
+
+ def _run_tool(self, name: str, args: dict, emit: Emit, sources: list) -> str:
+ if name == "cerca_web":
+ emit({"type": "status", "status": "searching"})
+ try:
+ hits = brave_search(str(args.get("query", "")), self.search_api_key)
+ except Exception as err:
+ return f"Errore nella ricerca: {err}"
+ for h in hits[:5]:
+ if all(s["url"] != h["url"] for s in sources):
+ sources.append({"title": h["title"], "url": h["url"]})
+ return "\n".join(f"{i+1}. {h['title']}\n {h['url']}\n {h['description']}" for i, h in enumerate(hits)) or "Nessun risultato."
+ if registry.has(name):
+ emit({"type": "status", "status": "working", "detail": name})
+ return registry.run(name, args)
+ emit({"type": "status", "status": "memory"})
+ res, ev = run_tool(name, args)
+ if ev:
+ emit(ev)
+ return res
+
+ def _generate(self, emit: Emit, abort: threading.Event, announced: list) -> tuple[str, list[str], bool]:
+ from mlx_lm import stream_generate
+ from mlx_lm.sample_utils import make_sampler
+ f = FORMATS[self.fmt]
+ think, tools, text, truncated = TagFilter(*f["think"], keep=False), TagFilter(*f["call"], keep=True), "", False
+ sampler = make_sampler(temp=0.7, top_p=0.8, top_k=20)
+ # Cache del prompt: la parte già vista (fotografia del turno precedente) non viene ricalcolata.
+ tokens, cut = self._prompt_tokens()
+ cache = self._prefill(tokens[:-1], min(cut, len(tokens) - 1))
+ generated: list[int] = []
+ try:
+ for r in stream_generate(self.model, self.tokenizer, tokens[-1:], max_tokens=MAX_TOKENS, sampler=sampler, prompt_cache=cache):
+ if abort.is_set():
+ raise Aborted()
+ generated.append(r.token)
+ visible = tools.push(think.push(r.text))
+ if visible:
+ if not announced:
+ announced.append(True)
+ emit({"type": "status", "status": "responding"})
+ text += visible
+ emit({"type": "text", "delta": visible})
+ if r.finish_reason == "length":
+ truncated = True
+ except Aborted:
+ raise
+ else:
+ # generate_step inserisce nella cache ogni token emesso, tranne l'ultimo quando si ferma per limite.
+ _loaded.update(cache=cache, tokens=tokens + (generated[:-1] if truncated else generated))
+ tail = tools.flush()
+ if tail:
+ text += tail
+ emit({"type": "text", "delta": tail})
+ return text, tools.blocks, truncated
+
+ def send(self, user_text: str, emit: Emit, abort: threading.Event, **_) -> None:
+ self.history.messages.append({"role": "user", "content": f"{user_text}\n\n(Adesso è {today_label()}.)"})
+ full, sources, announced = "", [], []
+ try:
+ self._ensure_loaded(emit)
+ emit({"type": "status", "status": "thinking"})
+ for rnd in range(MAX_ROUNDS):
+ with _lock:
+ round_text, raw_calls, truncated = self._generate(emit, abort, announced)
+ full += round_text
+ calls = [c for c in (_parse_call(r, self.fmt) for r in raw_calls) if c]
+ if not calls:
+ self.history.messages.append({"role": "assistant", "content": round_text.strip()})
+ break
+ self.history.messages.append({
+ "role": "assistant", "content": round_text.strip(),
+ "tool_calls": [{"id": f"call_{rnd}_{i}", "type": "function", "function": {"name": n, "arguments": raw}} for i, (n, a, raw) in enumerate(calls)],
+ })
+ for i, (n, a, raw) in enumerate(calls):
+ result = self._run_tool(n, a, emit, sources)
+ self.history.messages.append({"role": "tool", "tool_call_id": f"call_{rnd}_{i}", "name": n, "content": str(result)})
+ emit({"type": "status", "status": "thinking"})
+ if truncated:
+ break
+ full = full.strip() or "(nessuna risposta)"
+ self.history.save()
+ emit({"type": "done", "text": full, "sources": sources})
+ except Aborted:
+ self._rollback()
+ emit({"type": "error", "message": "Risposta interrotta.", "aborted": True})
+ except Exception as err:
+ self._rollback()
+ emit({"type": "error", "message": f"Modello interno: {err}"})
+
+ def _rollback(self) -> None:
+ msgs = self.history.messages
+ if msgs and msgs[-1].get("role") == "user":
+ msgs.pop()
+ self.history.save()
diff --git a/avatar/engines/openai_compat.py b/avatar/engines/openai_compat.py
index 5917306..03ad19b 100644
--- a/avatar/engines/openai_compat.py
+++ b/avatar/engines/openai_compat.py
@@ -98,7 +98,7 @@ class OpenAICompatEngine:
else "\n- Non hai accesso al web: se ti chiedono informazioni aggiornate, dillo chiaramente.")
else:
note = "\n\nNota: in questa modalità non hai strumenti (niente memoria automatica né ricerca web)."
- return {"role": "system", "content": f"{persona_text(self.assistant_name)}\n\n{user_block(self.user_name, memory_prompt())}\n\nAdesso è {today_label()}.{note}"}
+ return {"role": "system", "content": f"{persona_text(self.assistant_name)}\n\n{user_block(self.user_name, memory_prompt())}{note}"}
def _recent(self) -> list:
msgs = self.history.messages
@@ -109,6 +109,15 @@ class OpenAICompatEngine:
start += 1
return msgs[start:]
+ def _messages(self) -> list:
+ """Data e ora in coda all'ultimo messaggio utente: il prefisso resta uguale tra i turni (cache di vLLM)."""
+ msgs = [dict(m) for m in self._recent()]
+ for m in reversed(msgs):
+ if m.get("role") == "user":
+ m["content"] = f"{m['content']}\n\n(Adesso è {today_label()}.)"
+ break
+ return [self._system(), *msgs]
+
def _tools(self):
if not self._tools_supported:
return None
@@ -143,7 +152,7 @@ class OpenAICompatEngine:
emit({"type": "status", "status": "thinking"})
for rnd in range(MAX_ROUNDS):
tools = self._tools()
- kwargs: dict = dict(model=self.model, messages=[self._system(), *self._recent()], stream=True)
+ kwargs: dict = dict(model=self.model, messages=self._messages(), stream=True)
if tools:
kwargs.update(tools=tools, tool_choice="auto")
try:
diff --git a/avatar/settings.py b/avatar/settings.py
index 41bcbc3..c8f1f2c 100644
--- a/avatar/settings.py
+++ b/avatar/settings.py
@@ -13,10 +13,11 @@ DATA_DIR = BASE_DIR / "data"
KEYRING_SERVICE = "AvatarPy"
DEFAULTS: dict[str, Any] = {
- "provider": "anthropic", # anthropic | local | claudecode
+ "provider": "anthropic", # anthropic | local | claudecode | mlx
"effort": "medium", # low | medium | high
"local_base_url": "http://localhost:8000/v1",
"local_model": "",
+ "mlx_model": "mlx-community/Qwen3-30B-A3B-Instruct-2507-4bit", # motore interno (mlx-lm), repo Hugging Face o cartella
"search_api_key": "",
"claudecode_model": "sonnet",
"claudecode_access": "chat", # chat | read | full
@@ -127,4 +128,6 @@ class Settings:
return bool(self.get("local_base_url") and self.get("local_model"))
if p == "claudecode":
return True
+ if p == "mlx":
+ return bool(self.get("mlx_model"))
return bool(self.get_secret("anthropic_api_key"))
diff --git a/avatar/settings_dialog.py b/avatar/settings_dialog.py
index 02035d1..147e7d0 100644
--- a/avatar/settings_dialog.py
+++ b/avatar/settings_dialog.py
@@ -26,7 +26,8 @@ QPushButton#primary { background: #0a4a66; color: #ffffff; }
"""
PROVIDERS = [("anthropic", "Claude (Anthropic, cloud)"), ("local", "Server locale compatibile OpenAI (vLLM, Ollama…)"),
- ("claudecode", "Claude Code (il tuo accesso, nessuna chiave)")]
+ ("claudecode", "Claude Code (il tuo accesso, nessuna chiave)"),
+ ("mlx", "Modello interno (MLX, gira dentro l'app)")]
EFFORTS = [("low", "Veloce"), ("medium", "Bilanciata"), ("high", "Approfondita")]
ACCESS = [("chat", "Solo conversazione e ricerca web"), ("read", "Leggere file e cercare nelle cartelle"),
("full", "Completo: modifica file ed esegue comandi senza chiedere")]
@@ -73,6 +74,9 @@ class SettingsDialog(QDialog):
form.addRow("Motore", self.provider)
self.effort = _combo(EFFORTS, s.get("effort"))
form.addRow("Profondità di ragionamento (Claude e Claude Code)", self.effort)
+ self.search_key = QLineEdit(s.get("search_api_key")); self.search_key.setEchoMode(QLineEdit.EchoMode.Password)
+ self.search_key.setPlaceholderText("opzionale, per i motori locali: senza chiave la ricerca web resta spenta")
+ form.addRow("Chiave Brave Search (server locale e modello interno)", self.search_key)
add(pg_motore, form)
self.stack = QStackedWidget()
@@ -93,9 +97,6 @@ class SettingsDialog(QDialog):
b = QPushButton("Rileva"); b.clicked.connect(self._detect_models); row.addWidget(b)
f.addRow("Modello", row)
self.local_hint = QLabel("Premi \"Rileva\" per leggere i modelli dal server."); f.addRow("", self.local_hint)
- self.search_key = QLineEdit(s.get("search_api_key")); self.search_key.setEchoMode(QLineEdit.EchoMode.Password)
- self.search_key.setPlaceholderText("opzionale: senza chiave la ricerca web resta spenta")
- f.addRow("Chiave Brave Search", self.search_key)
self.stack.addWidget(w)
# Claude Code
w = QWidget(); f = QFormLayout(w)
@@ -109,6 +110,18 @@ class SettingsDialog(QDialog):
self.cc_path = QLineEdit(s.get("claudecode_path")); self.cc_path.setPlaceholderText("vuoto = automatico")
f.addRow("Percorso del comando claude", self.cc_path)
self.stack.addWidget(w)
+ # Modello interno (MLX)
+ from .engines.mlx_engine import cached_models, DEFAULT_MODEL
+ w = QWidget(); f = QFormLayout(w)
+ self.mlx_model = QComboBox(); self.mlx_model.setEditable(True)
+ self.mlx_model.addItems(cached_models() or [DEFAULT_MODEL])
+ self.mlx_model.setEditText(s.get("mlx_model") or DEFAULT_MODEL)
+ f.addRow("Modello (repo Hugging Face mlx-community)", self.mlx_model)
+ hint = QLabel("Elenco: modelli già scaricati. Un nome nuovo viene scaricato al primo uso. "
+ "Con 32 GB di RAM: modelli fino a ~20 GB in 4 bit (Qwen3-30B-A3B è veloce e supporta gli strumenti). "
+ "Non tenere aperto anche VLLMac con lo stesso modello.")
+ hint.setWordWrap(True); f.addRow("", hint)
+ self.stack.addWidget(w)
add(pg_motore, self.stack)
self.provider.currentIndexChanged.connect(self.stack.setCurrentIndex)
self.stack.setCurrentIndex(self.provider.currentIndex())
@@ -443,6 +456,7 @@ class SettingsDialog(QDialog):
values = {
"provider": self.provider.currentData(), "effort": self.effort.currentData(),
"local_base_url": self.local_url.text().strip().rstrip("/"), "local_model": self.local_model.currentText().strip(),
+ "mlx_model": self.mlx_model.currentText().strip(),
"search_api_key": self.search_key.text().strip(),
"claudecode_model": self.cc_model.currentData(), "claudecode_access": self.cc_access.currentData(),
"claudecode_config_dir": self.cc_config.currentText().strip(), "claudecode_path": self.cc_path.text().strip(),