ltcai 0.3.1 → 0.4.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.
@@ -1469,7 +1469,7 @@ const chatViewport = document.getElementById('chat-viewport');
1469
1469
  const icon = isUnavailable ? 'ti-lock' : (engineMissing || needsPull) ? 'ti-cloud-download' : verifyUnknown ? 'ti-activity' : 'ti-switch-3';
1470
1470
  const cls = (engineMissing || needsPull) && isLocalEngine ? ' needs-pull' : '';
1471
1471
  const action = isLocalEngine
1472
- ? `prepareAndLoadModel('${encodeURIComponent(model.id)}', '${engine?.id || ''}')`
1472
+ ? `selectModelByCard('${encodeURIComponent(model.id)}', '${engine?.id || ''}')`
1473
1473
  : `loadSelectedModel('${encodeURIComponent(model.id)}', '${engine?.id || ''}')`;
1474
1474
  return `
1475
1475
  <button class="model-option${cls}" ${isUnavailable ? 'disabled' : ''} onclick="${action}">
@@ -1669,7 +1669,17 @@ const chatViewport = document.getElementById('chat-viewport');
1669
1669
  if (!res.ok) throw new Error(data.detail || '모델 로드에 실패했습니다.');
1670
1670
  closeModelPanel();
1671
1671
  await loadModelStatus();
1672
- addMessage('ai', `모델을 <b>${escapeHtml(compactModelName(data.current || modelId))}</b>로 전환했습니다.`);
1672
+ // 피드백 #1/#2: 클라우드 경로도 백엔드 current 단일 진실원으로 사용한다.
1673
+ const actualCurrent = resolveActualCurrent(data, modelId);
1674
+ setCurrentModel(actualCurrent);
1675
+ updateCurrentModelUI(actualCurrent);
1676
+ let statusLine = `모델을 <b>${escapeHtml(compactModelName(actualCurrent))}</b>로 전환했습니다.`;
1677
+ const compat = describeCompatibility(data);
1678
+ if (compat) {
1679
+ statusLine += `<br><span class="sensitivity-preview">${escapeHtml(compat.message)}</span>`;
1680
+ showModelCompatibilityWarning(data);
1681
+ }
1682
+ addMessage('ai', statusLine);
1673
1683
  } catch (e) {
1674
1684
  document.getElementById('model-list').innerHTML = `
1675
1685
  <div class="sensitivity-preview">${escapeHtml(e.message)}</div>
@@ -1829,6 +1839,66 @@ const chatViewport = document.getElementById('chat-viewport');
1829
1839
  if (buffer.trim()) dispatchBlock(buffer.trim());
1830
1840
  }
1831
1841
 
