ltcai 0.2.1 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -154,14 +154,17 @@ Based on public product behavior as of 2026-05.
154
154
  |-------|----------|------|---------|
155
155
  | Qwen3-VL 4B | Multimodal / low spec | ~2.7 GB | 8 GB |
156
156
  | Qwen3-VL 8B | Multimodal / balanced | ~4.8 GB | 16 GB |
157
+ | GPT-OSS 20B | Reasoning / open-weight | ~12.1 GB | 32 GB |
157
158
  | Gemma 4 26B | Multimodal / large | ~15.6 GB | 32 GB |
159
+ | Gemma 4 31B | Multimodal / latest Gemma 4 | ~18.4 GB | 48 GB |
158
160
  | Qwen3-VL 30B A3B | Multimodal / top | ~18 GB | 48 GB |
161
+ | GPT-OSS 120B | Reasoning / top open-weight | ~62.3 GB | 128 GB |
159
162
  | Phi 4 Mini | Coding (fast) | ~2.2 GB | 8 GB |
160
163
  | Llama 3.1 8B | General | ~4.7 GB | 8 GB |
161
164
  | Mistral 7B v0.3 | General / Apache | ~4.1 GB | 8 GB |
162
165
 
163
166
  **Cross-platform (Ollama / LM Studio / vLLM / llama.cpp):**
164
- Same models via Ollama pull, LM Studio download, or vLLM serve.
167
+ Same models via Ollama pull, LM Studio download, vLLM serve, or llama.cpp GGUF where available.
165
168
 
166
169
  **Cloud (any platform):**
167
170
  OpenAI GPT-5.5 · Claude Opus 4.7 / Sonnet 4.6 / Haiku 4.5 via OpenRouter · Groq · Together · xAI · any OpenAI-compatible endpoint
