ltcai 2.1.0 → 2.2.0
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 +140 -590
- package/auto_setup.py +17 -17
- package/docs/CHANGELOG.md +45 -0
- package/docs/MULTI_AGENT_RUNTIME.md +4 -4
- package/docs/PLUGIN_SDK.md +7 -7
- package/docs/REALTIME_COLLABORATION.md +6 -6
- package/docs/V2_ARCHITECTURE.md +45 -25
- package/docs/WORKFLOW_DESIGNER.md +4 -4
- package/docs/architecture.md +127 -135
- package/docs/kg-schema.md +3 -3
- package/docs/public-deploy.md +2 -3
- package/knowledge_graph.py +2 -2
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/models.py +8 -0
- package/latticeai/core/config.py +1 -1
- package/latticeai/core/graph_curator.py +2 -2
- package/latticeai/core/marketplace.py +2 -2
- package/latticeai/core/model_compat.py +7 -63
- package/latticeai/core/model_resolution.py +1 -1
- package/latticeai/core/multi_agent.py +1 -1
- package/latticeai/core/plugins.py +1 -1
- package/latticeai/core/realtime.py +1 -1
- package/latticeai/core/workflow_engine.py +1 -1
- package/latticeai/core/workspace_os.py +1 -1
- package/latticeai/server_app.py +1 -1
- package/latticeai/services/model_catalog.py +105 -153
- package/latticeai/services/model_recommendation.py +28 -17
- package/latticeai/services/model_runtime.py +2 -2
- package/llm_router.py +80 -92
- package/ltcai_cli.py +2 -3
- package/package.json +1 -1
- package/static/chat.html +5 -6
- package/static/scripts/chat.js +34 -36
- package/static/workspace.html +1 -1
- package/telegram_bot.py +1 -1
package/llm_router.py
CHANGED
|
@@ -29,16 +29,14 @@ executor = ThreadPoolExecutor(max_workers=1)
|
|
|
29
29
|
|
|
30
30
|
try:
|
|
31
31
|
import mlx.core as mx
|
|
32
|
-
from mlx_lm import load as lm_load
|
|
33
32
|
from mlx_vlm import load as vlm_load
|
|
34
33
|
VLM_AVAILABLE = True
|
|
35
|
-
print("✅ MLX-VLM
|
|
34
|
+
print("✅ MLX-VLM is ready for multimodal models.")
|
|
36
35
|
except Exception as e:
|
|
37
36
|
mx = None
|
|
38
|
-
lm_load = None
|
|
39
37
|
vlm_load = None
|
|
40
38
|
VLM_AVAILABLE = False
|
|
41
|
-
print(f"⚠️ MLX
|
|
39
|
+
print(f"⚠️ MLX-VLM unavailable: {e}")
|
|
42
40
|
|
|
43
41
|
BRAND_NAME = "Lattice AI"
|
|
44
42
|
LEGACY_BRAND_PATTERNS = [
|
|
@@ -77,12 +75,12 @@ OPENAI_COMPATIBLE_PROVIDERS = {
|
|
|
77
75
|
"groq": {
|
|
78
76
|
"env_key": "GROQ_API_KEY",
|
|
79
77
|
"base_url": "https://api.groq.com/openai/v1",
|
|
80
|
-
"default_model": "llama-
|
|
78
|
+
"default_model": "meta-llama/llama-4-scout-17b-16e-instruct",
|
|
81
79
|
},
|
|
82
80
|
"together": {
|
|
83
81
|
"env_key": "TOGETHER_API_KEY",
|
|
84
82
|
"base_url": "https://api.together.xyz/v1",
|
|
85
|
-
"default_model": "
|
|
83
|
+
"default_model": "Qwen/Qwen3-VL-32B-Instruct",
|
|
86
84
|
},
|
|
87
85
|
"xai": {
|
|
88
86
|
"env_key": "XAI_API_KEY",
|
|
@@ -93,14 +91,14 @@ OPENAI_COMPATIBLE_PROVIDERS = {
|
|
|
93
91
|
"env_key": "OLLAMA_API_KEY",
|
|
94
92
|
"base_url_env": "OLLAMA_BASE_URL",
|
|
95
93
|
"base_url": "http://localhost:11434/v1",
|
|
96
|
-
"default_model": "
|
|
94
|
+
"default_model": "hf.co/ggml-org/gemma-4-12B-it-GGUF:Q4_K_M",
|
|
97
95
|
"api_key_fallback": "ollama",
|
|
98
96
|
},
|
|
99
97
|
"vllm": {
|
|
100
98
|
"env_key": "VLLM_API_KEY",
|
|
101
99
|
"base_url_env": "VLLM_BASE_URL",
|
|
102
100
|
"base_url": "http://localhost:8000/v1",
|
|
103
|
-
"default_model": "
|
|
101
|
+
"default_model": "Qwen/Qwen3-VL-8B-Instruct",
|
|
104
102
|
"api_key_fallback": "vllm",
|
|
105
103
|
},
|
|
106
104
|
"lmstudio": {
|
|
@@ -137,20 +135,18 @@ PROVIDER_MODEL_CATALOG = {
|
|
|
137
135
|
{"id": "anthropic/claude-sonnet-4.6", "name": "Claude Sonnet 4.6 via OpenRouter", "family": "Claude"},
|
|
138
136
|
{"id": "anthropic/claude-haiku-4.5", "name": "Claude Haiku 4.5 via OpenRouter", "family": "Claude"},
|
|
139
137
|
{"id": "qwen/qwen3-vl-235b-a22b-instruct", "name": "Qwen3-VL 235B A22B via OpenRouter", "family": "Qwen"},
|
|
140
|
-
{"id": "
|
|
138
|
+
{"id": "google/gemma-4-12b-it", "name": "Gemma 4 12B via OpenRouter", "family": "Gemma"},
|
|
141
139
|
{"id": "x-ai/grok-2", "name": "Grok 2 via OpenRouter", "family": "Grok"},
|
|
142
|
-
{"id": "meta-llama/llama-
|
|
140
|
+
{"id": "meta-llama/llama-4-scout-17b-16e-instruct", "name": "Llama 4 Scout via OpenRouter", "family": "Llama"},
|
|
143
141
|
{"id": "google/gemini-2.5-flash", "name": "Gemini 2.5 Flash via OpenRouter", "family": "Gemini"},
|
|
144
142
|
],
|
|
145
143
|
"groq": [
|
|
146
|
-
{"id": "
|
|
147
|
-
{"id": "llama-3.1-8b-instant", "name": "Llama 3.1 8B Instant", "family": "Llama"},
|
|
148
|
-
{"id": "llama-3.3-70b-versatile", "name": "Llama 3.3 70B Versatile", "family": "Llama"},
|
|
144
|
+
{"id": "meta-llama/llama-4-scout-17b-16e-instruct", "name": "Llama 4 Scout", "family": "Llama"},
|
|
149
145
|
],
|
|
150
146
|
"together": [
|
|
151
147
|
{"id": "Qwen/Qwen3-VL-32B-Instruct", "name": "Qwen3-VL 32B", "family": "Qwen"},
|
|
152
|
-
{"id": "
|
|
153
|
-
{"id": "
|
|
148
|
+
{"id": "google/gemma-4-12b-it", "name": "Gemma 4 12B", "family": "Gemma"},
|
|
149
|
+
{"id": "meta-llama/Llama-4-Scout-17B-16E-Instruct", "name": "Llama 4 Scout", "family": "Llama"},
|
|
154
150
|
],
|
|
155
151
|
"xai": [
|
|
156
152
|
{"id": "grok-beta", "name": "Grok Beta", "family": "Grok"},
|
|
@@ -158,6 +154,40 @@ PROVIDER_MODEL_CATALOG = {
|
|
|
158
154
|
],
|
|
159
155
|
}
|
|
160
156
|
|
|
157
|
+
MODEL_SOURCE_BY_FAMILY = {
|
|
158
|
+
"GPT": ("미국", "OpenAI"),
|
|
159
|
+
"Claude": ("미국", "Anthropic"),
|
|
160
|
+
"Qwen": ("중국", "Alibaba"),
|
|
161
|
+
"Llama": ("미국", "Meta"),
|
|
162
|
+
"Gemini": ("미국", "Google"),
|
|
163
|
+
"Grok": ("미국", "xAI"),
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def source_metadata_for_model(provider: str, model: Dict[str, str], *, local_server: bool) -> Dict[str, str]:
|
|
168
|
+
family = str(model.get("family") or "")
|
|
169
|
+
country, company = MODEL_SOURCE_BY_FAMILY.get(family, ("미상", provider.title()))
|
|
170
|
+
if local_server:
|
|
171
|
+
execution_method = "내 컴퓨터에서만 실행"
|
|
172
|
+
internet_requirement = "모델을 다운로드할 때만 인터넷 필요; 실행 중에는 필요 없음"
|
|
173
|
+
else:
|
|
174
|
+
execution_method = "인터넷 연결 후 사용"
|
|
175
|
+
internet_requirement = "내 파일이 인터넷으로 전송될 수 있음"
|
|
176
|
+
return {
|
|
177
|
+
"source_country": country,
|
|
178
|
+
"source_company": company,
|
|
179
|
+
"execution_method": execution_method,
|
|
180
|
+
"internet_requirement": internet_requirement,
|
|
181
|
+
"model_name": model.get("name") or model.get("id") or "",
|
|
182
|
+
"source_display_order": [
|
|
183
|
+
"source_country",
|
|
184
|
+
"source_company",
|
|
185
|
+
"execution_method",
|
|
186
|
+
"internet_requirement",
|
|
187
|
+
"model_name",
|
|
188
|
+
],
|
|
189
|
+
}
|
|
190
|
+
|
|
161
191
|
@dataclass
|
|
162
192
|
class CloudModel:
|
|
163
193
|
provider: str
|
|
@@ -207,37 +237,29 @@ def _resolve_local_hf_model(model_id: str) -> str:
|
|
|
207
237
|
return model_id
|
|
208
238
|
|
|
209
239
|
def ensure_mlx_runtime() -> None:
|
|
210
|
-
global mx,
|
|
211
|
-
if mx is not None and
|
|
240
|
+
global mx, vlm_load, VLM_AVAILABLE
|
|
241
|
+
if mx is not None and vlm_load is not None:
|
|
212
242
|
return
|
|
213
243
|
try:
|
|
214
244
|
import mlx.core as mlx_core
|
|
215
|
-
from
|
|
245
|
+
from mlx_vlm import load as mlx_vlm_load
|
|
216
246
|
|
|
217
247
|
mx = mlx_core
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
from mlx_vlm import load as mlx_vlm_load
|
|
221
|
-
vlm_load = mlx_vlm_load
|
|
222
|
-
VLM_AVAILABLE = True
|
|
223
|
-
except Exception:
|
|
224
|
-
vlm_load = None
|
|
225
|
-
VLM_AVAILABLE = False
|
|
248
|
+
vlm_load = mlx_vlm_load
|
|
249
|
+
VLM_AVAILABLE = True
|
|
226
250
|
mx.set_default_device(mx.gpu)
|
|
227
251
|
except Exception as e:
|
|
228
|
-
raise RuntimeError(f"MLX runtime is not available after install: {e}") from e
|
|
252
|
+
raise RuntimeError(f"MLX-VLM runtime is not available after install: {e}") from e
|
|
229
253
|
|
|
230
254
|
def _mlx_sampler(temperature: float):
|
|
231
255
|
"""Build an MLX sampler callable for the given temperature.
|
|
232
256
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
``generate_step() got an unexpected keyword argument 'temp'``. Both libraries
|
|
237
|
-
accept ``sampler=`` and share make_sampler from mlx_lm.sample_utils.
|
|
257
|
+
Lattice v2.2 keeps local execution on MLX-VLM only. Returning ``None`` lets
|
|
258
|
+
MLX-VLM use its bundled default sampler without pulling another generation
|
|
259
|
+
package into the runtime contract.
|
|
238
260
|
"""
|
|
239
|
-
|
|
240
|
-
return
|
|
261
|
+
_ = temperature
|
|
262
|
+
return None
|
|
241
263
|
|
|
242
264
|
class LLMRouter:
|
|
243
265
|
def __init__(self):
|
|
@@ -331,8 +353,8 @@ class LLMRouter:
|
|
|
331
353
|
return self._load_cloud_model(provider, provider_model, api_key_override=api_key_override, owner=owner)
|
|
332
354
|
|
|
333
355
|
ensure_mlx_runtime()
|
|
334
|
-
if mx is None or
|
|
335
|
-
raise RuntimeError("MLX is not available in this process. Run on Apple Silicon with Metal access.")
|
|
356
|
+
if mx is None or vlm_load is None:
|
|
357
|
+
raise RuntimeError("MLX-VLM is not available in this process. Run on Apple Silicon with Metal access.")
|
|
336
358
|
|
|
337
359
|
cache_key = f"{model_id}_{draft_model_id}" if draft_model_id else model_id
|
|
338
360
|
if cache_key in self._cache:
|
|
@@ -348,24 +370,13 @@ class LLMRouter:
|
|
|
348
370
|
|
|
349
371
|
def _load():
|
|
350
372
|
mx.set_default_device(mx.gpu)
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
if is_gemma4 and VLM_AVAILABLE:
|
|
355
|
-
print(f"🔄 Loading Target (VLM Mode): {target_model_id}...")
|
|
356
|
-
model, tokenizer = vlm_load(target_model_id)
|
|
357
|
-
else:
|
|
358
|
-
print(f"🔄 Loading Target (LM Mode): {target_model_id}...")
|
|
359
|
-
model, tokenizer = lm_load(target_model_id)
|
|
360
|
-
|
|
361
|
-
# 2. Draft 로드 (Gemma 4는 항상 vlm_load 사용)
|
|
373
|
+
print(f"🔄 Loading Target (VLM Mode): {target_model_id}...")
|
|
374
|
+
model, tokenizer = vlm_load(target_model_id)
|
|
375
|
+
|
|
362
376
|
draft_model = None
|
|
363
377
|
if target_draft_model_id:
|
|
364
378
|
print(f"🔄 Loading Assistant (VLM Mode): {target_draft_model_id}...")
|
|
365
|
-
|
|
366
|
-
draft_model, _ = vlm_load(target_draft_model_id)
|
|
367
|
-
else:
|
|
368
|
-
draft_model, _ = lm_load(target_draft_model_id)
|
|
379
|
+
draft_model, _ = vlm_load(target_draft_model_id)
|
|
369
380
|
print(f"✅ Assistant Ready.")
|
|
370
381
|
|
|
371
382
|
return model, tokenizer, draft_model
|
|
@@ -418,14 +429,16 @@ class LLMRouter:
|
|
|
418
429
|
}]
|
|
419
430
|
for model in provider_models:
|
|
420
431
|
model_id = model["id"]
|
|
432
|
+
local_server = provider in local_server_providers
|
|
421
433
|
items.append({
|
|
422
434
|
"id": f"{provider}:{model_id}",
|
|
423
435
|
"name": model.get("name") or f"{provider.title()} · {model_id}",
|
|
424
436
|
"provider": provider,
|
|
425
437
|
"family": model.get("family"),
|
|
426
|
-
"tag": "local-server" if
|
|
438
|
+
"tag": "local-server" if local_server else "cloud",
|
|
427
439
|
"available": has_key,
|
|
428
440
|
"requires": config["env_key"] if not has_key else None,
|
|
441
|
+
**source_metadata_for_model(provider, model, local_server=local_server),
|
|
429
442
|
})
|
|
430
443
|
custom = os.getenv("LATTICEAI_CLOUD_MODELS") or ""
|
|
431
444
|
for raw in [item.strip() for item in custom.split(",") if item.strip()]:
|
|
@@ -439,6 +452,11 @@ class LLMRouter:
|
|
|
439
452
|
"tag": "cloud",
|
|
440
453
|
"available": bool(os.getenv(config["env_key"]) or config.get("api_key_fallback")),
|
|
441
454
|
"requires": None,
|
|
455
|
+
**source_metadata_for_model(
|
|
456
|
+
provider,
|
|
457
|
+
{"id": model, "name": f"{provider.title()} · {model}", "family": provider.title()},
|
|
458
|
+
local_server=provider in local_server_providers,
|
|
459
|
+
),
|
|
442
460
|
})
|
|
443
461
|
return items
|
|
444
462
|
|
|
@@ -511,25 +529,15 @@ class LLMRouter:
|
|
|
511
529
|
return await self._cloud_generate(cached, message, context, max_tokens, temperature)
|
|
512
530
|
|
|
513
531
|
model, tokenizer, draft_model = self._cache[self._current]
|
|
514
|
-
|
|
515
|
-
prompt = (
|
|
516
|
-
self._build_vlm_prompt(model, tokenizer, message, context, 1)
|
|
517
|
-
if image_data and is_gemma4 and VLM_AVAILABLE
|
|
518
|
-
else self._build_prompt(message, context, tokenizer)
|
|
519
|
-
)
|
|
532
|
+
prompt = self._build_vlm_prompt(model, tokenizer, message, context, 1 if image_data else 0)
|
|
520
533
|
|
|
521
534
|
loop = asyncio.get_event_loop()
|
|
522
535
|
|
|
523
536
|
def _gen():
|
|
524
537
|
import mlx.core as mx
|
|
525
538
|
mx.set_default_device(mx.gpu)
|
|
526
|
-
|
|
527
|
-
if
|
|
528
|
-
from mlx_vlm import generate as vlm_gen
|
|
529
|
-
return vlm_gen(model, tokenizer, prompt=prompt, image=self._prep_image(image_data), max_tokens=max_tokens, sampler=_mlx_sampler(temperature), draft_model=draft_model, draft_kind="mtp")
|
|
530
|
-
else:
|
|
531
|
-
from mlx_lm import generate as lm_gen
|
|
532
|
-
return lm_gen(model, tokenizer, prompt=prompt, max_tokens=max_tokens, sampler=_mlx_sampler(temperature), draft_model=draft_model)
|
|
539
|
+
from mlx_vlm import generate as vlm_gen
|
|
540
|
+
return vlm_gen(model, tokenizer, prompt=prompt, image=self._prep_image(image_data) if image_data else None, max_tokens=max_tokens, sampler=_mlx_sampler(temperature), draft_model=draft_model, draft_kind="mtp")
|
|
533
541
|
result = await loop.run_in_executor(executor, _gen)
|
|
534
542
|
# mlx-vlm might return a GenerationResult object; extract the text
|
|
535
543
|
if hasattr(result, "text"):
|
|
@@ -567,12 +575,7 @@ class LLMRouter:
|
|
|
567
575
|
return
|
|
568
576
|
|
|
569
577
|
model, tokenizer, draft_model = self._cache[self._current]
|
|
570
|
-
|
|
571
|
-
prompt = (
|
|
572
|
-
self._build_vlm_prompt(model, tokenizer, message, context, 1)
|
|
573
|
-
if image_data and is_gemma4 and VLM_AVAILABLE
|
|
574
|
-
else self._build_prompt(message, context, tokenizer)
|
|
575
|
-
)
|
|
578
|
+
prompt = self._build_vlm_prompt(model, tokenizer, message, context, 1 if image_data else 0)
|
|
576
579
|
loop = asyncio.get_event_loop()
|
|
577
580
|
queue = asyncio.Queue()
|
|
578
581
|
|
|
@@ -580,13 +583,8 @@ class LLMRouter:
|
|
|
580
583
|
import mlx.core as mx
|
|
581
584
|
mx.set_default_device(mx.gpu)
|
|
582
585
|
try:
|
|
583
|
-
|
|
584
|
-
if
|
|
585
|
-
from mlx_vlm import stream_generate as vlm_stream
|
|
586
|
-
gen = vlm_stream(model, tokenizer, prompt=prompt, image=self._prep_image(image_data), max_tokens=max_tokens, sampler=_mlx_sampler(temperature), draft_model=draft_model, draft_kind="mtp")
|
|
587
|
-
else:
|
|
588
|
-
from mlx_lm import stream_generate as lm_stream
|
|
589
|
-
gen = lm_stream(model, tokenizer, prompt=prompt, max_tokens=max_tokens, sampler=_mlx_sampler(temperature), draft_model=draft_model)
|
|
586
|
+
from mlx_vlm import stream_generate as vlm_stream
|
|
587
|
+
gen = vlm_stream(model, tokenizer, prompt=prompt, image=self._prep_image(image_data) if image_data else None, max_tokens=max_tokens, sampler=_mlx_sampler(temperature), draft_model=draft_model, draft_kind="mtp")
|
|
590
588
|
|
|
591
589
|
for chunk in gen:
|
|
592
590
|
text = chunk.text if hasattr(chunk, "text") else (chunk[0] if isinstance(chunk, tuple) else str(chunk))
|
|
@@ -675,13 +673,8 @@ class LLMRouter:
|
|
|
675
673
|
def _gen():
|
|
676
674
|
import mlx.core as mx
|
|
677
675
|
mx.set_default_device(mx.gpu)
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
from mlx_vlm import generate as vlm_gen
|
|
681
|
-
return vlm_gen(model, tokenizer, prompt=prompt, image=None, max_tokens=max_tokens, sampler=_mlx_sampler(temperature), draft_model=draft_model, draft_kind="mtp")
|
|
682
|
-
else:
|
|
683
|
-
from mlx_lm import generate as lm_gen
|
|
684
|
-
return lm_gen(model, tokenizer, prompt=prompt, max_tokens=max_tokens, sampler=_mlx_sampler(temperature), draft_model=draft_model)
|
|
676
|
+
from mlx_vlm import generate as vlm_gen
|
|
677
|
+
return vlm_gen(model, tokenizer, prompt=prompt, image=None, max_tokens=max_tokens, sampler=_mlx_sampler(temperature), draft_model=draft_model, draft_kind="mtp")
|
|
685
678
|
result = await loop.run_in_executor(executor, _gen)
|
|
686
679
|
if hasattr(result, "text"):
|
|
687
680
|
return normalize_branding(result.text)
|
|
@@ -742,13 +735,8 @@ class LLMRouter:
|
|
|
742
735
|
import mlx.core as mx
|
|
743
736
|
mx.set_default_device(mx.gpu)
|
|
744
737
|
try:
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
from mlx_vlm import stream_generate as vlm_stream
|
|
748
|
-
gen = vlm_stream(model, tokenizer, prompt=prompt, image=None, max_tokens=max_tokens, sampler=_mlx_sampler(temperature), draft_model=draft_model, draft_kind="mtp")
|
|
749
|
-
else:
|
|
750
|
-
from mlx_lm import stream_generate as lm_stream
|
|
751
|
-
gen = lm_stream(model, tokenizer, prompt=prompt, max_tokens=max_tokens, sampler=_mlx_sampler(temperature), draft_model=draft_model)
|
|
738
|
+
from mlx_vlm import stream_generate as vlm_stream
|
|
739
|
+
gen = vlm_stream(model, tokenizer, prompt=prompt, image=None, max_tokens=max_tokens, sampler=_mlx_sampler(temperature), draft_model=draft_model, draft_kind="mtp")
|
|
752
740
|
for chunk in gen:
|
|
753
741
|
text = chunk.text if hasattr(chunk, "text") else (chunk[0] if isinstance(chunk, tuple) else str(chunk))
|
|
754
742
|
loop.call_soon_threadsafe(queue.put_nowait, text)
|
package/ltcai_cli.py
CHANGED
|
@@ -97,9 +97,8 @@ def doctor() -> int:
|
|
|
97
97
|
("FastAPI", _has_module("fastapi"), "required server dependency", True),
|
|
98
98
|
("Uvicorn", _has_module("uvicorn"), "required server dependency", True),
|
|
99
99
|
("OpenAI SDK", _has_module("openai"), "required for cloud providers", False),
|
|
100
|
-
("MLX", _has_module("mlx"), "required for Apple Silicon
|
|
101
|
-
("MLX-
|
|
102
|
-
("MLX-VLM", _has_module("mlx_vlm"), "required for Gemma/VLM models", False),
|
|
100
|
+
("MLX", _has_module("mlx"), "required for Apple Silicon multimodal models", False),
|
|
101
|
+
("MLX-VLM", _has_module("mlx_vlm"), "required for Gemma-4/VLM models", False),
|
|
103
102
|
("Ollama binary", shutil.which("ollama") is not None, "optional local-server engine", False),
|
|
104
103
|
]
|
|
105
104
|
data_dir = Path(os.getenv("LATTICEAI_DATA_DIR") or Path.home() / ".ltcai")
|
package/package.json
CHANGED
package/static/chat.html
CHANGED
|
@@ -237,8 +237,7 @@
|
|
|
237
237
|
<button class="hdc-btn" onclick="openDataGraph()"><i class="ti ti-arrow-right"></i> 그래프 보기</button>
|
|
238
238
|
</div>
|
|
239
239
|
|
|
240
|
-
|
|
241
|
-
<div class="hdc-card hdc-setup" id="home-setup-card" style="display:none">
|
|
240
|
+
<div class="hdc-card hdc-setup" id="home-setup-card">
|
|
242
241
|
<div class="hdc-title"><i class="ti ti-settings-automation"></i> 자동 설정</div>
|
|
243
242
|
<div class="hdc-setup-count">
|
|
244
243
|
<span id="home-setup-num">—</span>
|
|
@@ -279,7 +278,7 @@
|
|
|
279
278
|
<div class="chat-capability-row" id="chat-capability-row">
|
|
280
279
|
<span>파일 생성</span>
|
|
281
280
|
<span>지식 정리</span>
|
|
282
|
-
<span
|
|
281
|
+
<span>내 컴퓨터에서 실행</span>
|
|
283
282
|
</div>
|
|
284
283
|
</div>
|
|
285
284
|
</section>
|
|
@@ -359,12 +358,12 @@
|
|
|
359
358
|
<button class="mode-card" id="mode-card-advanced" onclick="selectMode('advanced')">
|
|
360
359
|
<div class="mode-icon"><i class="ti ti-terminal-2"></i></div>
|
|
361
360
|
<h3 data-i18n="mode_advanced">고급 모드</h3>
|
|
362
|
-
<span data-i18n="mode_advanced_sub"
|
|
361
|
+
<span data-i18n="mode_advanced_sub">같은 기능을 더 자세한 설명으로 표시</span>
|
|
363
362
|
</button>
|
|
364
363
|
<button class="mode-card" id="mode-card-admin" onclick="selectMode('admin')">
|
|
365
364
|
<div class="mode-icon"><i class="ti ti-shield-lock"></i></div>
|
|
366
365
|
<h3 data-i18n="mode_admin">관리자 모드</h3>
|
|
367
|
-
<span data-i18n="mode_admin_sub"
|
|
366
|
+
<span data-i18n="mode_admin_sub">사용자, 정책, 감사 로그 관리</span>
|
|
368
367
|
</button>
|
|
369
368
|
</div>
|
|
370
369
|
</section>
|
|
@@ -375,7 +374,7 @@
|
|
|
375
374
|
<div class="model-panel-header">
|
|
376
375
|
<div>
|
|
377
376
|
<h2 data-i18n="model_switcher">모델 스위처</h2>
|
|
378
|
-
<p style="color: var(--muted); font-size: 12px; margin-top: 4px;" data-i18n="model_switcher_sub"
|
|
377
|
+
<p style="color: var(--muted); font-size: 12px; margin-top: 4px;" data-i18n="model_switcher_sub">제작 국가, 제작 회사, 실행 방식, 인터넷 사용 여부를 확인하고 모델을 선택합니다.</p>
|
|
379
378
|
</div>
|
|
380
379
|
<button class="admin-close" onclick="closeModelPanel()"><i class="ti ti-x"></i></button>
|
|
381
380
|
</div>
|
package/static/scripts/chat.js
CHANGED
|
@@ -227,7 +227,7 @@ const chatViewport = document.getElementById('chat-viewport');
|
|
|
227
227
|
my_status: '내 상태 보기', auto_setup: '자동 설정',
|
|
228
228
|
nav_home: '홈', nav_chat: '채팅', nav_workspace: 'Workspace OS', nav_knowledge: '지식 그래프',
|
|
229
229
|
nav_pipeline: '파이프라인', nav_files: '내 컴퓨터',
|
|
230
|
-
nav_model_status: '모델 상태', nav_runtime: '
|
|
230
|
+
nav_model_status: '모델 상태', nav_runtime: '실행 방식 설정',
|
|
231
231
|
nav_advanced_settings: '고급 설정',
|
|
232
232
|
history_search_ph: '대화 검색...', new_chat: 'New Chat',
|
|
233
233
|
history_section: '대화', history_empty: '아직 저장된 대화가 없습니다.',
|
|
@@ -235,7 +235,7 @@ const chatViewport = document.getElementById('chat-viewport');
|
|
|
235
235
|
confirm_delete_chat: '이 대화를 삭제할까요?',
|
|
236
236
|
home_greeting: '안녕하세요, {name}님',
|
|
237
237
|
home_greeting_short: '안녕하세요',
|
|
238
|
-
ops_ai_model: 'AI 모델', ops_local_runtime: '
|
|
238
|
+
ops_ai_model: 'AI 모델', ops_local_runtime: '내 컴퓨터에서 실행',
|
|
239
239
|
ops_admin_network: '관리자 네트워크', ops_admin_security: '관리자 보안',
|
|
240
240
|
ops_pipeline_value: '멀티 LLM 파이프라인',
|
|
241
241
|
ops_pipeline_meta: 'Plan → Execute → Review 모델 설정',
|
|
@@ -248,7 +248,7 @@ const chatViewport = document.getElementById('chat-viewport');
|
|
|
248
248
|
home_recent_files: '최근 파일', home_open_files: '파일 열기', home_no_files: '파일이 없습니다',
|
|
249
249
|
chat_intro_title: 'Lattice AI',
|
|
250
250
|
chat_intro_desc: '로컬 모델, 파일, 지식 그래프, 멀티모달 작업을 한 대화 흐름에서 연결하는 개인 AI 워크스페이스입니다.',
|
|
251
|
-
chat_cap_file: '파일 생성', chat_cap_knowledge: '지식 정리', chat_cap_runtime: '
|
|
251
|
+
chat_cap_file: '파일 생성', chat_cap_knowledge: '지식 정리', chat_cap_runtime: '내 컴퓨터에서 실행',
|
|
252
252
|
// 계정 모달
|
|
253
253
|
tab_profile: '프로필', tab_password: '비밀번호',
|
|
254
254
|
label_name: '이름', label_nickname: '닉네임',
|
|
@@ -283,12 +283,12 @@ const chatViewport = document.getElementById('chat-viewport');
|
|
|
283
283
|
mode_default: '기본 모드',
|
|
284
284
|
mode_default_sub: '대화, 파일 생성, 지식 정리를 한 화면에서',
|
|
285
285
|
mode_advanced: '고급 모드',
|
|
286
|
-
mode_advanced_sub: '
|
|
286
|
+
mode_advanced_sub: '같은 기능을 더 자세한 설명으로 표시',
|
|
287
287
|
mode_admin: '관리자 모드',
|
|
288
|
-
mode_admin_sub: '
|
|
288
|
+
mode_admin_sub: '사용자, 정책, 감사 로그 관리',
|
|
289
289
|
// 패널 제목
|
|
290
290
|
model_switcher: '모델 스위처',
|
|
291
|
-
model_switcher_sub: '실행
|
|
291
|
+
model_switcher_sub: '제작 국가, 제작 회사, 실행 방식, 인터넷 사용 여부를 확인하고 모델을 선택합니다.',
|
|
292
292
|
// 권한 다이얼로그
|
|
293
293
|
perm_title: '파일 접근 요청', btn_deny: '거부', btn_allow: '허용',
|
|
294
294
|
},
|
|
@@ -306,7 +306,7 @@ const chatViewport = document.getElementById('chat-viewport');
|
|
|
306
306
|
my_status: 'My Status', auto_setup: 'Auto Setup',
|
|
307
307
|
nav_home: 'Home', nav_chat: 'Chat', nav_workspace: 'Workspace OS', nav_knowledge: 'Knowledge Graph',
|
|
308
308
|
nav_pipeline: 'Pipeline', nav_files: 'My Computer',
|
|
309
|
-
nav_model_status: 'Model Status', nav_runtime: '
|
|
309
|
+
nav_model_status: 'Model Status', nav_runtime: 'Execution Settings',
|
|
310
310
|
nav_advanced_settings: 'Advanced Settings',
|
|
311
311
|
history_search_ph: 'Search chats...', new_chat: 'New Chat',
|
|
312
312
|
history_section: 'Chats', history_empty: 'No saved chats yet.',
|
|
@@ -314,7 +314,7 @@ const chatViewport = document.getElementById('chat-viewport');
|
|
|
314
314
|
confirm_delete_chat: 'Delete this chat?',
|
|
315
315
|
home_greeting: 'Hello, {name}',
|
|
316
316
|
home_greeting_short: 'Hello',
|
|
317
|
-
ops_ai_model: 'AI model', ops_local_runtime: '
|
|
317
|
+
ops_ai_model: 'AI model', ops_local_runtime: 'Runs on this computer',
|
|
318
318
|
ops_admin_network: 'Admin Network', ops_admin_security: 'Admin Security',
|
|
319
319
|
ops_pipeline_value: 'Multi-LLM Pipeline',
|
|
320
320
|
ops_pipeline_meta: 'Plan → Execute → Review model setup',
|
|
@@ -327,7 +327,7 @@ const chatViewport = document.getElementById('chat-viewport');
|
|
|
327
327
|
home_recent_files: 'Recent Files', home_open_files: 'Open Files', home_no_files: 'No files yet',
|
|
328
328
|
chat_intro_title: 'Lattice AI',
|
|
329
329
|
chat_intro_desc: 'A personal AI workspace that connects local models, files, knowledge graphs, and multimodal work in one conversation flow.',
|
|
330
|
-
chat_cap_file: 'File creation', chat_cap_knowledge: 'Knowledge organizing', chat_cap_runtime: '
|
|
330
|
+
chat_cap_file: 'File creation', chat_cap_knowledge: 'Knowledge organizing', chat_cap_runtime: 'Runs on this computer',
|
|
331
331
|
// Account modal
|
|
332
332
|
tab_profile: 'Profile', tab_password: 'Password',
|
|
333
333
|
label_name: 'Name', label_nickname: 'Nickname',
|
|
@@ -367,7 +367,7 @@ const chatViewport = document.getElementById('chat-viewport');
|
|
|
367
367
|
mode_admin_sub: 'Admin dashboard for operators',
|
|
368
368
|
// Panel titles
|
|
369
369
|
model_switcher: 'Model Switcher',
|
|
370
|
-
model_switcher_sub: '
|
|
370
|
+
model_switcher_sub: 'Check maker country, maker company, execution method, internet use, then select a model.',
|
|
371
371
|
// Permission dialog
|
|
372
372
|
perm_title: 'File Access Request', btn_deny: 'Deny', btn_allow: 'Allow',
|
|
373
373
|
}
|
|
@@ -650,11 +650,8 @@ const chatViewport = document.getElementById('chat-viewport');
|
|
|
650
650
|
}
|
|
651
651
|
|
|
652
652
|
async function _loadHomeDashboard() {
|
|
653
|
-
const mode = getCurrentMode();
|
|
654
|
-
|
|
655
|
-
// 자동 설정 카드: 고급/관리자 모드만
|
|
656
653
|
const setupCard = document.getElementById('home-setup-card');
|
|
657
|
-
if (setupCard) setupCard.style.display =
|
|
654
|
+
if (setupCard) setupCard.style.display = 'flex';
|
|
658
655
|
|
|
659
656
|
// 모델 + sysinfo 병렬 fetch
|
|
660
657
|
try {
|
|
@@ -953,7 +950,7 @@ const chatViewport = document.getElementById('chat-viewport');
|
|
|
953
950
|
const selected = models.find(item => item.checked && !item.disabled && (item.model_id || item.action?.model_id))
|
|
954
951
|
|| models.find(item => !item.disabled && (item.model_id || item.action?.model_id));
|
|
955
952
|
const zero = onboardingRecs?.summary?.zero_config || onboardingEnv?.zero_config?.recommend || {};
|
|
956
|
-
const modelId = selected?.model_id || selected?.action?.model_id || zero.model_id || 'mlx-community/
|
|
953
|
+
const modelId = selected?.model_id || selected?.action?.model_id || zero.model_id || 'mlx-community/gemma-4-12b-it-4bit';
|
|
957
954
|
const engineItem = (onboardingRecs?.engines || []).find(item => item.checked && !item.disabled);
|
|
958
955
|
const runtime = engineItem?.name || (zero.runtime === 'mlx' ? 'MLX' : zero.runtime) || 'MLX';
|
|
959
956
|
return {
|
|
@@ -1351,7 +1348,7 @@ const chatViewport = document.getElementById('chat-viewport');
|
|
|
1351
1348
|
<button class="onboarding-mode" onclick="finishOnboarding('advanced')">
|
|
1352
1349
|
<i class="ti ti-terminal-2"></i>
|
|
1353
1350
|
<h3>고급 모드</h3>
|
|
1354
|
-
<p
|
|
1351
|
+
<p>같은 기능을 유지하면서 모델, 메모리, 실행 방식 설명을 더 자세히 표시합니다.</p>
|
|
1355
1352
|
</button>
|
|
1356
1353
|
${adminCard}
|
|
1357
1354
|
</div>
|
|
@@ -1528,7 +1525,7 @@ const chatViewport = document.getElementById('chat-viewport');
|
|
|
1528
1525
|
const isUnavailable = unsupported || (!isLocalEngine && engineMissing) || keyMissing || verifyFailed;
|
|
1529
1526
|
const badge = unsupported ? '현재 환경 미지원'
|
|
1530
1527
|
: engineMissing && isLocalEngine ? '설치 후 자동 로드'
|
|
1531
|
-
: engineMissing ? '
|
|
1528
|
+
: engineMissing ? '실행 도구 설치 필요'
|
|
1532
1529
|
: needsPull ? '다운로드 후 자동 로드'
|
|
1533
1530
|
: keyMissing ? `필요: ${model.requires || 'API key'}`
|
|
1534
1531
|
: verifyFailed ? `실패: ${model.verify_reason || '검증 실패'}`
|
|
@@ -1539,11 +1536,19 @@ const chatViewport = document.getElementById('chat-viewport');
|
|
|
1539
1536
|
const action = isLocalEngine
|
|
1540
1537
|
? `selectModelByCard('${encodeURIComponent(model.id)}', '${engine?.id || ''}')`
|
|
1541
1538
|
: `loadSelectedModel('${encodeURIComponent(model.id)}', '${engine?.id || ''}')`;
|
|
1539
|
+
const sourceLine = [
|
|
1540
|
+
model.source_country,
|
|
1541
|
+
model.source_company,
|
|
1542
|
+
model.execution_method,
|
|
1543
|
+
model.internet_requirement,
|
|
1544
|
+
model.model_name || model.name,
|
|
1545
|
+
].filter(Boolean).join(' · ');
|
|
1546
|
+
const detailLine = sourceLine || `${model.id} · ${badge}`;
|
|
1542
1547
|
return `
|
|
1543
1548
|
<button class="model-option${cls}" ${isUnavailable ? 'disabled' : ''} onclick="${action}">
|
|
1544
1549
|
<div>
|
|
1545
1550
|
<strong>${escapeHtml(model.name || compactModelName(model.id))}</strong>
|
|
1546
|
-
<span>${escapeHtml(model.id)} · ${escapeHtml(badge)}</span>
|
|
1551
|
+
<span>${escapeHtml(detailLine)}${sourceLine ? `<br>${escapeHtml(model.id)} · ${escapeHtml(badge)}` : ''}</span>
|
|
1547
1552
|
</div>
|
|
1548
1553
|
<i class="ti ${icon}"></i>
|
|
1549
1554
|
</button>
|
|
@@ -1556,12 +1561,9 @@ const chatViewport = document.getElementById('chat-viewport');
|
|
|
1556
1561
|
if (raw.includes('claude')) return 'Claude';
|
|
1557
1562
|
if (raw.includes('grok')) return 'Grok';
|
|
1558
1563
|
if (raw.includes('gemini')) return 'Gemini';
|
|
1559
|
-
if (raw.includes('mistral') || raw.includes('mixtral')) return 'Mistral';
|
|
1560
1564
|
if (raw.includes('qwen')) return 'Qwen';
|
|
1561
1565
|
if (raw.includes('llama')) return 'Llama';
|
|
1562
1566
|
if (raw.includes('gemma')) return 'Gemma';
|
|
1563
|
-
if (raw.includes('phi')) return 'Phi';
|
|
1564
|
-
if (raw.includes('deepseek')) return 'DeepSeek';
|
|
1565
1567
|
return (model?.family || '기타');
|
|
1566
1568
|
}
|
|
1567
1569
|
|
|
@@ -1642,17 +1644,17 @@ const chatViewport = document.getElementById('chat-viewport');
|
|
|
1642
1644
|
const cloudEngines = cachedEngineList.filter(engine => engine.kind === 'cloud');
|
|
1643
1645
|
const isLocal = modelPanelFilter === 'local';
|
|
1644
1646
|
const target = isLocal ? localEngines : cloudEngines;
|
|
1645
|
-
const emptyText = isLocal ? '
|
|
1647
|
+
const emptyText = isLocal ? '내 컴퓨터에서 실행할 수 있는 항목이 없습니다.' : '인터넷 연결 후 사용할 수 있는 항목이 없습니다.';
|
|
1646
1648
|
|
|
1647
1649
|
modelList.innerHTML = `
|
|
1648
|
-
<div class="model-group-title"
|
|
1650
|
+
<div class="model-group-title">실행 방식</div>
|
|
1649
1651
|
<div class="model-filter">
|
|
1650
|
-
<button class="model-filter-btn ${isLocal ? 'active' : ''}" onclick="setModelPanelFilter('local')"
|
|
1651
|
-
<button class="model-filter-btn ${!isLocal ? 'active' : ''}" onclick="setModelPanelFilter('cloud')"
|
|
1652
|
+
<button class="model-filter-btn ${isLocal ? 'active' : ''}" onclick="setModelPanelFilter('local')">내 컴퓨터에서만 실행</button>
|
|
1653
|
+
<button class="model-filter-btn ${!isLocal ? 'active' : ''}" onclick="setModelPanelFilter('cloud')">인터넷 연결 후 사용</button>
|
|
1652
1654
|
</div>
|
|
1653
1655
|
${!isLocal ? `
|
|
1654
1656
|
<div style="display:flex;justify-content:flex-end;margin:-2px 0 8px;">
|
|
1655
|
-
<button class="admin-action" onclick="verifyCloudModels(true)"><i class="ti ti-activity"></i>
|
|
1657
|
+
<button class="admin-action" onclick="verifyCloudModels(true)"><i class="ti ti-activity"></i> 인터넷 모델 실사용 테스트</button>
|
|
1656
1658
|
</div>
|
|
1657
1659
|
` : ''}
|
|
1658
1660
|
${target.length ? target.map(engineCardHtml).join('') : `<div class="sensitivity-preview">${emptyText}</div>`}
|
|
@@ -1680,7 +1682,7 @@ const chatViewport = document.getElementById('chat-viewport');
|
|
|
1680
1682
|
|
|
1681
1683
|
async function verifyCloudModels(force = true) {
|
|
1682
1684
|
const modelList = document.getElementById('model-list');
|
|
1683
|
-
modelList.innerHTML = `<div class="sensitivity-preview"
|
|
1685
|
+
modelList.innerHTML = `<div class="sensitivity-preview">인터넷 모델 실사용 테스트 중입니다... (연결 방식별로 수 초~수십 초)</div>`;
|
|
1684
1686
|
try {
|
|
1685
1687
|
const res = await apiFetch('/engines/verify-cloud', {
|
|
1686
1688
|
method: 'POST',
|
|
@@ -1688,9 +1690,9 @@ const chatViewport = document.getElementById('chat-viewport');
|
|
|
1688
1690
|
body: JSON.stringify({ force })
|
|
1689
1691
|
});
|
|
1690
1692
|
const data = await res.json();
|
|
1691
|
-
if (!res.ok) throw new Error(data.detail || '
|
|
1693
|
+
if (!res.ok) throw new Error(data.detail || '인터넷 모델 실사용 테스트 실패');
|
|
1692
1694
|
await openModelPanel();
|
|
1693
|
-
addMessage('ai',
|
|
1695
|
+
addMessage('ai', `인터넷 모델 실사용 테스트를 완료했습니다. 실패한 모델은 잠금 상태로 표시됩니다.`);
|
|
1694
1696
|
} catch (e) {
|
|
1695
1697
|
modelList.innerHTML = `
|
|
1696
1698
|
<div class="sensitivity-preview">${escapeHtml(e.message)}</div>
|
|
@@ -1832,7 +1834,7 @@ const chatViewport = document.getElementById('chat-viewport');
|
|
|
1832
1834
|
</div>
|
|
1833
1835
|
</div>
|
|
1834
1836
|
<div id="model-download-detail" class="model-download-detail">
|
|
1835
|
-
|
|
1837
|
+
실행 도구 설치, 모델 다운로드, 연결 준비, 로드까지 자동으로 진행합니다. 첫 실행은 수 분이 걸릴 수 있습니다.
|
|
1836
1838
|
</div>
|
|
1837
1839
|
</div>
|
|
1838
1840
|
`;
|
|
@@ -4363,7 +4365,7 @@ const chatViewport = document.getElementById('chat-viewport');
|
|
|
4363
4365
|
const keys = env.api_keys || {};
|
|
4364
4366
|
|
|
4365
4367
|
const mlxLabel = mlx.available
|
|
4366
|
-
? (mlx.
|
|
4368
|
+
? (mlx.mlx_vlm ? 'MLX-VLM 설치됨' : 'MLX 설치됨 · MLX-VLM 필요')
|
|
4367
4369
|
: '미설치';
|
|
4368
4370
|
|
|
4369
4371
|
const cloudKeys = Object.entries(keys).filter(([,v]) => v).map(([k]) => k.toUpperCase());
|
|
@@ -4378,7 +4380,7 @@ const chatViewport = document.getElementById('chat-viewport');
|
|
|
4378
4380
|
{ icon: mlx.available ? '✅' : '⚠️', label: 'MLX', value: mlxLabel, ok: mlx.available },
|
|
4379
4381
|
{ icon: tools.ollama ? '✅' : '○', label: 'Ollama', value: tools.ollama ? '설치됨' : '미설치', ok: true },
|
|
4380
4382
|
{ icon: tools.brew ? '✅' : '○', label: 'Homebrew', value: tools.brew ? '설치됨' : '미설치', ok: true },
|
|
4381
|
-
{ icon: cloudKeys.length ? '✅' : '○', label: '
|
|
4383
|
+
{ icon: cloudKeys.length ? '✅' : '○', label: '인터넷 AI',
|
|
4382
4384
|
value: cloudKeys.length ? cloudKeys.join(', ') : '없음', ok: true },
|
|
4383
4385
|
{ icon: env.os === 'Darwin' ? '🍎' : '🐧',
|
|
4384
4386
|
label: '운영체제',
|
|
@@ -4607,10 +4609,6 @@ const chatViewport = document.getElementById('chat-viewport');
|
|
|
4607
4609
|
let _mcpCurrentTab = 'registry';
|
|
4608
4610
|
|
|
4609
4611
|
async function openMcpModal() {
|
|
4610
|
-
if (getCurrentMode() === 'default') {
|
|
4611
|
-
showToast('고급 모드에서 사용할 수 있습니다.');
|
|
4612
|
-
return;
|
|
4613
|
-
}
|
|
4614
4612
|
document.getElementById('mcp-modal-overlay').classList.add('open');
|
|
4615
4613
|
await renderMcpModal(_mcpCurrentTab);
|
|
4616
4614
|
}
|