1842
+ // 피드백 #1/#2: "사용자가 보는 현재 모델" === "실제로 채팅에 사용되는 모델".
1843
+ // 백엔드가 돌려준 current/resolution을 단일 진실원으로 사용한다.
1844
+ function resolveActualCurrent(finalData, fallbackId) {
1845
+ if (!finalData) return fallbackId || '';
1846
+ return (
1847
+ finalData.current
1848
+ || (finalData.resolution && (finalData.resolution.expected_current || finalData.resolution.resolved_model))
1849
+ || fallbackId
1850
+ || ''
1851
+ );
1852
+ }
1853
+
1854
+ function setCurrentModel(modelId) {
1855
+ if (!modelId) return;
1856
+ window.__latticeActiveModel = modelId;
1857
+ }
1858
+
1859
+ function updateCurrentModelUI(modelId) {
1860
+ if (!modelId) return;
1861
+ const modelEl = document.getElementById('ops-model');
1862
+ if (modelEl) {
1863
+ modelEl.textContent = compactModelName(modelId);
1864
+ modelEl.title = modelId;
1865
+ }
1866
+ const metaEl = document.getElementById('ops-model-meta');
1867
+ if (metaEl && !metaEl.dataset.loaded) {
1868
+ metaEl.dataset.loaded = 'true';
1869
+ }
1870
+ }
1871
+
1872
+ function describeCompatibility(finalData) {
1873
+ if (!finalData) return null;
1874
+ if (finalData.ready_to_chat === false) {
1875
+ const reason = (finalData.smoke_test && finalData.smoke_test.reason) || '채팅 호환성 검사 실패';
1876
+ return {
1877
+ severity: 'degraded',
1878
+ message: `⚠️ 채팅 호환성이 낮습니다 (${reason}). 다른 실행 엔진을 추천합니다.`,
1879
+ };
1880
+ }
1881
+ if (finalData.compatibility_status === 'degraded') {
1882
+ return {
1883
+ severity: 'degraded',
1884
+ message: '⚠️ 모델은 로드됐지만 호환성 테스트가 degraded로 나왔습니다. 답변 품질이 일정하지 않을 수 있어요.',
1885
+ };
1886
+ }
1887
+ if (finalData.compatibility_status === 'unknown') {
1888
+ return {
1889
+ severity: 'unknown',
1890
+ message: '호환성 테스트를 완료하지 못했습니다. 채팅이 가능하지만 답변 품질이 일정하지 않을 수 있어요.',
1891
+ };
1892
+ }
1893
+ return null;
1894
+ }
1895
+
1896
+ function showModelCompatibilityWarning(finalData) {
1897
+ const info = describeCompatibility(finalData);
1898
+ if (!info) return;
1899
+ try { showToast(info.message); } catch (_) {}
1900
+ }
1901
+
1832
1902
  async function prepareAndLoadModel(encodedId, engine = '') {
1833
1903
  const modelId = decodeURIComponent(encodedId);
1834
1904
  const displayName = compactModelName(modelId);
@@ -1870,22 +1940,22 @@ const chatViewport = document.getElementById('chat-viewport');
1870
1940
  if (!finalData) throw new Error('모델 준비 응답이 비어 있습니다.');
1871
1941
  closeModelPanel();
1872
1942
  await loadModelStatus();
1873
- // 피드백 #1/#2: 사용자가 클릭한 modelId가 아니라 백엔드가 돌려준 current를 신뢰한다.
1874
- const actualCurrent = finalData.current || (finalData.resolution && finalData.resolution.expected_current) || modelId;
1875
- window.__latticeActiveModel = actualCurrent;
1943
+ const actualCurrent = resolveActualCurrent(finalData, modelId);
1944
+ setCurrentModel(actualCurrent);
1945
+ updateCurrentModelUI(actualCurrent);
1876
1946
  let statusLine = `<b>${escapeHtml(compactModelName(actualCurrent))}</b> 로드 되었습니다.`;
1877
- if (finalData.ready_to_chat === false) {
1878
- const reason = (finalData.smoke_test && finalData.smoke_test.reason) || '채팅 호환성 검사 실패';
1879
- statusLine += `<br><span class="sensitivity-preview">⚠️ 현재 채팅 호환성이 낮습니다 (${escapeHtml(reason)}). 다른 실행 엔진을 추천합니다.</span>`;
1880
- } else if (finalData.compatibility_status === 'unknown') {
1881
- statusLine += `<br><span class="sensitivity-preview">호환성 테스트를 완료하지 못했습니다. 채팅이 가능하지만 답변 품질이 일정하지 않을 수 있어요.</span>`;
1947
+ const compat = describeCompatibility(finalData);
1948
+ if (compat) {
1949
+ statusLine += `<br><span class="sensitivity-preview">${escapeHtml(compat.message)}</span>`;
1882
1950
  }
1883
1951
  addMessage('ai', statusLine);
1952
+ return finalData;
1884
1953
  } catch (e) {
1885
1954
  document.getElementById('model-list').innerHTML = `
1886
1955
  <div class="sensitivity-preview">${escapeHtml(e.message)}</div>
1887
1956
  <button class="admin-action" onclick="openModelPanel()" style="margin-top: 12px;">목록으로 돌아가기</button>
1888
1957
  `;
1958
+ throw e;
1889
1959
  }
1890
1960
  }
