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 +8 -2
- package/auto_setup.py +15 -1
- package/docs/CHANGELOG.md +24 -0
- package/knowledge_graph.py +200 -29
- package/llm_router.py +1 -1
- package/package.json +2 -2
- package/server.py +137 -21
- package/static/lattice-reference.css +94 -5
- package/latticeai/__pycache__/__init__.cpython-314.pyc +0 -0
- package/latticeai/api/__pycache__/admin.cpython-314.pyc +0 -0
- package/latticeai/api/__pycache__/auth.cpython-314.pyc +0 -0
- package/latticeai/core/__pycache__/__init__.cpython-314.pyc +0 -0
- package/latticeai/core/__pycache__/audit.cpython-314.pyc +0 -0
- package/latticeai/core/__pycache__/security.cpython-314.pyc +0 -0
- package/latticeai/core/__pycache__/sessions.cpython-314.pyc +0 -0
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,
|
|
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.
|
|
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 "
|
|
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
|
### 버그 수정
|
package/knowledge_graph.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1238
|
-
|
|
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
|
-
|
|
1251
|
-
|
|
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=
|
|
1392
|
+
sha256=excluded.sha256,
|
|
1366
1393
|
last_scanned_at=excluded.last_scanned_at,
|
|
1367
|
-
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=
|
|
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=
|
|
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(
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
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
|
|
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": "
|
|
3328
|
-
|
|
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:
|
|
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:
|
|
3227
|
-
|
|
3228
|
-
|
|
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;
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|