@@ -360,7 +363,7 @@ Full reference: [docs/mcp-tools.md](docs/mcp-tools.md)
360
363
  | VS Code Marketplace | [marketplace.visualstudio.com](https://marketplace.visualstudio.com/items?itemName=parktaesoo.ltcai) |
361
364
  | Open VSX | [open-vsx.org](https://open-vsx.org/extension/parktaesoo/ltcai) |
362
365
 
363
- Current version: **0.2.1** — [Changelog](docs/CHANGELOG.md)
366
+ Current version: **0.2.2** — [Changelog](docs/CHANGELOG.md)
364
367
 
365
368
  ---
366
369
 
@@ -416,8 +419,11 @@ LTCAI --tunnel # + Cloudflare 공개 URL 자동 발급
416
419
  |------|------|------|----------|
417
420
  | Qwen3-VL 4B | 멀티모달 / 저사양 | ~2.7GB | 8GB |
418
421
  | Qwen3-VL 8B | 멀티모달 / 균형 추천 | ~4.8GB | 16GB |
422
+ | GPT-OSS 20B | 추론 / 오픈가중치 | ~12.1GB | 32GB |
419
423
  | Gemma 4 26B | 멀티모달 / 대형 | ~15.6GB | 32GB |
424
+ | Gemma 4 31B | 멀티모달 / 최신 Gemma 4 | ~18.4GB | 48GB |
420
425
  | Qwen3-VL 30B A3B | 멀티모달 / 최고급 | ~18GB | 48GB |
426
+ | GPT-OSS 120B | 추론 / 최고급 오픈가중치 | ~62.3GB | 128GB |
421
427
 
422
428
  자세한 내용: [docs/CHANGELOG.md](docs/CHANGELOG.md) · [보안](SECURITY.md) · [기여](CONTRIBUTING.md)
423
429
 
package/auto_setup.py CHANGED
@@ -443,8 +443,16 @@ class Recommendation:
443
443
  _MODEL_CATALOG: List[Dict[str, Any]] = [
444
444
  # (min_ram_mb, min_vram_mb, model_id, quant, runtime_preference)
445
445
  # OS 오버헤드(~4-6 GB) + KV 캐시 여유를 감안한 보수적 RAM 임계값
446
+ {"ram": 128 * 1024, "vram": 48 * 1024,
447
+ "id": "mlx-community/gpt-oss-120b-MXFP4-Q4", "q": "mxfp4", "multimodal": False},
448
+ {"ram": 64 * 1024, "vram": 32 * 1024,
449
+ "id": "mlx-community/gemma-4-31b-it-4bit", "q": "4bit", "multimodal": True},
446
450
  {"ram": 64 * 1024, "vram": 32 * 1024,
447
451
  "id": "Qwen/Qwen3-VL-30B-A3B-Instruct", "q": "q4_K_M", "multimodal": True},
452
+ {"ram": 48 * 1024, "vram": 24 * 1024,
453
+ "id": "mlx-community/gemma-4-31b-it-4bit", "q": "4bit", "multimodal": True},
454
+ {"ram": 32 * 1024, "vram": 16 * 1024,
455
+ "id": "mlx-community/gpt-oss-20b-MXFP4-Q8", "q": "mxfp4", "multimodal": False},
448
456
  {"ram": 48 * 1024, "vram": 24 * 1024,
449
457
  "id": "Qwen/Qwen3-VL-30B-A3B-Instruct", "q": "q4_K_M", "multimodal": True},
450
458
  {"ram": 32 * 1024, "vram": 16 * 1024,
@@ -630,7 +638,13 @@ def plan(profile: SystemProfile, rec: Recommendation) -> InstallPlan:
630
638
  model_command = ["huggingface-cli", "download", rec.model_id, "--quiet"]
631
639
  if rec.runtime == "ollama":
632
640
  lower = rec.model_id.lower()
633
- if "qwen3-vl-8b" in lower:
641
+ if "gpt-oss-120b" in lower:
642
+ model_command = ["ollama", "pull", "gpt-oss:120b"]
643
+ elif "gpt-oss-20b" in lower:
644
+ model_command = ["ollama", "pull", "gpt-oss:20b"]
645
+ elif "gemma-4-31b" in lower:
646
+ model_command = ["ollama", "pull", "hf.co/ggml-org/gemma-4-31B-it-GGUF:Q4_K_M"]
647
+ elif "qwen3-vl-8b" in lower:
634
648
  model_command = ["ollama", "pull", "qwen3-vl:8b"]
635
649
  elif "qwen3-vl-4b" in lower:
636
650
  model_command = ["ollama", "pull", "qwen3-vl:4b"]
package/docs/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.2.2] - 2026-05-26
4
+
5
+ ### 모델 카탈로그
6
+
7
+ - `GPT-OSS 20B`, `GPT-OSS 120B`, `Gemma 4 31B 4-bit`를 MLX/Ollama/vLLM/LM Studio/llama.cpp 모델 선택 및 다운로드/로드 흐름에 추가
8
+ - 엔진별 모델 목록에서 같은 패밀리의 최신 major/minor 버전이 있으면 낮은 버전 항목을 숨기도록 정리
9
+ - 설정 마법사 추천표와 RAM 티어에 새 모델을 반영
10
+
11
+ ### 지식 그래프
12
+
13
+ - 로컬 폴더 스캔 시 PDF, Word, PowerPoint, Excel, CSV, 텍스트/코드, OCR 이미지 등 지원 파일은 실제 본문 텍스트가 추출된 경우에만 그래프 노드로 생성
14
+ - 빈 PDF/Word/PowerPoint/Excel 파일이나 OCR이 비어 있는 파일은 `skipped_empty_text`로 기록하고 그래프에는 표시하지 않도록 변경
15
+ - 기존 버전에서 파일명/상대경로만으로 만들어진 로컬 파일 노드는 다음 스캔에서 재추출 검증 후 자동 정리
16
+ - Word 표 셀, PowerPoint 슬라이드 텍스트, Excel 실제 셀 값 추출을 보강하고 파일명 기반 개념 추출을 제거
17
+
18
+ ### UX
19
+
20
+ - 지식 그래프 오른쪽 사이드바의 하단 잘림 문제를 수정하고 데스크톱/모바일에서 패널, 메타데이터, 긴 경로가 자연스럽게 스크롤/줄바꿈되도록 조정
21
+
22
+ ### Release
23
+
24
+ - 배포 버전을 `0.2.2`로 상향
25
+ - 대상 채널: `npm` · `PyPI` · `VS Code Marketplace` · `Open VSX`
26
+
3
27
  ## [0.2.1] - 2026-05-25
4
28
 
5
29
  ### 버그 수정
@@ -1223,21 +1223,44 @@ class KnowledgeGraphStore:
1223
1223
  from docx import Document
1224
1224
  doc = Document(str(path))
1225
1225
  paragraphs = [p.text for p in doc.paragraphs if p.text.strip()]
1226
+ table_lines = []
1227
+ for table in doc.tables:
1228
+ for row in table.rows:
1229
+ cells = [_clean_text(cell.text) for cell in row.cells]
1230
+ if any(cells):
1231
+ table_lines.append("\t".join(cells))
1226
1232
  meta["paragraphs"] = len(paragraphs)
1227
1233
  meta["tables"] = len(doc.tables)
1228
- text = "\n\n".join(paragraphs)
1234
+ meta["table_rows"] = len(table_lines)
1235
+ text = "\n\n".join([*paragraphs, *table_lines])
1229
1236
  elif ext == ".xlsx":
1230
1237
  from openpyxl import load_workbook
1231
1238
  wb = load_workbook(str(path), read_only=True, data_only=True)
1232
1239
  rows_all = []
1240
+ non_empty_rows = 0
1241
+ non_empty_cells = 0
1242
+ char_count = 0
1233
1243
  for ws in wb.worksheets:
1234
- rows_all.append(f"[Sheet: {ws.title}]")
1244
+ sheet_rows = []
1235
1245
  for row in ws.iter_rows(values_only=True):
1236
- cells = [str(cell) if cell is not None else "" for cell in row]
1237
- rows_all.append("\t".join(cells))
1238
- if len("\n".join(rows_all)) > 200_000:
1246
+ cells = [str(cell).strip() if cell is not None else "" for cell in row]
1247
+ if not any(cells):
1248
+ continue
1249
+ line = "\t".join(cells)
1250
+ non_empty_rows += 1
1251
+ non_empty_cells += sum(1 for cell in cells if cell)
1252
+ sheet_rows.append(line)
1253
+ char_count += len(line) + 1
1254
+ if char_count > 200_000:
1239
1255
  break
1256
+ if sheet_rows:
1257
+ rows_all.append(f"[Sheet: {ws.title}]")
1258
+ rows_all.extend(sheet_rows)
1259
+ if char_count > 200_000:
1260
+ break
1240
1261
  meta["sheets"] = len(wb.worksheets)
1262
+ meta["rows"] = non_empty_rows
1263
+ meta["cells"] = non_empty_cells
1241
1264
  text = "\n".join(rows_all)
1242
1265
  elif ext == ".pptx":
1243
1266
  from pptx import Presentation
@@ -1247,9 +1270,13 @@ class KnowledgeGraphStore:
1247
1270
  parts = []
1248
1271
  for shape in slide.shapes:
1249
1272
  if getattr(shape, "has_text_frame", False):
1250
- parts.append(shape.text_frame.text)
1251
- slides_text.append(f"[Slide {index}]\n" + "\n".join(parts))
1273
+ slide_text = shape.text_frame.text.strip()
1274
+ if slide_text:
1275
+ parts.append(slide_text)
1276
+ if parts:
1277
+ slides_text.append(f"[Slide {index}]\n" + "\n".join(parts))
1252
1278
  meta["slides"] = len(prs.slides)
1279
+ meta["text_slides"] = len(slides_text)
1253
1280
  text = "\n\n".join(slides_text)
1254
1281
  elif category == "image":
1255
1282
  from PIL import Image
@@ -1362,13 +1389,13 @@ class KnowledgeGraphStore:
1362
1389
  extension=excluded.extension,
1363
1390
  size_bytes=excluded.size_bytes,
1364
1391
  modified_at=excluded.modified_at,
1365
- sha256=COALESCE(excluded.sha256, local_file_index.sha256),
1392
+ sha256=excluded.sha256,
1366
1393
  last_scanned_at=excluded.last_scanned_at,
1367
- last_indexed_at=COALESCE(excluded.last_indexed_at, local_file_index.last_indexed_at),
1394
+ last_indexed_at=excluded.last_indexed_at,
1368
1395
  parser_type=excluded.parser_type,
1369
1396
  status=excluded.status,
1370
1397
  error_message=excluded.error_message,
1371
- graph_node_id=COALESCE(excluded.graph_node_id, local_file_index.graph_node_id),
1398
+ graph_node_id=excluded.graph_node_id,
1372
1399
  deleted=excluded.deleted,
1373
1400
  metadata_json=excluded.metadata_json
1374
1401
  """,
@@ -1381,6 +1408,113 @@ class KnowledgeGraphStore:
1381
1408
  )
1382
1409
  return index_id
1383
1410
 
1411
+ def _delete_local_file_graph(self, conn: sqlite3.Connection, file_node_id: Optional[str]) -> None:
1412
+ if not file_node_id:
1413
+ return
1414
+
1415
+ file_row = conn.execute(
1416
+ "SELECT metadata_json FROM nodes WHERE id=?",
1417
+ (file_node_id,),
1418
+ ).fetchone()
1419
+ source_id = None
1420
+ if file_row:
1421
+ source_id = _safe_loads(file_row["metadata_json"]).get("source_id")
1422
+
1423
+ linked_rows = conn.execute(
1424
+ """
1425
+ SELECT n.id, n.type, n.metadata_json
1426
+ FROM edges e
1427
+ JOIN nodes n ON n.id=e.to_node
1428
+ WHERE e.from_node=?
1429
+ """,
1430
+ (file_node_id,),
1431
+ ).fetchall()
1432
+ owned_ids: set = set()
1433
+ auto_candidate_ids: set = set()
1434
+ for row in linked_rows:
1435
+ metadata = _safe_loads(row["metadata_json"])
1436
+ if row["type"] in {"Chunk", "ImageText", "Section"} or metadata.get("source_node") == file_node_id:
1437
+ owned_ids.add(row["id"])
1438
+ elif metadata.get("auto_extracted") and metadata.get("source") == "local_folder":
1439
+ auto_candidate_ids.add(row["id"])
1440
+
1441
+ conn.execute("DELETE FROM chunks WHERE source_node=?", (file_node_id,))
1442
+ conn.execute("DELETE FROM edges WHERE from_node=? OR to_node=?", (file_node_id, file_node_id))
1443
+ conn.execute("DELETE FROM nodes WHERE id=?", (file_node_id,))
1444
+
1445
+ def delete_nodes(node_ids: set) -> None:
1446
+ if not node_ids:
1447
+ return
1448
+ placeholders = ",".join("?" * len(node_ids))
1449
+ params = list(node_ids)
1450
+ conn.execute(f"DELETE FROM chunks WHERE source_node IN ({placeholders})", params)
1451
+ conn.execute(f"DELETE FROM edges WHERE from_node IN ({placeholders}) OR to_node IN ({placeholders})", params * 2)
1452
+ conn.execute(f"DELETE FROM nodes WHERE id IN ({placeholders})", params)
1453
+
1454
+ delete_nodes(owned_ids)
1455
+
1456
+ removable_auto_ids: set = set()
1457
+ for node_id in auto_candidate_ids:
1458
+ remaining_edges = conn.execute(
1459
+ "SELECT from_node, to_node FROM edges WHERE from_node=? OR to_node=?",
1460
+ (node_id, node_id),
1461
+ ).fetchall()
1462
+ if all(
1463
+ (row["from_node"] in auto_candidate_ids and row["to_node"] in auto_candidate_ids)
1464
+ for row in remaining_edges
1465
+ ):
1466
+ removable_auto_ids.add(node_id)
1467
+ delete_nodes(removable_auto_ids)
1468
+ if source_id:
1469
+ self._cleanup_local_graph_orphans(conn, str(source_id))
1470
+
1471
+ def _cleanup_local_graph_orphans(self, conn: sqlite3.Connection, source_id: str) -> None:
1472
+ while True:
1473
+ folder_rows = conn.execute(
1474
+ "SELECT id, metadata_json FROM nodes WHERE type='Folder'"
1475
+ ).fetchall()
1476
+ leaf_ids = []
1477
+ for row in folder_rows:
1478
+ metadata = _safe_loads(row["metadata_json"])
1479
+ if metadata.get("source_id") != source_id:
1480
+ continue
1481
+ has_children = conn.execute(
1482
+ "SELECT 1 FROM edges WHERE from_node=? LIMIT 1",
1483
+ (row["id"],),
1484
+ ).fetchone()
1485
+ if not has_children:
1486
+ leaf_ids.append(row["id"])
1487
+ if not leaf_ids:
1488
+ break
1489
+ placeholders = ",".join("?" * len(leaf_ids))
1490
+ conn.execute(f"DELETE FROM edges WHERE from_node IN ({placeholders}) OR to_node IN ({placeholders})", leaf_ids * 2)
1491
+ conn.execute(f"DELETE FROM nodes WHERE id IN ({placeholders})", leaf_ids)
1492
+
1493
+ for node_type in ("Drive", "Computer"):
1494
+ rows = conn.execute("SELECT id FROM nodes WHERE type=?", (node_type,)).fetchall()
1495
+ removable = []
1496
+ for row in rows:
1497
+ has_children = conn.execute(
1498
+ "SELECT 1 FROM edges WHERE from_node=? LIMIT 1",
1499
+ (row["id"],),
1500
+ ).fetchone()
1501
+ if not has_children:
1502
+ removable.append(row["id"])
1503
+ if removable:
1504
+ placeholders = ",".join("?" * len(removable))
1505
+ conn.execute(f"DELETE FROM edges WHERE from_node IN ({placeholders}) OR to_node IN ({placeholders})", removable * 2)
1506
+ conn.execute(f"DELETE FROM nodes WHERE id IN ({placeholders})", removable)
1507
+
1508
+ def _local_file_index_has_extracted_text(self, row: sqlite3.Row) -> bool:
1509
+ metadata = _safe_loads(row["metadata_json"])
1510
+ parser = metadata.get("parser") if isinstance(metadata, dict) else {}
1511
+ if not isinstance(parser, dict):
1512
+ return False
1513
+ try:
1514
+ return int(parser.get("extracted_chars") or 0) > 0
1515
+ except (TypeError, ValueError):
1516
+ return False
1517
+
1384
1518
  def _upsert_local_file_node(
1385
1519
  self,
1386
1520
  conn: sqlite3.Connection,
@@ -1397,6 +1531,9 @@ class KnowledgeGraphStore:
1397
1531
  text: str,
1398
1532
  parser_meta: Dict[str, Any],
1399
1533
  ) -> str:
1534
+ text = _clean_text(text)
1535
+ if not text:
1536
+ raise ValueError("텍스트 추출 결과가 비어 있습니다.")
1400
1537
  try:
1401
1538
  relative_path = file_path.relative_to(root).as_posix()
1402
1539
  except ValueError:
@@ -1446,7 +1583,7 @@ class KnowledgeGraphStore:
1446
1583
  file_node_id,
1447
1584
  _node_type_for_category(category),
1448
1585
  file_path.name,
1449
- summary=(_clean_text(text) or relative_path)[:700],
1586
+ summary=text[:700],
1450
1587
  metadata=metadata,
1451
1588
  raw=metadata,
1452
1589
  )
@@ -1488,7 +1625,7 @@ class KnowledgeGraphStore:
1488
1625
  )
1489
1626
  self._upsert_edge(conn, file_node_id, chunk_id, "포함함", weight=0.7, metadata={"source": "local_scan"})
1490
1627
 
1491
- concepts = _extract_concepts(f"{file_path.name}\n{target_for_concepts}", limit=18)
1628
+ concepts = _extract_concepts(target_for_concepts, limit=18)
1492
1629
  concept_ids: Dict[str, str] = {}
1493
1630
  for concept in concepts:
1494
1631
  node_t = _classify_node_type(concept, target_for_concepts)
@@ -1620,10 +1757,21 @@ class KnowledgeGraphStore:
1620
1757
  except ValueError:
1621
1758
  relative_path = file_path.name
1622
1759
  seen_relative_paths.add(relative_path)
1760
+ modified_at = _safe_iso_from_stat_mtime(stat.st_mtime)
1761
+ existing = conn.execute(
1762
+ """
1763
+ SELECT size_bytes, modified_at, sha256, graph_node_id, status, metadata_json
1764
+ FROM local_file_index
1765
+ WHERE source_id=? AND relative_path=?
1766
+ """,
1767
+ (source_id, relative_path),
1768
+ ).fetchone()
1623
1769
  decision = self._local_file_decision(file_path, root, stat)
1624
1770
  parser_type = decision["parser_type"]
1625
1771
  if not decision["indexable"]:
1626
1772
  counts[decision["status"]] += 1
1773
+ if existing and existing["graph_node_id"]:
1774
+ self._delete_local_file_graph(conn, existing["graph_node_id"])
1627
1775
  self._upsert_local_file_index(
1628
1776
  conn,
1629
1777
  source_id=source_id,
@@ -1638,19 +1786,11 @@ class KnowledgeGraphStore:
1638
1786
  )
1639
1787
  continue
1640
1788
 
1641
- modified_at = _safe_iso_from_stat_mtime(stat.st_mtime)
1642
- existing = conn.execute(
1643
- """
1644
- SELECT size_bytes, modified_at, sha256, graph_node_id, status
1645
- FROM local_file_index
1646
- WHERE source_id=? AND relative_path=?
1647
- """,
1648
- (source_id, relative_path),
1649
- ).fetchone()
1650
1789
  if (
1651
1790
  existing
1652
1791
  and existing["status"] == "indexed"
1653
1792
  and existing["graph_node_id"]
1793
+ and self._local_file_index_has_extracted_text(existing)
1654
1794
  and existing["size_bytes"] == stat.st_size
1655
1795
  and existing["modified_at"] == modified_at
1656
1796
  ):
@@ -1667,7 +1807,7 @@ class KnowledgeGraphStore:
1667
1807
  parser_type=parser_type,
1668
1808
  sha256=existing["sha256"],
1669
1809
  graph_node_id=existing["graph_node_id"],
1670
- metadata={"category": decision["category"], "unchanged": True},
1810
+ metadata={**_safe_loads(existing["metadata_json"]), "category": decision["category"], "unchanged": True},
1671
1811
  )
1672
1812
  continue
1673
1813
 
@@ -1677,6 +1817,8 @@ class KnowledgeGraphStore:
1677
1817
  except Exception as exc:
1678
1818
  counts["failed"] += 1
1679
1819
  errors.append({"path": str(file_path), "error": str(exc)})
1820
+ if existing and existing["graph_node_id"]:
1821
+ self._delete_local_file_graph(conn, existing["graph_node_id"])
1680
1822
  self._upsert_local_file_index(
1681
1823
  conn,
1682
1824
  source_id=source_id,
@@ -1692,7 +1834,12 @@ class KnowledgeGraphStore:
1692
1834
  )
1693
1835
  continue
1694
1836
 
1695
- if existing and existing["sha256"] == digest and existing["graph_node_id"]:
1837
+ if (
1838
+ existing
1839
+ and existing["sha256"] == digest
1840
+ and existing["graph_node_id"]
1841
+ and self._local_file_index_has_extracted_text(existing)
1842
+ ):
1696
1843
  counts["skipped_unchanged"] += 1
1697
1844
  self._upsert_local_file_index(
1698
1845
  conn,
@@ -1706,7 +1853,7 @@ class KnowledgeGraphStore:
1706
1853
  parser_type=parser_type,
1707
1854
  sha256=digest,
1708
1855
  graph_node_id=existing["graph_node_id"],
1709
- metadata={"category": decision["category"], "sha256_unchanged": True},
1856
+ metadata={**_safe_loads(existing["metadata_json"]), "category": decision["category"], "sha256_unchanged": True},
1710
1857
  )
1711
1858
  continue
1712
1859
 
@@ -1716,6 +1863,27 @@ class KnowledgeGraphStore:
1716
1863
  decision["category"],
1717
1864
  include_ocr=include_ocr,
1718
1865
  )
1866
+ text = _clean_text(text)
1867
+ parser_meta = {**parser_meta, "extracted_chars": len(text)}
1868
+ if not text:
1869
+ counts["skipped_empty_text"] += 1
1870
+ if existing and existing["graph_node_id"]:
1871
+ self._delete_local_file_graph(conn, existing["graph_node_id"])
1872
+ self._upsert_local_file_index(
1873
+ conn,
1874
+ source_id=source_id,
1875
+ root=root,
1876
+ file_path=file_path,
1877
+ stat=stat,
1878
+ os_type=os_type,
1879
+ drive_id=drive_id,
1880
+ status="skipped_empty_text",
1881
+ parser_type=parser_type,
1882
+ sha256=digest,
1883
+ error_message="텍스트 추출 결과가 비어 있습니다.",
1884
+ metadata={"category": decision["category"], "parser": parser_meta},
1885
+ )
1886
+ continue
1719
1887
  graph_node_id = self._upsert_local_file_node(
1720
1888
  conn,
1721
1889
  source_id=source_id,
@@ -1749,6 +1917,8 @@ class KnowledgeGraphStore:
1749
1917
  except Exception as exc:
1750
1918
  counts["failed"] += 1
1751
1919
  errors.append({"path": str(file_path), "error": str(exc)})
1920
+ if existing and existing["graph_node_id"]:
1921
+ self._delete_local_file_graph(conn, existing["graph_node_id"])
1752
1922
  self._upsert_local_file_index(
1753
1923
  conn,
1754
1924
  source_id=source_id,
@@ -1765,19 +1935,20 @@ class KnowledgeGraphStore:
1765
1935
  )
1766
1936
 
1767
1937
  if not limit_reached:
1768
- existing_paths = {
1769
- row["relative_path"]
1938
+ existing_rows = {
1939
+ row["relative_path"]: row["graph_node_id"]
1770
1940
  for row in conn.execute(
1771
- "SELECT relative_path FROM local_file_index WHERE source_id=?",
1941
+ "SELECT relative_path, graph_node_id FROM local_file_index WHERE source_id=?",
1772
1942
  (source_id,),
1773
1943
  )
1774
1944
  }
1775
- deleted_paths = existing_paths - seen_relative_paths
1945
+ deleted_paths = set(existing_rows) - seen_relative_paths
1776
1946
  for relative_path in deleted_paths:
1947
+ self._delete_local_file_graph(conn, existing_rows.get(relative_path))
1777
1948
  conn.execute(
1778
1949
  """
1779
1950
  UPDATE local_file_index
1780
- SET status='deleted', deleted=1, last_scanned_at=?, error_message=NULL
1951
+ SET status='deleted', deleted=1, last_scanned_at=?, error_message=NULL, graph_node_id=NULL
1781
1952
  WHERE source_id=? AND relative_path=?
1782
1953
  """,
1783
1954
  (_now(), source_id, relative_path),
package/llm_router.py CHANGED
@@ -329,7 +329,7 @@ class LLMRouter:
329
329
  return f"Cached: {cache_key}"
330
330
 
331
331
  self._enforce_local_model_limit(cache_key)
332
- print(f"⏳ Loading Gemma 4 Stack: {cache_key}...")
332
+ print(f"⏳ Loading local model stack: {cache_key}...")
333
333
  loop = asyncio.get_event_loop()
334
334
  target_model_id = _resolve_local_hf_model(model_id)
335
335
  target_draft_model_id = _resolve_local_hf_model(draft_model_id) if draft_model_id else None
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ltcai",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "Lattice AI local MLX/cloud LLM workspace server",
5
5
  "homepage": "https://github.com/TaeSooPark-PTS/LatticeAI#readme",
6
6
  "repository": {
@@ -54,7 +54,7 @@
54
54
  "tools.py",
55
55
  "codex_telegram_bot.py",
56
56
  "mcp_registry.py",
57
- "latticeai/",
57
+ "latticeai/**/*.py",
58
58
  "skills/",
59
59
  "static/account.html",
60
60
  "static/chat.html",
package/server.py CHANGED
@@ -1103,7 +1103,7 @@ async def lifespan(app: FastAPI):
1103
1103
  except Exception:
1104
1104
  pass
1105
1105
 
1106
- app = FastAPI(title=f"Lattice AI Server ({APP_MODE})", version="2.1.0", lifespan=lifespan)
1106
+ app = FastAPI(title=f"Lattice AI Server ({APP_MODE})", version="0.2.2", lifespan=lifespan)
1107
1107
 
1108
1108
  CORS_ALLOWED_ORIGINS = [
1109
1109
  f"http://localhost:{DEFAULT_PORT}",
@@ -1620,6 +1620,9 @@ ENGINE_MODEL_CATALOG = {
1620
1620
  {"id": "mlx-community/Qwen3-VL-30B-A3B-Instruct-4bit", "name": "Qwen3-VL 30B A3B", "family": "Qwen3-VL", "tag": "local-vlm", "size": "18GB", "pullable": True},
1621
1621
  {"id": "mlx-community/gemma-3-27b-it-4bit", "name": "Gemma 3 27B", "family": "Gemma 3", "tag": "local-vlm", "size": "17GB", "pullable": True},
1622
1622
  {"id": "mlx-community/gemma-4-26b-a4b-it-4bit", "name": "Gemma 4 26B A4B Instruct", "family": "Gemma 4", "tag": "local-vlm", "size": "15.6GB", "pullable": True},
1623
+ {"id": "mlx-community/gemma-4-31b-it-4bit", "name": "Gemma 4 31B Instruct", "family": "Gemma 4", "tag": "local-vlm", "size": "18.4GB", "pullable": True},
1624
+ {"id": "mlx-community/gpt-oss-20b-MXFP4-Q8", "name": "GPT-OSS 20B", "family": "GPT-OSS", "tag": "local-reasoning", "size": "12.1GB", "pullable": True},
1625
+ {"id": "mlx-community/gpt-oss-120b-MXFP4-Q4", "name": "GPT-OSS 120B", "family": "GPT-OSS", "tag": "local-large", "size": "62.3GB", "pullable": True},
1623
1626
  {"id": "mlx-community/Llama-3.3-70B-Instruct-4bit", "name": "Llama 3.3 70B", "family": "Llama 3.x", "tag": "local-general", "size": "40GB+", "pullable": True},
1624
1627
  {"id": "mlx-community/Llama-3.1-70B-Instruct-4bit", "name": "Llama 3.1 70B", "family": "Llama 3.1", "tag": "local-general", "size": "40GB+", "pullable": True},
1625
1628
  ],
@@ -1627,6 +1630,9 @@ ENGINE_MODEL_CATALOG = {
1627
1630
  {"id": "ollama:qwen3-vl:4b", "name": "Qwen3-VL 4B via Ollama", "family": "Qwen3-VL", "tag": "local-vlm", "size": "pull required", "pullable": True},
1628
1631
  {"id": "ollama:qwen3-vl:8b", "name": "Qwen3-VL 8B via Ollama", "family": "Qwen3-VL", "tag": "local-vlm", "size": "pull required", "pullable": True},
1629
1632
  {"id": "ollama:qwen3-vl:30b", "name": "Qwen3-VL 30B via Ollama", "family": "Qwen3-VL", "tag": "local-vlm", "size": "pull required", "pullable": True},
1633
+ {"id": "ollama:gpt-oss:20b", "name": "GPT-OSS 20B via Ollama", "family": "GPT-OSS", "tag": "local-reasoning", "size": "pull required", "pullable": True},
1634
+ {"id": "ollama:gpt-oss:120b", "name": "GPT-OSS 120B via Ollama", "family": "GPT-OSS", "tag": "local-large", "size": "pull required", "pullable": True},
1635
+ {"id": "ollama:hf.co/ggml-org/gemma-4-31B-it-GGUF:Q4_K_M", "name": "Gemma 4 31B Q4 via Ollama", "family": "Gemma 4", "tag": "local-vlm", "size": "18.7GB", "pullable": True},
1630
1636
  {"id": "ollama:qwen3:8b", "name": "Qwen3 8B via Ollama", "family": "Qwen", "tag": "local-server", "size": "pull required", "pullable": True},
1631
1637
  {"id": "ollama:qwen2.5-coder:14b", "name": "Qwen2.5 Coder 14B via Ollama", "family": "Qwen", "tag": "local-coding", "size": "pull required", "pullable": True},
1632
1638
  {"id": "ollama:gemma3:1b", "name": "Gemma 3 1B via Ollama", "family": "Gemma", "tag": "local-light", "size": "pull required", "pullable": True},
@@ -1649,6 +1655,8 @@ ENGINE_MODEL_CATALOG = {
1649
1655
  {"id": "ollama:smollm2:1.7b", "name": "SmolLM2 1.7B via Ollama", "family": "SmolLM", "tag": "local-light", "size": "pull required", "pullable": True},
1650
1656
  ],
1651
1657
  "vllm": [
1658
+ {"id": "vllm:openai/gpt-oss-20b", "name": "GPT-OSS 20B via vLLM", "family": "GPT-OSS", "tag": "local-reasoning", "size": "server model", "pullable": True},
1659
+ {"id": "vllm:openai/gpt-oss-120b", "name": "GPT-OSS 120B via vLLM", "family": "GPT-OSS", "tag": "local-large", "size": "server model", "pullable": True},
1652
1660
  {"id": "vllm:Qwen/Qwen3-VL-4B-Instruct", "name": "Qwen3-VL 4B via vLLM", "family": "Qwen3-VL", "tag": "local-vlm", "size": "server model", "pullable": True},
1653
1661
  {"id": "vllm:Qwen/Qwen3-VL-8B-Instruct", "name": "Qwen3-VL 8B via vLLM", "family": "Qwen3-VL", "tag": "local-vlm", "size": "server model", "pullable": True},
1654
1662
  {"id": "vllm:Qwen/Qwen3-VL-30B-A3B-Instruct", "name": "Qwen3-VL 30B A3B via vLLM", "family": "Qwen3-VL", "tag": "local-vlm", "size": "server model", "pullable": True},
@@ -1671,6 +1679,9 @@ ENGINE_MODEL_CATALOG = {
1671
1679
  {"id": "vllm:meta-llama/Llama-3.1-70B-Instruct", "name": "Llama 3.1 70B via vLLM", "family": "Llama 3.1", "tag": "local-server", "size": "server model", "pullable": True},
1672
1680
  ],
1673
1681
  "lmstudio": [
1682
+ {"id": "lmstudio:openai/gpt-oss-20b", "name": "GPT-OSS 20B via LM Studio", "family": "GPT-OSS", "tag": "local-reasoning", "size": "server model", "pullable": True},
1683
+ {"id": "lmstudio:openai/gpt-oss-120b", "name": "GPT-OSS 120B via LM Studio", "family": "GPT-OSS", "tag": "local-large", "size": "server model", "pullable": True},
1684
+ {"id": "lmstudio:ggml-org/gemma-4-31B-it-GGUF", "name": "Gemma 4 31B 4-bit via LM Studio", "family": "Gemma 4", "tag": "local-vlm", "size": "server model", "pullable": True},
1674
1685
  {"id": "lmstudio:Qwen/Qwen3-VL-4B-Instruct", "name": "Qwen3-VL 4B via LM Studio", "family": "Qwen3-VL", "tag": "local-vlm", "size": "server model", "pullable": True},
1675
1686
  {"id": "lmstudio:Qwen/Qwen3-VL-8B-Instruct", "name": "Qwen3-VL 8B via LM Studio", "family": "Qwen3-VL", "tag": "local-vlm", "size": "server model", "pullable": True},
1676
1687
  {"id": "lmstudio:Qwen/Qwen3-VL-30B-A3B-Instruct", "name": "Qwen3-VL 30B A3B via LM Studio", "family": "Qwen3-VL", "tag": "local-vlm", "size": "server model", "pullable": True},
@@ -1691,6 +1702,9 @@ ENGINE_MODEL_CATALOG = {
1691
1702
  {"id": "lmstudio:meta-llama/Llama-3.1-70B-Instruct", "name": "Llama 3.1 70B via LM Studio", "family": "Llama 3.1", "tag": "local-server", "size": "server model", "pullable": True},
1692
1703
  ],
1693
1704
  "llamacpp": [
1705
+ {"id": "llamacpp:ggml-org/gpt-oss-20b-GGUF", "name": "GPT-OSS 20B GGUF via llama.cpp", "family": "GPT-OSS", "tag": "gguf-q4", "size": "gguf", "pullable": True},
1706
+ {"id": "llamacpp:ggml-org/gpt-oss-120b-GGUF", "name": "GPT-OSS 120B GGUF via llama.cpp", "family": "GPT-OSS", "tag": "gguf-q4", "size": "gguf", "pullable": True},
1707
+ {"id": "llamacpp:ggml-org/gemma-4-31B-it-GGUF", "name": "Gemma 4 31B GGUF via llama.cpp", "family": "Gemma 4", "tag": "gguf-q4", "size": "gguf", "pullable": True},
1694
1708
  {"id": "llamacpp:Qwen/Qwen3-VL-4B-Instruct-GGUF", "name": "Qwen3-VL 4B GGUF via llama.cpp", "family": "Qwen3-VL", "tag": "gguf-vlm", "size": "gguf", "pullable": True},
1695
1709
  {"id": "llamacpp:Qwen/Qwen3-VL-8B-Instruct-GGUF", "name": "Qwen3-VL 8B GGUF via llama.cpp", "family": "Qwen3-VL", "tag": "gguf-vlm", "size": "gguf", "pullable": True},
1696
1710
  {"id": "llamacpp:unsloth/gemma-2-2b-it-GGUF", "name": "Gemma 2 2B GGUF via llama.cpp", "family": "Gemma", "tag": "gguf-q4", "size": "gguf", "pullable": True},
@@ -1706,6 +1720,97 @@ ENGINE_MODEL_CATALOG = {
1706
1720
  ],
1707
1721
  }
1708
1722
 
1723
+ MODEL_ENGINE_ALIASES = {
1724
+ "gpt-oss-20b": {
1725
+ "local_mlx": "mlx-community/gpt-oss-20b-MXFP4-Q8",
1726
+ "ollama": "gpt-oss:20b",
1727
+ "vllm": "openai/gpt-oss-20b",
1728
+ "lmstudio": "openai/gpt-oss-20b",
1729
+ "llamacpp": "ggml-org/gpt-oss-20b-GGUF",
1730
+ },
1731
+ "openai/gpt-oss-20b": {
1732
+ "local_mlx": "mlx-community/gpt-oss-20b-MXFP4-Q8",
1733
+ "ollama": "gpt-oss:20b",
1734
+ "vllm": "openai/gpt-oss-20b",
1735
+ "lmstudio": "openai/gpt-oss-20b",
1736
+ "llamacpp": "ggml-org/gpt-oss-20b-GGUF",
1737
+ },
1738
+ "gpt-oss-120b": {
1739
+ "local_mlx": "mlx-community/gpt-oss-120b-MXFP4-Q4",
1740
+ "ollama": "gpt-oss:120b",
1741
+ "vllm": "openai/gpt-oss-120b",
1742
+ "lmstudio": "openai/gpt-oss-120b",
1743
+ "llamacpp": "ggml-org/gpt-oss-120b-GGUF",
1744
+ },
1745
+ "openai/gpt-oss-120b": {
1746
+ "local_mlx": "mlx-community/gpt-oss-120b-MXFP4-Q4",
1747
+ "ollama": "gpt-oss:120b",
1748
+ "vllm": "openai/gpt-oss-120b",
1749
+ "lmstudio": "openai/gpt-oss-120b",
1750
+ "llamacpp": "ggml-org/gpt-oss-120b-GGUF",
1751
+ },
1752
+ "gemma-4-31b-it-4bit": {
1753
+ "local_mlx": "mlx-community/gemma-4-31b-it-4bit",
1754
+ "ollama": "hf.co/ggml-org/gemma-4-31B-it-GGUF:Q4_K_M",
1755
+ "vllm": "suitch/gemma-4-31B-it-4bit",
1756
+ "lmstudio": "ggml-org/gemma-4-31B-it-GGUF",
1757
+ "llamacpp": "ggml-org/gemma-4-31B-it-GGUF",
1758
+ },
1759
+ "suitch/gemma-4-31b-it-4bit": {
1760
+ "local_mlx": "mlx-community/gemma-4-31b-it-4bit",
1761
+ "ollama": "hf.co/ggml-org/gemma-4-31B-it-GGUF:Q4_K_M",
1762
+ "vllm": "suitch/gemma-4-31B-it-4bit",
1763
+ "lmstudio": "ggml-org/gemma-4-31B-it-GGUF",
1764
+ "llamacpp": "ggml-org/gemma-4-31B-it-GGUF",
1765
+ },
1766
+ "mlx-community/gemma-4-31b-it-4bit": {
1767
+ "local_mlx": "mlx-community/gemma-4-31b-it-4bit",
1768
+ "ollama": "hf.co/ggml-org/gemma-4-31B-it-GGUF:Q4_K_M",
1769
+ "vllm": "suitch/gemma-4-31B-it-4bit",
1770
+ "lmstudio": "ggml-org/gemma-4-31B-it-GGUF",
1771
+ "llamacpp": "ggml-org/gemma-4-31B-it-GGUF",
1772
+ },
1773
+ }
1774
+
1775
+ _VERSIONED_MODEL_PATTERNS = (
1776
+ ("gemma", re.compile(r"\bgemma[-\s]?(\d+(?:\.\d+)?)", re.IGNORECASE)),
1777
+ ("qwen", re.compile(r"\bqwen[-\s]?(\d+(?:\.\d+)?)", re.IGNORECASE)),
1778
+ ("llama", re.compile(r"\bllama[-\s]?(\d+(?:\.\d+)?)", re.IGNORECASE)),
1779
+ ("phi", re.compile(r"\bphi[-\s]?(\d+(?:\.\d+)?)", re.IGNORECASE)),
1780
+ )
1781
+
1782
+
1783
+ def _version_tuple(raw: str) -> tuple[int, ...]:
1784
+ return tuple(int(part) for part in raw.split(".") if part.isdigit())
1785
+
1786
+
1787
+ def _model_family_version(model: Dict[str, object]) -> Optional[tuple[str, tuple[int, ...]]]:
1788
+ text = " ".join(str(model.get(key) or "") for key in ("family", "name", "id"))
1789
+ for family, pattern in _VERSIONED_MODEL_PATTERNS:
1790
+ match = pattern.search(text)
1791
+ if match:
1792
+ version = _version_tuple(match.group(1))
1793
+ if version:
1794
+ return family, version
1795
+ return None
1796
+
1797
+
1798
+ def filter_lower_family_versions(models: List[Dict[str, object]]) -> List[Dict[str, object]]:
1799
+ max_versions: Dict[str, tuple[int, ...]] = {}
1800
+ detected: List[tuple[Dict[str, object], Optional[tuple[str, tuple[int, ...]]]]] = []
1801
+ for model in models:
1802
+ version_info = _model_family_version(model)
1803
+ detected.append((model, version_info))
1804
+ if not version_info:
1805
+ continue
1806
+ family, version = version_info
1807
+ if version > max_versions.get(family, (0,)):
1808
+ max_versions[family] = version
1809
+ return [
1810
+ model for model, version_info in detected
1811
+ if not version_info or version_info[1] >= max_versions.get(version_info[0], version_info[1])
1812
+ ]
1813
+
1709
1814
  def _update_env_file(env_file: Path, key: str, value: str) -> None:
1710
1815
  lines = []
1711
1816
  found = False
@@ -2525,17 +2630,20 @@ def engine_status() -> List[Dict]:
2525
2630
  for m in ENGINE_MODEL_CATALOG["ollama"]:
2526
2631
  pull_name = m["id"].removeprefix("ollama:")
2527
2632
  ollama_models.append({**m, "pulled": pull_name in pulled})
2633
+ ollama_models = filter_lower_family_versions(ollama_models)
2528
2634
 
2529
2635
  HF_MODELS_ROOT.mkdir(parents=True, exist_ok=True)
2530
2636
  mlx_models = []
2531
2637
  for m in ENGINE_MODEL_CATALOG.get("local_mlx", []):
2532
2638
  repo_id = m["id"]
2533
2639
  mlx_models.append({**m, "pulled": hf_model_ready(repo_id, "local_mlx")})
2640
+ mlx_models = filter_lower_family_versions(mlx_models)
2534
2641
 
2535
2642
  vllm_models = []
2536
2643
  for m in ENGINE_MODEL_CATALOG.get("vllm", []):
2537
2644
  repo_id = m["id"].removeprefix("vllm:")
2538
2645
  vllm_models.append({**m, "pulled": hf_model_ready(repo_id, "vllm")})
2646
+ vllm_models = filter_lower_family_versions(vllm_models)
2539
2647
 
2540
2648
  lmstudio_models = []
2541
2649
  downloaded_lmstudio = get_lmstudio_models()
@@ -2567,11 +2675,13 @@ def engine_status() -> List[Dict]:
2567
2675
  repo_id = m["id"].removeprefix("lmstudio:")
2568
2676
  if f"lmstudio:{repo_id}" not in known_ids and repo_id not in downloaded_by_key:
2569
2677
  lmstudio_models.append({**m, "pulled": False})
2678
+ lmstudio_models = filter_lower_family_versions(lmstudio_models)
2570
2679
 
2571
2680
  llamacpp_models = []
2572
2681
  for m in ENGINE_MODEL_CATALOG.get("llamacpp", []):
2573
2682
  repo_id = m["id"].removeprefix("llamacpp:")
2574
2683
  llamacpp_models.append({**m, "pulled": hf_model_ready(repo_id, "llamacpp")})
2684
+ llamacpp_models = filter_lower_family_versions(llamacpp_models)
2575
2685
 
2576
2686
  local_server_specs = [
2577
2687
  {
@@ -2768,8 +2878,29 @@ def install_engine(engine: str) -> Dict:
2768
2878
  return result
2769
2879
 
2770
2880
 
2881
+ def _resolve_model_alias(model_id: str, engine: Optional[str] = None) -> str:
2882
+ raw = model_id.strip()
2883
+ engine_hint = (engine or "").strip().lower()
2884
+ provider: Optional[str] = None
2885
+ model_name = raw
2886
+ if ":" in raw:
2887
+ prefix, rest = raw.split(":", 1)
2888
+ prefix = prefix.strip().lower()
2889
+ if prefix in {"ollama", "vllm", "lmstudio", "llamacpp", "local_mlx", "mlx"}:
2890
+ provider = "local_mlx" if prefix in {"local_mlx", "mlx"} else prefix
2891
+ model_name = rest.strip()
2892
+ provider = provider or ("local_mlx" if engine_hint in {"", "local_mlx", "mlx"} else engine_hint)
2893
+ aliases = MODEL_ENGINE_ALIASES.get(model_name.lower())
2894
+ if not aliases:
2895
+ return raw
2896
+ mapped = aliases.get(provider)
2897
+ if not mapped:
2898
+ return raw
2899
+ return mapped if provider == "local_mlx" else f"{provider}:{mapped}"
2900
+
2901
+
2771
2902
  def normalize_local_model_request(model_id: str, engine: Optional[str] = None) -> str:
2772
- model_id = model_id.strip()
2903
+ model_id = _resolve_model_alias(model_id, engine)
2773
2904
  engine = (engine or "").strip().lower()
2774
2905
  if engine in {"local_mlx", "mlx"} and model_id.startswith(("local_mlx:", "mlx:")):
2775
2906
  return model_id.split(":", 1)[1].strip()
@@ -3165,7 +3296,7 @@ async def verify_cloud_models(force: bool = False, provider_filter: Optional[str
3165
3296
 
3166
3297
  @app.get("/health")
3167
3298
  async def health(request: Request):
3168
- base = {"status": "ok", "version": "2.1.0", "mode": APP_MODE}
3299
+ base = {"status": "ok", "version": "0.2.2", "mode": APP_MODE}
3169
3300
  if not get_current_user(request) and REQUIRE_AUTH:
3170
3301
  return base
3171
3302
  engines = await asyncio.to_thread(engine_status)
@@ -3206,7 +3337,7 @@ async def engines_verify_cloud(req: VerifyCloudRequest, request: Request):
3206
3337
  @app.post("/engines/pull-model")
3207
3338
  async def pull_ollama_model(req: PullModelRequest, request: Request):
3208
3339
  require_user(request)
3209
- model_ref = req.model.strip()
3340
+ model_ref = normalize_local_model_request(req.model, None)
3210
3341
  if not model_ref:
3211
3342
  raise HTTPException(status_code=400, detail="모델 식별자가 비어 있습니다.")
3212
3343
 
@@ -3324,23 +3455,8 @@ async def set_api_key(req: SetApiKeyRequest, request: Request):
3324
3455
  async def list_models():
3325
3456
  """HuggingFace 추천 모델 목록 및 로드 상태 반환"""
3326
3457
  recommended = [
3327
- {"id": "mlx-community/Qwen3-VL-4B-Instruct-4bit", "name": "Qwen3-VL 4B", "tag": "multimodal", "size": "2.7GB"},
3328
- {"id": "mlx-community/Qwen3-VL-8B-Instruct-4bit", "name": "Qwen3-VL 8B", "tag": "multimodal", "size": "4.8GB"},
3329
- {"id": "mlx-community/Qwen3-VL-30B-A3B-Instruct-4bit", "name": "Qwen3-VL 30B A3B","tag": "multimodal", "size": "18GB"},
3330
- {"id": "mlx-community/SmolLM-1.7B-Instruct-4bit", "name": "SmolLM 1.7B", "tag": "ultra-light", "size": "963MB"},
3331
- {"id": "mlx-community/gemma-3-1b-it-4bit", "name": "Gemma 3 1B", "tag": "ultra-light", "size": "733MB"},
3332
- {"id": "mlx-community/Llama-3.2-1B-Instruct-4bit", "name": "Llama 3.2 1B", "tag": "light", "size": "1.3GB"},
3333
- {"id": "mlx-community/Llama-3.2-3B-Instruct-4bit", "name": "Llama 3.2 3B", "tag": "light", "size": "2.0GB"},
3334
- {"id": "mlx-community/Phi-4-mini-instruct-4bit", "name": "Phi 4 Mini", "tag": "coding", "size": "2.2GB"},
3335
- {"id": "mlx-community/Qwen2.5-VL-7B-Instruct-4bit", "name": "Qwen2.5-VL 7B", "tag": "multimodal", "size": "4.4GB"},
3336
- {"id": "mlx-community/Mistral-7B-Instruct-v0.3-4bit", "name": "Mistral 7B v0.3", "tag": "general", "size": "4.1GB"},
3337
- {"id": "mlx-community/Llama-3.1-8B-Instruct-4bit", "name": "Llama 3.1 8B", "tag": "general", "size": "4.7GB"},
3338
- {"id": "mlx-community/gemma-4-e4b-it-4bit", "name": "Gemma 4 E4B", "tag": "multimodal", "size": "5.2GB"},
3339
- {"id": "mlx-community/gemma-3-12b-it-4bit", "name": "Gemma 3 12B", "tag": "balanced", "size": "8.0GB"},
3340
- {"id": "mlx-community/phi-4-4bit", "name": "Phi 4", "tag": "coding", "size": "8.3GB"},
3341
- {"id": "mlx-community/Mistral-Small-24B-Instruct-2501-4bit", "name": "Mistral Small 24B", "tag": "large", "size": "13.3GB"},
3342
- {"id": "mlx-community/Qwen2.5-Coder-32B-Instruct-4bit", "name": "Qwen2.5 Coder 32B","tag": "coding", "size": "18.5GB"},
3343
- {"id": "mlx-community/gemma-4-26b-a4b-it-4bit", "name": "Gemma 4 26B A4B", "tag": "multimodal", "size": "15.6GB"},
3458
+ {"id": item["id"], "name": item["name"], "tag": item["tag"], "size": item["size"]}
3459
+ for item in filter_lower_family_versions(ENGINE_MODEL_CATALOG.get("local_mlx", []))
3344
3460
  ]
3345
3461
  return {
3346
3462
  "recommended": recommended,
@@ -2608,6 +2608,28 @@ body.lattice-ref-graph {
2608
2608
  font-family: "SF Pro Display", "Inter", ui-sans-serif, system-ui, -apple-system, sans-serif;
2609
2609
  }
2610
2610
 
2611
+ body.lattice-ref-graph .app {
2612
+ min-height: 0;
2613
+ height: 100dvh;
2614
+ overflow: hidden;
2615
+ }
2616
+
2617
+ body.lattice-ref-graph .stage {
2618
+ height: calc(100dvh - 138px);
2619
+ max-height: calc(100dvh - 138px);
2620
+ min-height: 0;
2621
+ }
2622
+
2623
+ body.lattice-ref-graph .app > aside:not(.reference-rail) {
2624
+ height: calc(100dvh - 138px);
2625
+ max-height: calc(100dvh - 138px);
2626
+ min-height: 0;
2627
+ overflow-y: auto;
2628
+ overscroll-behavior: contain;
2629
+ padding-bottom: max(28px, env(safe-area-inset-bottom));
2630
+ scrollbar-gutter: stable;
2631
+ }
2632
+
2611
2633
  .app {
2612
2634
  display: grid;
2613
2635
  grid-template-columns: minmax(0, 1fr) 360px;
@@ -2993,7 +3015,7 @@ body.lattice-ref-graph {
2993
3015
  display: flex;
2994
3016
  flex-direction: column;
2995
3017
  gap: 6px;
2996
- max-height: 150px;
3018
+ max-height: min(150px, 22dvh);
2997
3019
  overflow-y: auto;
2998
3020
  padding-right: 2px;
2999
3021
  }
@@ -3110,7 +3132,7 @@ body.lattice-ref-graph {
3110
3132
  color: var(--muted);
3111
3133
  font-size: 12px;
3112
3134
  line-height: 1.3;
3113
- white-space: nowrap;
3135
+ white-space: normal;
3114
3136
  }
3115
3137
 
3116
3138
  .local-option-row input {
@@ -3174,6 +3196,7 @@ body.lattice-ref-graph {
3174
3196
  gap: 9px;
3175
3197
  min-height: 26px;
3176
3198
  font-size: 12px;
3199
+ min-width: 0;
3177
3200
  }
3178
3201
 
3179
3202
  .filter-item {
@@ -3208,9 +3231,11 @@ body.lattice-ref-graph {
3208
3231
  .filter-name,
3209
3232
  .legend-name {
3210
3233
  flex: 1;
3234
+ min-width: 0;
3211
3235
  color: #14162c;
3212
3236
  font-size: 13px;
3213
3237
  font-weight: 500;
3238
+ overflow-wrap: anywhere;
3214
3239
  }
3215
3240
 
3216
3241
  .filter-count,
@@ -3223,9 +3248,10 @@ body.lattice-ref-graph {
3223
3248
  }
3224
3249
 
3225
3250
  .detail-wrap {
3226
- flex: 1;
3227
- overflow-y: auto;
3228
- padding: 18px;
3251
+ flex: 0 0 auto;
3252
+ min-height: auto;
3253
+ overflow: visible;
3254
+ padding: 18px 18px max(24px, env(safe-area-inset-bottom));
3229
3255
  }
3230
3256
 
3231
3257
  .type-badge {
@@ -3245,6 +3271,7 @@ body.lattice-ref-graph {
3245
3271
  font-weight: 750;
3246
3272
  margin-bottom: 8px;
3247
3273
  letter-spacing: -0.01em;
3274
+ overflow-wrap: anywhere;
3248
3275
  }
3249
3276
 
3250
3277
  .detail-summary {
@@ -3296,6 +3323,9 @@ body.lattice-ref-graph {
3296
3323
  line-height: 1.65;
3297
3324
  white-space: pre-wrap;
3298
3325
  word-break: break-word;
3326
+ overflow-wrap: anywhere;
3327
+ max-height: min(42dvh, 460px);
3328
+ overflow: auto;
3299
3329
  }
3300
3330
 
3301
3331
  .jump-btn {
@@ -3323,6 +3353,65 @@ body.lattice-ref-graph {
3323
3353
  }
3324
3354
 
3325
3355
  @media (max-width: 900px) {
3356
+ body.lattice-ref-graph {
3357
+ overflow-y: auto;
3358
+ }
3359
+
3360
+ body.lattice-ref-graph .reference-rail {
3361
+ display: none;
3362
+ }
3363
+
3364
+ body.lattice-ref-graph {
3365
+ grid-template-columns: 1fr;
3366
+ }
3367
+
3368
+ body.lattice-ref-graph .app {
3369
+ height: auto;
3370
+ min-height: 100dvh;
3371
+ overflow: visible;
3372
+ grid-template-columns: 1fr;
3373
+ grid-template-rows: minmax(420px, 58dvh) auto;
3374
+ }
3375
+
3376
+ body.lattice-ref-graph .stage {
3377
+ height: auto;
3378
+ max-height: none;
3379
+ min-height: 420px;
3380
+ margin: 84px 12px 12px;
3381
+ }
3382
+
3383
+ body.lattice-ref-graph .stage::before {
3384
+ top: 26px;
3385
+ left: 58px;
3386
+ font-size: 24px;
3387
+ }
3388
+
3389
+ body.lattice-ref-graph .stage::after {
3390
+ top: 22px;
3391
+ left: 14px;
3392
+ }
3393
+
3394
+ body.lattice-ref-graph .search-shell {
3395
+ top: -60px;
3396
+ left: 60px;
3397
+ right: 12px;
3398
+ width: auto;
3399
+ }
3400
+
3401
+ body.lattice-ref-graph .app > aside:not(.reference-rail) {
3402
+ height: auto;
3403
+ max-height: none;
3404
+ min-height: 0;
3405
+ margin: 0 12px 24px;
3406
+ overflow: visible;
3407
+ padding-bottom: max(18px, env(safe-area-inset-bottom));
3408
+ }
3409
+
3410
+ body.lattice-ref-graph .detail-wrap {
3411
+ flex: none;
3412
+ overflow: visible;
3413
+ }
3414
+
3326
3415
  .app {
3327
3416
  grid-template-columns: 1fr;
3328
3417
  grid-template-rows: 1fr 360px;