1891
1961
 
@@ -1893,17 +1963,36 @@ const chatViewport = document.getElementById('chat-viewport');
1893
1963
  return prepareAndLoadModel(encodedId, engine);
1894
1964
  }
1895
1965
 
1896
- // 피드백 #1/#2: 사용자가 직접 모델을 선택했을 때도 같은 표준 흐름을 타도록 노출.
1897
- async function selectModelByCard(card) {
1898
- if (!card || !card.id) {
1966
+ // 피드백 #1/#2: 모델 카드 클릭 prepare/load smoke test current 반영 → 채팅 가능 여부 표시
1967
+ // 하나의 흐름으로 이어지도록 한다. encodedId/engine 또는 card 객체 양쪽 모두 받는다.
1968
+ async function selectModelByCard(modelIdOrCard, engineArg) {
1969
+ let encoded;
1970
+ let engine = engineArg || '';
1971
+ if (typeof modelIdOrCard === 'string') {
1972
+ encoded = modelIdOrCard.includes('%') ? modelIdOrCard : encodeURIComponent(modelIdOrCard);
1973
+ } else if (modelIdOrCard && modelIdOrCard.id) {
1974
+ encoded = encodeURIComponent(modelIdOrCard.id);
1975
+ if (!engine) {
1976
+ engine = modelIdOrCard.engine
1977
+ || (Array.isArray(modelIdOrCard.engine_options) && modelIdOrCard.engine_options[0]?.engine)
1978
+ || '';
1979
+ }
1980
+ } else {
1899
1981
  throw new Error('모델 카드가 비어 있습니다.');
1900
1982
  }
1901
- const encoded = encodeURIComponent(card.id);
1902
- const engine = card.engine || (Array.isArray(card.engine_options) && card.engine_options[0]?.engine) || '';
1903
- return prepareAndLoadModel(encoded, engine);
1983
+ const result = await prepareAndLoadModel(encoded, engine);
1984
+ if (result && result.current) {
1985
+ setCurrentModel(result.current);
1986
+ updateCurrentModelUI(result.current);
1987
+ }
1988
+ if (result && (result.ready_to_chat === false || result.compatibility_status === 'degraded')) {
1989
+ showModelCompatibilityWarning(result);
1990
+ }
1991
+ return result;
1904
1992
  }
1905
1993
  if (typeof window !== 'undefined') {
1906
1994
  window.selectModelByCard = selectModelByCard;
1995
+ window.prepareAndLoadModel = prepareAndLoadModel;
1907
1996
  }
1908
1997
 
1909
1998
  function fillVpcForm(config) {
package/tools.py CHANGED
@@ -17,7 +17,7 @@ import tempfile
17
17
  import json
18
18
  from html.parser import HTMLParser
19
19
  from pathlib import Path
20
- from typing import Any, Dict, List, Optional
20
+ from typing import Any, Callable, Dict, List, Optional
21
21
 
22
22
  _PLATFORM = platform.system() # "Darwin" | "Windows" | "Linux"
23
23
 
@@ -1435,118 +1435,90 @@ def git_show(revision: str = "HEAD", cwd: Optional[str] = None) -> Dict[str, Any
1435
1435
  return _run_git(["show", "--stat", "--oneline", "--decorate", revision], cwd)
1436
1436
 
1437
1437
 
1438
+ def _h_create_xlsx(args: Dict[str, Any]) -> Dict[str, Any]:
1439
+ rows = args.get("rows", [])
1440
+ if isinstance(rows, str):
1441
+ rows = json.loads(rows)
1442
+ return create_xlsx(rows, args.get("filename", "spreadsheet.xlsx"), args.get("sheet_name", "Sheet1"))
1443
+
1444
+
1445
+ def _h_create_pptx(args: Dict[str, Any]) -> Dict[str, Any]:
1446
+ slides = args.get("slides", [])
1447
+ if isinstance(slides, str):
1448
+ slides = json.loads(slides)
1449
+ return create_pptx(args.get("title", ""), slides, args.get("filename", "presentation.pptx"))
1450
+
1451
+
1452
+ # ── Tool registry: the single source of truth for name → invocation ───────────
1453
+ # Each entry binds the args dict to a tool function. ``execute_tool`` is a
1454
+ # lookup over this table — adding a tool means adding one entry here, not
1455
+ # editing an if/elif chain. server.py's governance map and catalog brief are
1456
+ # checked against ``registered_tools()`` so the three never silently drift.
1457
+ TOOL_HANDLERS: Dict[str, Callable[[Dict[str, Any]], Dict[str, Any]]] = {
1458
+ # filesystem
1459
+ "list_dir": lambda a: list_dir(a.get("path", ".")),
1460
+ "workspace_tree": lambda a: workspace_tree(a.get("path", "."), a.get("max_depth", 3)),
1461
+ "read_file": lambda a: read_file(a["path"], offset=a.get("offset", 0), limit=a.get("limit", 0), line_numbers=a.get("line_numbers", True)),
1462
+ "write_file": lambda a: write_file(a["path"], a.get("content", "")),
1463
+ "edit_file": lambda a: edit_file(a["path"], a["old_string"], a["new_string"], replace_all=bool(a.get("replace_all", False))),
1464
+ "grep": lambda a: grep(a["pattern"], path=a.get("path", "."), glob=a.get("glob"), max_results=a.get("max_results", 50), case_insensitive=bool(a.get("case_insensitive", False)), context_lines=a.get("context_lines", 0)),
1465
+ "search_files": lambda a: search_files(a["query"], a.get("path", "."), a.get("max_results", 20)),
1466
+ "inspect_html": lambda a: inspect_html(a["path"]),
1467
+ "preview_url": lambda a: preview_url(a.get("path", "index.html")),
1468
+ # planning
1469
+ "todo_read": lambda a: todo_read(),
1470
+ "todo_write": lambda a: todo_write(a.get("todos") or []),
1471
+ # documents
1472
+ "create_docx": lambda a: create_docx(a.get("title", ""), a.get("body", ""), a.get("filename", "document.docx")),
1473
+ "create_xlsx": _h_create_xlsx,
1474
+ "create_pptx": _h_create_pptx,
1475
+ "create_pdf": lambda a: create_pdf(a.get("title", ""), a.get("body", ""), a.get("filename", "document.pdf")),
1476
+ "create_web_project": lambda a: create_web_project(a.get("path", ""), a.get("framework", "react"), a.get("template", "vite")),
1477
+ # local filesystem
1478
+ "local_list": lambda a: local_list(a["path"]),
1479
+ "local_read": lambda a: local_read(a["path"]),
1480
+ "local_write": lambda a: local_write(a["path"], a.get("content", "")),
1481
+ "read_document": lambda a: read_document(a["path"]),
1482
+ "network_status": lambda a: network_status(),
1483
+ # computer use
1484
+ "computer_screenshot": lambda a: computer_screenshot(),
1485
+ "computer_open_app": lambda a: computer_open_app(a.get("app", "Google Chrome")),
1486
+ "computer_open_url": lambda a: computer_open_url(a["url"], a.get("app", "Google Chrome")),
1487
+ "computer_click": lambda a: computer_click(a.get("x", 0), a.get("y", 0), a.get("button", "left"), a.get("double", False)),
1488
+ "computer_type": lambda a: computer_type(a["text"], a.get("interval", 0.04)),
1489
+ "computer_key": lambda a: computer_key(a["key"]),
1490
+ "computer_scroll": lambda a: computer_scroll(a.get("x", 0), a.get("y", 0), a.get("direction", "down"), a.get("clicks", 3)),
1491
+ "computer_move": lambda a: computer_move(a.get("x", 0), a.get("y", 0)),
1492
+ "computer_drag": lambda a: computer_drag(a.get("x1", 0), a.get("y1", 0), a.get("x2", 0), a.get("y2", 0)),
1493
+ "computer_status": lambda a: computer_status(),
1494
+ "chrome_status": lambda a: desktop_bridge_status(),
1495
+ "computer_use_status": lambda a: desktop_bridge_status(),
1496
+ # knowledge / obsidian
1497
+ "knowledge_save": lambda a: knowledge_save(a["content"], a.get("folder", "00_Raw"), a.get("title")),
1498
+ "knowledge_search": lambda a: knowledge_search(a["query"], a.get("max_results", 5)),
1499
+ "knowledge_tree": lambda a: knowledge_tree(),
1500
+ "obsidian_save": lambda a: obsidian_save(a["content"], a.get("folder", "00_Raw"), a.get("title")),
1501
+ "obsidian_search": lambda a: obsidian_search(a["query"], a.get("max_results", 5)),
1502
+ "obsidian_tree": lambda a: obsidian_tree(),
1503
+ # git (read-only)
1504
+ "git_status": lambda a: git_status(a.get("cwd")),
1505
+ "git_diff": lambda a: git_diff(a.get("path"), a.get("cwd")),
1506
+ "git_log": lambda a: git_log(a.get("max_count", 5), a.get("cwd")),
1507
+ "git_show": lambda a: git_show(a.get("revision", "HEAD"), a.get("cwd")),
1508
+ # exec
1509
+ "run_command": lambda a: run_command(a["command"], a.get("cwd")),
1510
+ "build_project": lambda a: build_project(a.get("cwd"), a.get("script", "build")),
1511
+ "deploy_project": lambda a: deploy_project(a.get("cwd"), a.get("script", "deploy")),
1512
+ }
1513
+
1514
+
1515
+ def registered_tools() -> frozenset:
1516
+ """Names dispatchable through ``execute_tool`` — the seam other modules verify against."""
1517
+ return frozenset(TOOL_HANDLERS)
1518
+
1519
+
1438
1520
  def execute_tool(action: str, args: Dict[str, Any]) -> Dict[str, Any]:
1439
- if action == "list_dir":
1440
- return list_dir(args.get("path", "."))
1441
- if action == "workspace_tree":
1442
- return workspace_tree(args.get("path", "."), args.get("max_depth", 3))
1443
- if action == "read_file":
1444
- return read_file(
1445
- args["path"],
1446
- offset=args.get("offset", 0),
1447
- limit=args.get("limit", 0),
1448
- line_numbers=args.get("line_numbers", True),
1449
- )
1450
- if action == "write_file":
1451
- return write_file(args["path"], args.get("content", ""))
1452
- if action == "edit_file":
1453
- return edit_file(
1454
- args["path"],
1455
- args["old_string"],
1456
- args["new_string"],
1457
- replace_all=bool(args.get("replace_all", False)),
1458
- )
1459
- if action == "grep":
1460
- return grep(
1461
- args["pattern"],
1462
- path=args.get("path", "."),
1463
- glob=args.get("glob"),
1464
- max_results=args.get("max_results", 50),
1465
- case_insensitive=bool(args.get("case_insensitive", False)),
1466
- context_lines=args.get("context_lines", 0),
1467
- )
1468
- if action == "search_files":
1469
- return search_files(args["query"], args.get("path", "."), args.get("max_results", 20))
1470
- if action == "todo_read":
1471
- return todo_read()
1472
- if action == "todo_write":
1473
- return todo_write(args.get("todos") or [])
1474
- if action == "inspect_html":
1475
- return inspect_html(args["path"])
1476
- if action == "preview_url":
1477
- return preview_url(args.get("path", "index.html"))
1478
- if action == "create_docx":
1479
- return create_docx(args.get("title", ""), args.get("body", ""), args.get("filename", "document.docx"))
1480
- if action == "create_xlsx":
1481
- rows = args.get("rows", [])
1482
- if isinstance(rows, str):
1483
- rows = json.loads(rows)
1484
- return create_xlsx(rows, args.get("filename", "spreadsheet.xlsx"), args.get("sheet_name", "Sheet1"))
1485
- if action == "create_pptx":
1486
- slides = args.get("slides", [])
1487
- if isinstance(slides, str):
1488
- slides = json.loads(slides)
1489
- return create_pptx(args.get("title", ""), slides, args.get("filename", "presentation.pptx"))
1490
- if action == "create_pdf":
1491
- return create_pdf(args.get("title", ""), args.get("body", ""), args.get("filename", "document.pdf"))
1492
- if action == "create_web_project":
1493
- return create_web_project(args.get("path", ""), args.get("framework", "react"), args.get("template", "vite"))
1494
- if action == "local_list":
1495
- return local_list(args["path"])
1496
- if action == "local_read":
1497
- return local_read(args["path"])
1498
- if action == "local_write":
1499
- return local_write(args["path"], args.get("content", ""))
1500
- if action == "read_document":
1501
- return read_document(args["path"])
1502
- if action == "network_status":
1503
- return network_status()
1504
- if action == "computer_screenshot":
1505
- return computer_screenshot()
1506
- if action == "computer_open_app":
1507
- return computer_open_app(args.get("app", "Google Chrome"))
1508
- if action == "computer_open_url":
1509
- return computer_open_url(args["url"], args.get("app", "Google Chrome"))
1510
- if action == "computer_click":
1511
- return computer_click(args.get("x", 0), args.get("y", 0), args.get("button", "left"), args.get("double", False))
1512
- if action == "computer_type":
1513
- return computer_type(args["text"], args.get("interval", 0.04))
1514
- if action == "computer_key":
1515
- return computer_key(args["key"])
1516
- if action == "computer_scroll":
1517
- return computer_scroll(args.get("x", 0), args.get("y", 0), args.get("direction", "down"), args.get("clicks", 3))
1518
- if action == "computer_move":
1519
- return computer_move(args.get("x", 0), args.get("y", 0))
1520
- if action == "computer_drag":
1521
- return computer_drag(args.get("x1", 0), args.get("y1", 0), args.get("x2", 0), args.get("y2", 0))
1522
- if action == "computer_status":
1523
- return computer_status()
1524
- if action in {"chrome_status", "computer_use_status"}:
1525
- return desktop_bridge_status()
1526
- if action == "knowledge_save":
1527
- return knowledge_save(args["content"], args.get("folder", "00_Raw"), args.get("title"))
1528
- if action == "knowledge_search":
1529
- return knowledge_search(args["query"], args.get("max_results", 5))
1530
- if action == "knowledge_tree":
1531
- return knowledge_tree()
1532
- if action == "obsidian_save":
1533
- return obsidian_save(args["content"], args.get("folder", "00_Raw"), args.get("title"))
1534
- if action == "obsidian_search":
1535
- return obsidian_search(args["query"], args.get("max_results", 5))
1536
- if action == "obsidian_tree":
1537
- return obsidian_tree()
1538
- if action == "git_status":
1539
- return git_status(args.get("cwd"))
1540
- if action == "git_diff":
1541
- return git_diff(args.get("path"), args.get("cwd"))
1542
- if action == "git_log":
1543
- return git_log(args.get("max_count", 5), args.get("cwd"))
1544
- if action == "git_show":
1545
- return git_show(args.get("revision", "HEAD"), args.get("cwd"))
1546
- if action == "run_command":
1547
- return run_command(args["command"], args.get("cwd"))
1548
- if action == "build_project":
1549
- return build_project(args.get("cwd"), args.get("script", "build"))
1550
- if action == "deploy_project":
1551
- return deploy_project(args.get("cwd"), args.get("script", "deploy"))
1552
- raise ToolError(f"Unknown action: {action}")
1521
+ handler = TOOL_HANDLERS.get(action)
1522
+ if handler is None:
1523
+ raise ToolError(f"Unknown action: {action}")
1524
+ return handler(args)