ltcai 2.1.0 → 2.2.1
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 +153 -609
- package/auto_setup.py +17 -17
- package/docs/CHANGELOG.md +83 -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/docs/spec-vs-impl.md +13 -10
- 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 +8 -3
- package/static/account.html +3 -1
- package/static/activity.html +5 -2
- package/static/admin.html +5 -1
- package/static/agents.html +5 -2
- package/static/chat.html +12 -10
- package/static/css/responsive.css +597 -0
- package/static/css/tokens.css +224 -165
- package/static/graph.html +12 -2
- package/static/lattice-reference.css +366 -739
- package/static/platform.css +45 -16
- package/static/plugins.html +5 -2
- package/static/scripts/admin.js +33 -33
- package/static/scripts/chat.js +109 -42
- package/static/scripts/graph.js +169 -11
- package/static/scripts/ux.js +167 -0
- package/static/workflows.html +5 -2
- package/static/workspace.css +55 -19
- package/static/workspace.html +5 -2
- package/telegram_bot.py +1 -1
package/static/platform.css
CHANGED
|
@@ -1,19 +1,23 @@
|
|
|
1
1
|
/* Lattice AI v2.0 — shared styling for the Agentic Workspace Platform pages
|
|
2
2
|
(Plugin SDK, Workflow Designer, Multi-Agent Runtime, Realtime Activity). */
|
|
3
3
|
:root {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
--
|
|
7
|
-
--
|
|
8
|
-
--
|
|
9
|
-
--
|
|
10
|
-
--
|
|
11
|
-
--
|
|
4
|
+
/* Consume tokens.css semantic tokens so these pages follow light & dark themes.
|
|
5
|
+
tokens.css is linked before platform.css, so var() resolves at runtime. */
|
|
6
|
+
--bg: var(--lt-bg, #0f1115);
|
|
7
|
+
--panel: var(--lt-surface, #16191f);
|
|
8
|
+
--panel-2: var(--lt-surface-2, #1c2027);
|
|
9
|
+
--border: var(--lt-line, rgba(255, 255, 255, 0.08));
|
|
10
|
+
--text: var(--lt-ink, #e7ecf3);
|
|
11
|
+
--muted: var(--lt-ink-soft, #64748b);
|
|
12
|
+
--accent: var(--lt-accent, #6E4AE6);
|
|
13
|
+
--accent-2: var(--lt-accent, #5ea7ec);
|
|
12
14
|
--ok: #34d399;
|
|
13
15
|
--warn: #fbbf24;
|
|
14
16
|
--err: #f87171;
|
|
15
17
|
}
|
|
16
18
|
* { box-sizing: border-box; }
|
|
19
|
+
html, body { overflow-x: hidden; }
|
|
20
|
+
body { min-width: 320px; }
|
|
17
21
|
body {
|
|
18
22
|
margin: 0;
|
|
19
23
|
background: var(--bg);
|
|
@@ -27,15 +31,15 @@ header.app {
|
|
|
27
31
|
padding: 14px 24px; border-bottom: 1px solid var(--border);
|
|
28
32
|
background: rgba(22, 25, 31, 0.8); position: sticky; top: 0; backdrop-filter: blur(8px); z-index: 5;
|
|
29
33
|
}
|
|
30
|
-
header.app .brand { font-weight: 700; font-size: 16px; color:
|
|
34
|
+
header.app .brand { font-weight: 700; font-size: 16px; color: var(--text); letter-spacing: .3px; }
|
|
31
35
|
header.app .brand small { color: var(--accent); font-weight: 600; margin-left: 6px; }
|
|
32
36
|
header.app nav { display: flex; gap: 14px; flex-wrap: wrap; }
|
|
33
|
-
header.app nav a { color: var(--muted); font-size: 13px; padding: 4px 6px; border-radius: 6px; }
|
|
34
|
-
header.app nav a:hover, header.app nav a.active { color:
|
|
37
|
+
header.app nav a { color: var(--muted); font-size: 13px; padding: 4px 6px; border-radius: 6px; min-height: 44px; display: inline-flex; align-items: center; }
|
|
38
|
+
header.app nav a:hover, header.app nav a.active { color: var(--text); background: var(--panel-2); }
|
|
35
39
|
main { max-width: 1080px; margin: 0 auto; padding: 28px 24px 80px; }
|
|
36
40
|
h1 { font-size: 22px; margin: 0 0 4px; }
|
|
37
41
|
.sub { color: var(--muted); font-size: 13px; margin: 0 0 24px; }
|
|
38
|
-
.grid { display: grid; gap: 14px; grid-template-columns: repeat(auto-fill, minmax(
|
|
42
|
+
.grid { display: grid; gap: 14px; grid-template-columns: repeat(auto-fill, minmax(min(100%, 260px), 1fr)); }
|
|
39
43
|
.card {
|
|
40
44
|
background: var(--panel); border: 1px solid var(--border); border-radius: 14px;
|
|
41
45
|
padding: 16px 18px;
|
|
@@ -54,22 +58,47 @@ h1 { font-size: 22px; margin: 0 0 4px; }
|
|
|
54
58
|
button, .btn {
|
|
55
59
|
background: var(--accent); color: #fff; border: none; border-radius: 8px;
|
|
56
60
|
padding: 7px 14px; font-size: 13px; cursor: pointer; font-weight: 600;
|
|
61
|
+
min-height: 44px;
|
|
57
62
|
}
|
|
58
63
|
button.ghost { background: var(--panel-2); color: var(--text); border: 1px solid var(--border); }
|
|
59
64
|
button:hover { filter: brightness(1.08); }
|
|
60
65
|
button:disabled { opacity: .5; cursor: not-allowed; }
|
|
61
66
|
textarea, input, select {
|
|
62
67
|
width: 100%; background: var(--panel-2); color: var(--text); border: 1px solid var(--border);
|
|
63
|
-
border-radius: 8px; padding: 9px 11px; font-size:
|
|
68
|
+
border-radius: 8px; padding: 9px 11px; font-size: 16px; font-family: inherit;
|
|
69
|
+
min-height: 44px;
|
|
64
70
|
}
|
|
65
71
|
textarea { min-height: 90px; resize: vertical; }
|
|
66
72
|
label { display: block; font-size: 12px; color: var(--muted); margin: 10px 0 4px; }
|
|
67
73
|
pre {
|
|
68
|
-
background:
|
|
69
|
-
padding: 12px; overflow: auto; font-size: 12px; color:
|
|
74
|
+
background: var(--panel-2); border: 1px solid var(--border); border-radius: 10px;
|
|
75
|
+
padding: 12px; overflow: auto; font-size: 12px; color: var(--text); max-height: 360px;
|
|
76
|
+
max-width: 100%; overflow-wrap: anywhere;
|
|
70
77
|
}
|
|
71
78
|
.empty { color: var(--muted); text-align: center; padding: 50px 0; }
|
|
72
79
|
.section { margin-top: 28px; }
|
|
73
80
|
.timeline-item { border-left: 2px solid var(--border); padding: 6px 0 6px 14px; margin-left: 6px; font-size: 13px; }
|
|
74
81
|
.timeline-item .t-meta { color: var(--muted); font-size: 11px; }
|
|
75
|
-
.toast {
|
|
82
|
+
.toast {
|
|
83
|
+
position: fixed;
|
|
84
|
+
bottom: max(20px, env(safe-area-inset-bottom));
|
|
85
|
+
left: auto;
|
|
86
|
+
right: max(12px, env(safe-area-inset-right));
|
|
87
|
+
background: var(--panel-2); border: 1px solid var(--border);
|
|
88
|
+
padding: 12px 16px; border-radius: 10px; font-size: 13px;
|
|
89
|
+
max-width: min(360px, calc(100vw - 24px));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/* 키보드 포커스 링 (tokens.css 의 :focus-visible 와 별개로 이 페이지에서도 보장) */
|
|
93
|
+
:focus-visible {
|
|
94
|
+
outline: 2px solid var(--accent, #6E4AE6);
|
|
95
|
+
outline-offset: 2px;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/* 폰: 패딩 축소 + 단일 열 + 헤더 컴팩트 */
|
|
99
|
+
@media (max-width: 600px) {
|
|
100
|
+
header.app { padding: 12px 14px; }
|
|
101
|
+
main { padding: 18px 14px 64px; }
|
|
102
|
+
.grid { grid-template-columns: 1fr; }
|
|
103
|
+
h1 { font-size: 20px; }
|
|
104
|
+
}
|
package/static/plugins.html
CHANGED
|
@@ -2,9 +2,12 @@
|
|
|
2
2
|
<html lang="en">
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
|
-
<meta name="viewport" content="width=device-width, initial-scale=1
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content" />
|
|
6
6
|
<title>Plugin SDK — Lattice AI</title>
|
|
7
|
-
<
|
|
7
|
+
<script src="/static/scripts/ux.js?v=2.2.1"></script>
|
|
8
|
+
<link rel="stylesheet" href="/static/css/tokens.css?v=2.2.1" />
|
|
9
|
+
<link rel="stylesheet" href="/static/platform.css?v=2.2.1" />
|
|
10
|
+
<link rel="stylesheet" href="/static/css/responsive.css?v=2.2.1" />
|
|
8
11
|
</head>
|
|
9
12
|
<body>
|
|
10
13
|
<main>
|
package/static/scripts/admin.js
CHANGED
|
@@ -587,12 +587,12 @@ function renderUsers(users) {
|
|
|
587
587
|
<tbody>
|
|
588
588
|
${latestUsers.map(user => `
|
|
589
589
|
<tr>
|
|
590
|
-
<td>${esc(user.email)}</td>
|
|
591
|
-
<td>${esc(user.name || '-')}</td>
|
|
592
|
-
<td>${esc(user.nickname || '-')}</td>
|
|
593
|
-
<td><span class="role">${esc(roleLabel(user.role))}</span></td>
|
|
594
|
-
<td>${permissionTag(statusLabel(user), user.disabled ? 'medium' : 'low')}</td>
|
|
595
|
-
<td>
|
|
590
|
+
<td data-label="${t('label_email')}">${esc(user.email)}</td>
|
|
591
|
+
<td data-label="${t('label_name')}">${esc(user.name || '-')}</td>
|
|
592
|
+
<td data-label="${t('label_nickname')}">${esc(user.nickname || '-')}</td>
|
|
593
|
+
<td data-label="${t('label_perm')}"><span class="role">${esc(roleLabel(user.role))}</span></td>
|
|
594
|
+
<td data-label="${t('label_status')}">${permissionTag(statusLabel(user), user.disabled ? 'medium' : 'low')}</td>
|
|
595
|
+
<td data-label="${t('label_actions')}">
|
|
596
596
|
<div class="actions">
|
|
597
597
|
<button class="table-btn" data-action="role" data-email="${esc(user.email)}" data-next-role="${user.role === 'admin' ? 'user' : 'admin'}">
|
|
598
598
|
${user.role === 'admin' ? t('btn_revoke_admin') : t('btn_grant_admin')}
|
|
@@ -635,15 +635,15 @@ function renderPermissions(users) {
|
|
|
635
635
|
const isAdmin = user.role === 'admin';
|
|
636
636
|
return `
|
|
637
637
|
<tr>
|
|
638
|
-
<td>
|
|
638
|
+
<td data-label="${t('label_user')}">
|
|
639
639
|
<strong>${esc(user.nickname || user.name || user.email)}</strong>
|
|
640
640
|
<div class="preview">${esc(user.email)}</div>
|
|
641
641
|
</td>
|
|
642
|
-
<td>${permissionTag(statusLabel(user), active ? 'low' : 'medium')}</td>
|
|
643
|
-
<td>${permissionTag(active ? t('permission_allowed') : t('permission_blocked'), active ? 'low' : 'medium')}</td>
|
|
644
|
-
<td>${permissionTag(active ? t('permission_allowed') : t('permission_blocked'), active ? 'low' : 'medium')}</td>
|
|
645
|
-
<td>${permissionTag(isAdmin && active ? t('permission_granted') : t('permission_not_granted'), isAdmin && active ? 'low' : 'medium')}</td>
|
|
646
|
-
<td>
|
|
642
|
+
<td data-label="${t('label_status')}">${permissionTag(statusLabel(user), active ? 'low' : 'medium')}</td>
|
|
643
|
+
<td data-label="${t('permission_default')}">${permissionTag(active ? t('permission_allowed') : t('permission_blocked'), active ? 'low' : 'medium')}</td>
|
|
644
|
+
<td data-label="${t('permission_advanced')}">${permissionTag(active ? t('permission_allowed') : t('permission_blocked'), active ? 'low' : 'medium')}</td>
|
|
645
|
+
<td data-label="${t('permission_admin')}">${permissionTag(isAdmin && active ? t('permission_granted') : t('permission_not_granted'), isAdmin && active ? 'low' : 'medium')}</td>
|
|
646
|
+
<td data-label="${t('label_actions')}">
|
|
647
647
|
<div class="actions">
|
|
648
648
|
<button class="table-btn" data-action="role" data-email="${esc(user.email)}" data-next-role="${isAdmin ? 'user' : 'admin'}">
|
|
649
649
|
${isAdmin ? t('btn_revoke_admin') : t('btn_grant_admin')}
|
|
@@ -773,12 +773,12 @@ function renderAudit(audit) {
|
|
|
773
773
|
<tbody>
|
|
774
774
|
${users.map(user => `
|
|
775
775
|
<tr>
|
|
776
|
-
<td><strong>${esc(user.nickname || user.email || 'Unknown')}</strong><div class="preview">${esc(user.email || '')}</div></td>
|
|
777
|
-
<td>${esc(user.user_messages || 0)} / ${esc(user.assistant_messages || 0)}</td>
|
|
778
|
-
<td>${esc(user.document_uploads || 0)}</td>
|
|
779
|
-
<td>${permissionTag(user.sensitive_events || 0, (user.high_sensitive_events || 0) ? 'high' : ((user.sensitive_events || 0) ? 'medium' : 'low'))}</td>
|
|
780
|
-
<td>${esc(user.clear_events || 0)} / ${esc(user.delete_events || 0)}</td>
|
|
781
|
-
<td>${esc(formatTime(user.last_activity_at))}</td>
|
|
776
|
+
<td data-label="${t('label_user')}"><strong>${esc(user.nickname || user.email || 'Unknown')}</strong><div class="preview">${esc(user.email || '')}</div></td>
|
|
777
|
+
<td data-label="AI Use">${esc(user.user_messages || 0)} / ${esc(user.assistant_messages || 0)}</td>
|
|
778
|
+
<td data-label="Uploads">${esc(user.document_uploads || 0)}</td>
|
|
779
|
+
<td data-label="Sensitive">${permissionTag(user.sensitive_events || 0, (user.high_sensitive_events || 0) ? 'high' : ((user.sensitive_events || 0) ? 'medium' : 'low'))}</td>
|
|
780
|
+
<td data-label="Clear/Delete">${esc(user.clear_events || 0)} / ${esc(user.delete_events || 0)}</td>
|
|
781
|
+
<td data-label="Last Active">${esc(formatTime(user.last_activity_at))}</td>
|
|
782
782
|
</tr>
|
|
783
783
|
`).join('')}
|
|
784
784
|
</tbody>
|
|
@@ -800,11 +800,11 @@ function renderAudit(audit) {
|
|
|
800
800
|
<tbody>
|
|
801
801
|
${events.map(event => `
|
|
802
802
|
<tr>
|
|
803
|
-
<td>${esc(formatTime(event.timestamp))}</td>
|
|
804
|
-
<td>${esc(auditEventLabel(event))}</td>
|
|
805
|
-
<td>${esc(event.user_nickname || event.user_email || 'Unknown')}</td>
|
|
806
|
-
<td>${esc(auditTarget(event))}</td>
|
|
807
|
-
<td>${permissionTag(event.sensitivity || 'none', event.sensitivity === 'high' ? 'high' : (event.sensitivity && event.sensitivity !== 'none' ? 'medium' : 'low'))}</td>
|
|
803
|
+
<td data-label="Time">${esc(formatTime(event.timestamp))}</td>
|
|
804
|
+
<td data-label="Event">${esc(auditEventLabel(event))}</td>
|
|
805
|
+
<td data-label="${t('label_user')}">${esc(event.user_nickname || event.user_email || 'Unknown')}</td>
|
|
806
|
+
<td data-label="Target/Data">${esc(auditTarget(event))}</td>
|
|
807
|
+
<td data-label="Risk">${permissionTag(event.sensitivity || 'none', event.sensitivity === 'high' ? 'high' : (event.sensitivity && event.sensitivity !== 'none' ? 'medium' : 'low'))}</td>
|
|
808
808
|
</tr>
|
|
809
809
|
`).join('')}
|
|
810
810
|
</tbody>
|
|
@@ -1377,16 +1377,16 @@ function renderCcUsersTable(users) {
|
|
|
1377
1377
|
}
|
|
1378
1378
|
const rows = users.slice(0, 25).map(u => `
|
|
1379
1379
|
<tr data-cc-user="${ccEscape(u.email)}" style="cursor:pointer">
|
|
1380
|
-
<td>${ccEscape(u.user)}</td>
|
|
1381
|
-
<td>${ccEscape(u.total_chats)}</td>
|
|
1382
|
-
<td style="color:#2c8a3f">${ccEscape(u.compliant_chats)}</td>
|
|
1383
|
-
<td style="color:#b13030">${ccEscape(u.risky_chats)}</td>
|
|
1384
|
-
<td>${ccEscape(u.uploaded_files)}</td>
|
|
1385
|
-
<td style="color:#2c8a3f">${ccEscape(u.compliant_files)}</td>
|
|
1386
|
-
<td style="color:#b13030">${ccEscape(u.risky_files)}</td>
|
|
1387
|
-
<td>${ccEscape(u.high_risk_events)}</td>
|
|
1388
|
-
<td>${ccEscape(u.risk_rate)}%</td>
|
|
1389
|
-
<td>${ccEscape((u.last_activity_at || '').slice(0, 19).replace('T', ' '))}</td>
|
|
1380
|
+
<td data-label="사용자">${ccEscape(u.user)}</td>
|
|
1381
|
+
<td data-label="총 채팅">${ccEscape(u.total_chats)}</td>
|
|
1382
|
+
<td data-label="준수 채팅" style="color:#2c8a3f">${ccEscape(u.compliant_chats)}</td>
|
|
1383
|
+
<td data-label="위험 채팅" style="color:#b13030">${ccEscape(u.risky_chats)}</td>
|
|
1384
|
+
<td data-label="총 파일">${ccEscape(u.uploaded_files)}</td>
|
|
1385
|
+
<td data-label="준수 파일" style="color:#2c8a3f">${ccEscape(u.compliant_files)}</td>
|
|
1386
|
+
<td data-label="위험 파일" style="color:#b13030">${ccEscape(u.risky_files)}</td>
|
|
1387
|
+
<td data-label="High">${ccEscape(u.high_risk_events)}</td>
|
|
1388
|
+
<td data-label="위험률">${ccEscape(u.risk_rate)}%</td>
|
|
1389
|
+
<td data-label="마지막 활동">${ccEscape((u.last_activity_at || '').slice(0, 19).replace('T', ' '))}</td>
|
|
1390
1390
|
</tr>
|
|
1391
1391
|
`).join('');
|
|
1392
1392
|
wrap.innerHTML = `
|
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 {
|
|
@@ -1058,15 +1055,22 @@ const chatViewport = document.getElementById('chat-viewport');
|
|
|
1058
1055
|
<div style="font-weight:700">⭐ Best for this PC — ${escapeHtml(top.name || top.id)} ${badge('recommended')}</div>
|
|
1059
1056
|
<div style="font-size:12px;opacity:0.8;margin-top:3px">${escapeHtml(top.reason || '')}</div>
|
|
1060
1057
|
<div style="font-size:12px;margin-top:4px">${escapeHtml(top.size || '')} · ${escapeHtml(ram(top))} · ${escapeHtml(nextStep(rec.engine))}</div>
|
|
1058
|
+
${modelSourceLine(top) ? `<div style="font-size:11px;opacity:0.7;margin-top:3px">${escapeHtml(modelSourceLine(top))}</div>` : ''}
|
|
1061
1059
|
</div>` : '';
|
|
1062
1060
|
|
|
1063
1061
|
const rows = families.map((fam) => {
|
|
1064
1062
|
const best = fam.best;
|
|
1065
|
-
const items = (fam.models || []).map((m) =>
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1063
|
+
const items = (fam.models || []).map((m) => {
|
|
1064
|
+
const src = modelSourceLine(m);
|
|
1065
|
+
return `
|
|
1066
|
+
<div style="padding:4px 0;font-size:12px;opacity:${m.status === 'not_recommended' ? 0.55 : 1}">
|
|
1067
|
+
<div style="display:flex;justify-content:space-between;gap:8px">
|
|
1068
|
+
<span>${escapeHtml(m.name || m.id)}</span>
|
|
1069
|
+
<span style="white-space:nowrap">${escapeHtml(m.size || '')} · ${escapeHtml(ram(m))} ${badge(m.status)}</span>
|
|
1070
|
+
</div>
|
|
1071
|
+
${src ? `<div style="font-size:11px;opacity:0.65;margin-top:2px">${escapeHtml(src)}</div>` : ''}
|
|
1072
|
+
</div>`;
|
|
1073
|
+
}).join('');
|
|
1070
1074
|
return `
|
|
1071
1075
|
<details style="margin:6px 0;border:1px solid var(--border,#e5e7eb);border-radius:8px;padding:8px 10px">
|
|
1072
1076
|
<summary style="cursor:pointer;font-weight:600">${escapeHtml(fam.family)} ${best ? badge(best.status) : ''}${best ? ` <span style="font-weight:400;opacity:0.7">${escapeHtml(best.name || '')}</span>` : ''}</summary>
|
|
@@ -1351,7 +1355,7 @@ const chatViewport = document.getElementById('chat-viewport');
|
|
|
1351
1355
|
<button class="onboarding-mode" onclick="finishOnboarding('advanced')">
|
|
1352
1356
|
<i class="ti ti-terminal-2"></i>
|
|
1353
1357
|
<h3>고급 모드</h3>
|
|
1354
|
-
<p
|
|
1358
|
+
<p>같은 기능을 유지하면서 모델, 메모리, 실행 방식 설명을 더 자세히 표시합니다.</p>
|
|
1355
1359
|
</button>
|
|
1356
1360
|
${adminCard}
|
|
1357
1361
|
</div>
|
|
@@ -1528,7 +1532,7 @@ const chatViewport = document.getElementById('chat-viewport');
|
|
|
1528
1532
|
const isUnavailable = unsupported || (!isLocalEngine && engineMissing) || keyMissing || verifyFailed;
|
|
1529
1533
|
const badge = unsupported ? '현재 환경 미지원'
|
|
1530
1534
|
: engineMissing && isLocalEngine ? '설치 후 자동 로드'
|
|
1531
|
-
: engineMissing ? '
|
|
1535
|
+
: engineMissing ? '실행 도구 설치 필요'
|
|
1532
1536
|
: needsPull ? '다운로드 후 자동 로드'
|
|
1533
1537
|
: keyMissing ? `필요: ${model.requires || 'API key'}`
|
|
1534
1538
|
: verifyFailed ? `실패: ${model.verify_reason || '검증 실패'}`
|
|
@@ -1539,29 +1543,67 @@ const chatViewport = document.getElementById('chat-viewport');
|
|
|
1539
1543
|
const action = isLocalEngine
|
|
1540
1544
|
? `selectModelByCard('${encodeURIComponent(model.id)}', '${engine?.id || ''}')`
|
|
1541
1545
|
: `loadSelectedModel('${encodeURIComponent(model.id)}', '${engine?.id || ''}')`;
|
|
1546
|
+
const chipsHtml = modelSourceChipsHtml(model);
|
|
1547
|
+
const detailLine = chipsHtml ? `${model.id} · ${badge}` : `${model.id} · ${badge}`;
|
|
1542
1548
|
return `
|
|
1543
1549
|
<button class="model-option${cls}" ${isUnavailable ? 'disabled' : ''} onclick="${action}">
|
|
1544
1550
|
<div>
|
|
1545
1551
|
<strong>${escapeHtml(model.name || compactModelName(model.id))}</strong>
|
|
1546
|
-
|
|
1552
|
+
${chipsHtml}
|
|
1553
|
+
<span>${escapeHtml(detailLine)}</span>
|
|
1547
1554
|
</div>
|
|
1548
1555
|
<i class="ti ${icon}"></i>
|
|
1549
1556
|
</button>
|
|
1550
1557
|
`;
|
|
1551
1558
|
}
|
|
1552
1559
|
|
|
1560
|
+
// 모델 출처 정보를 비전문가도 읽기 쉬운 라벨 칩으로 렌더링한다.
|
|
1561
|
+
// 필드: source_country / source_company / execution_method / internet_requirement / model_name
|
|
1562
|
+
// 누락된 필드는 자동으로 생략(graceful degrade)한다.
|
|
1563
|
+
function modelSourceChips(model) {
|
|
1564
|
+
if (!model) return [];
|
|
1565
|
+
return [
|
|
1566
|
+
['국가', model.source_country],
|
|
1567
|
+
['회사', model.source_company],
|
|
1568
|
+
['실행', model.execution_method],
|
|
1569
|
+
['인터넷', model.internet_requirement],
|
|
1570
|
+
['모델명', model.model_name || model.name],
|
|
1571
|
+
].filter(([, value]) => value != null && String(value).trim() !== '');
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
function modelSourceChipsHtml(model) {
|
|
1575
|
+
const chips = modelSourceChips(model);
|
|
1576
|
+
if (!chips.length) return '';
|
|
1577
|
+
const chipStyle = 'display:inline-flex;align-items:center;gap:3px;padding:2px 8px;'
|
|
1578
|
+
+ 'border:1px solid var(--border,#e5e7eb);border-radius:999px;'
|
|
1579
|
+
+ 'background:var(--surface-2,#f3f4f6);color:var(--text,#111);'
|
|
1580
|
+
+ 'font-size:11px;line-height:1.4;white-space:nowrap;';
|
|
1581
|
+
const inner = chips.map(([label, value]) =>
|
|
1582
|
+
`<span class="model-source-chip" style="${chipStyle}"><b style="font-weight:700;opacity:0.7">${escapeHtml(label)}:</b> ${escapeHtml(String(value))}</span>`
|
|
1583
|
+
).join('');
|
|
1584
|
+
return `<span class="model-source-chips" style="display:flex;flex-wrap:wrap;gap:4px;margin:4px 0 2px">${inner}</span>`;
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
// 한 줄짜리 평문 출처(국가 · 회사 · 실행 · 인터넷) — 좁은 행/요약용.
|
|
1588
|
+
function modelSourceLine(model) {
|
|
1589
|
+
if (!model) return '';
|
|
1590
|
+
return [
|
|
1591
|
+
model.source_country,
|
|
1592
|
+
model.source_company,
|
|
1593
|
+
model.execution_method,
|
|
1594
|
+
model.internet_requirement,
|
|
1595
|
+
].filter(value => value != null && String(value).trim() !== '').join(' · ');
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1553
1598
|
function normalizedFamily(model) {
|
|
1554
1599
|
const raw = `${model?.family || ''} ${model?.name || ''} ${model?.id || ''}`.toLowerCase();
|
|
1555
1600
|
if (raw.includes('gpt')) return 'GPT';
|
|
1556
1601
|
if (raw.includes('claude')) return 'Claude';
|
|
1557
1602
|
if (raw.includes('grok')) return 'Grok';
|
|
1558
1603
|
if (raw.includes('gemini')) return 'Gemini';
|
|
1559
|
-
if (raw.includes('mistral') || raw.includes('mixtral')) return 'Mistral';
|
|
1560
1604
|
if (raw.includes('qwen')) return 'Qwen';
|
|
1561
1605
|
if (raw.includes('llama')) return 'Llama';
|
|
1562
1606
|
if (raw.includes('gemma')) return 'Gemma';
|
|
1563
|
-
if (raw.includes('phi')) return 'Phi';
|
|
1564
|
-
if (raw.includes('deepseek')) return 'DeepSeek';
|
|
1565
1607
|
return (model?.family || '기타');
|
|
1566
1608
|
}
|
|
1567
1609
|
|
|
@@ -1642,17 +1684,17 @@ const chatViewport = document.getElementById('chat-viewport');
|
|
|
1642
1684
|
const cloudEngines = cachedEngineList.filter(engine => engine.kind === 'cloud');
|
|
1643
1685
|
const isLocal = modelPanelFilter === 'local';
|
|
1644
1686
|
const target = isLocal ? localEngines : cloudEngines;
|
|
1645
|
-
const emptyText = isLocal ? '
|
|
1687
|
+
const emptyText = isLocal ? '내 컴퓨터에서 실행할 수 있는 항목이 없습니다.' : '인터넷 연결 후 사용할 수 있는 항목이 없습니다.';
|
|
1646
1688
|
|
|
1647
1689
|
modelList.innerHTML = `
|
|
1648
|
-
<div class="model-group-title"
|
|
1690
|
+
<div class="model-group-title">실행 방식</div>
|
|
1649
1691
|
<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')"
|
|
1692
|
+
<button class="model-filter-btn ${isLocal ? 'active' : ''}" onclick="setModelPanelFilter('local')">내 컴퓨터에서만 실행</button>
|
|
1693
|
+
<button class="model-filter-btn ${!isLocal ? 'active' : ''}" onclick="setModelPanelFilter('cloud')">인터넷 연결 후 사용</button>
|
|
1652
1694
|
</div>
|
|
1653
1695
|
${!isLocal ? `
|
|
1654
1696
|
<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>
|
|
1697
|
+
<button class="admin-action" onclick="verifyCloudModels(true)"><i class="ti ti-activity"></i> 인터넷 모델 실사용 테스트</button>
|
|
1656
1698
|
</div>
|
|
1657
1699
|
` : ''}
|
|
1658
1700
|
${target.length ? target.map(engineCardHtml).join('') : `<div class="sensitivity-preview">${emptyText}</div>`}
|
|
@@ -1680,7 +1722,7 @@ const chatViewport = document.getElementById('chat-viewport');
|
|
|
1680
1722
|
|
|
1681
1723
|
async function verifyCloudModels(force = true) {
|
|
1682
1724
|
const modelList = document.getElementById('model-list');
|
|
1683
|
-
modelList.innerHTML = `<div class="sensitivity-preview"
|
|
1725
|
+
modelList.innerHTML = `<div class="sensitivity-preview">인터넷 모델 실사용 테스트 중입니다... (연결 방식별로 수 초~수십 초)</div>`;
|
|
1684
1726
|
try {
|
|
1685
1727
|
const res = await apiFetch('/engines/verify-cloud', {
|
|
1686
1728
|
method: 'POST',
|
|
@@ -1688,9 +1730,9 @@ const chatViewport = document.getElementById('chat-viewport');
|
|
|
1688
1730
|
body: JSON.stringify({ force })
|
|
1689
1731
|
});
|
|
1690
1732
|
const data = await res.json();
|
|
1691
|
-
if (!res.ok) throw new Error(data.detail || '
|
|
1733
|
+
if (!res.ok) throw new Error(data.detail || '인터넷 모델 실사용 테스트 실패');
|
|
1692
1734
|
await openModelPanel();
|
|
1693
|
-
addMessage('ai',
|
|
1735
|
+
addMessage('ai', `인터넷 모델 실사용 테스트를 완료했습니다. 실패한 모델은 잠금 상태로 표시됩니다.`);
|
|
1694
1736
|
} catch (e) {
|
|
1695
1737
|
modelList.innerHTML = `
|
|
1696
1738
|
<div class="sensitivity-preview">${escapeHtml(e.message)}</div>
|
|
@@ -1832,7 +1874,7 @@ const chatViewport = document.getElementById('chat-viewport');
|
|
|
1832
1874
|
</div>
|
|
1833
1875
|
</div>
|
|
1834
1876
|
<div id="model-download-detail" class="model-download-detail">
|
|
1835
|
-
|
|
1877
|
+
실행 도구 설치, 모델 다운로드, 연결 준비, 로드까지 자동으로 진행합니다. 첫 실행은 수 분이 걸릴 수 있습니다.
|
|
1836
1878
|
</div>
|
|
1837
1879
|
</div>
|
|
1838
1880
|
`;
|
|
@@ -3566,10 +3608,18 @@ const chatViewport = document.getElementById('chat-viewport');
|
|
|
3566
3608
|
|
|
3567
3609
|
function attachDocument(input) {
|
|
3568
3610
|
const file = input.files[0];
|
|
3611
|
+
if (!file) return;
|
|
3612
|
+
attachDocumentFile(file);
|
|
3613
|
+
input.value = '';
|
|
3614
|
+
}
|
|
3615
|
+
|
|
3616
|
+
// 파일을 직접 첨부 (드래그앤드롭 / 붙여넣기 / 파일 선택 공용 경로)
|
|
3617
|
+
function attachDocumentFile(file) {
|
|
3569
3618
|
if (!file) return;
|
|
3570
3619
|
attachedDocFile = file;
|
|
3571
3620
|
attachedDocContent = null;
|
|
3572
3621
|
const row = document.getElementById('attach-preview-row');
|
|
3622
|
+
if (!row) return;
|
|
3573
3623
|
row.style.display = 'flex';
|
|
3574
3624
|
row.innerHTML = `
|
|
3575
3625
|
<div class="attach-chip">
|
|
@@ -3578,9 +3628,30 @@ const chatViewport = document.getElementById('chat-viewport');
|
|
|
3578
3628
|
<button onclick="removeAttachedDoc()" title="제거">×</button>
|
|
3579
3629
|
</div>
|
|
3580
3630
|
<span style="font-size:11px;color:var(--muted);align-self:center">첨부됨 — 전송 시 AI가 파일을 읽습니다</span>`;
|
|
3581
|
-
input.value = '';
|
|
3582
3631
|
}
|
|
3583
3632
|
|
|
3633
|
+
// 채팅 영역에 파일을 끌어다 놓으면 첨부 (Drag & Drop)
|
|
3634
|
+
(function setupChatDropZone() {
|
|
3635
|
+
const zone = document.querySelector('.main-chat') || document.body;
|
|
3636
|
+
if (!zone) return;
|
|
3637
|
+
const hasFiles = (e) => e.dataTransfer && Array.prototype.indexOf.call(e.dataTransfer.types || [], 'Files') !== -1;
|
|
3638
|
+
['dragenter', 'dragover'].forEach(ev => zone.addEventListener(ev, (e) => {
|
|
3639
|
+
if (!hasFiles(e)) return;
|
|
3640
|
+
e.preventDefault();
|
|
3641
|
+
zone.classList.add('drag-over');
|
|
3642
|
+
}));
|
|
3643
|
+
zone.addEventListener('dragleave', (e) => {
|
|
3644
|
+
if (e.target === zone) zone.classList.remove('drag-over');
|
|
3645
|
+
});
|
|
3646
|
+
zone.addEventListener('drop', (e) => {
|
|
3647
|
+
zone.classList.remove('drag-over');
|
|
3648
|
+
if (!hasFiles(e)) return;
|
|
3649
|
+
e.preventDefault();
|
|
3650
|
+
const file = e.dataTransfer.files && e.dataTransfer.files[0];
|
|
3651
|
+
if (file) attachDocumentFile(file);
|
|
3652
|
+
});
|
|
3653
|
+
})();
|
|
3654
|
+
|
|
3584
3655
|
function removeAttachedDoc() {
|
|
3585
3656
|
attachedDocFile = null;
|
|
3586
3657
|
attachedDocContent = null;
|
|
@@ -4363,7 +4434,7 @@ const chatViewport = document.getElementById('chat-viewport');
|
|
|
4363
4434
|
const keys = env.api_keys || {};
|
|
4364
4435
|
|
|
4365
4436
|
const mlxLabel = mlx.available
|
|
4366
|
-
? (mlx.
|
|
4437
|
+
? (mlx.mlx_vlm ? 'MLX-VLM 설치됨' : 'MLX 설치됨 · MLX-VLM 필요')
|
|
4367
4438
|
: '미설치';
|
|
4368
4439
|
|
|
4369
4440
|
const cloudKeys = Object.entries(keys).filter(([,v]) => v).map(([k]) => k.toUpperCase());
|
|
@@ -4378,7 +4449,7 @@ const chatViewport = document.getElementById('chat-viewport');
|
|
|
4378
4449
|
{ icon: mlx.available ? '✅' : '⚠️', label: 'MLX', value: mlxLabel, ok: mlx.available },
|
|
4379
4450
|
{ icon: tools.ollama ? '✅' : '○', label: 'Ollama', value: tools.ollama ? '설치됨' : '미설치', ok: true },
|
|
4380
4451
|
{ icon: tools.brew ? '✅' : '○', label: 'Homebrew', value: tools.brew ? '설치됨' : '미설치', ok: true },
|
|
4381
|
-
{ icon: cloudKeys.length ? '✅' : '○', label: '
|
|
4452
|
+
{ icon: cloudKeys.length ? '✅' : '○', label: '인터넷 AI',
|
|
4382
4453
|
value: cloudKeys.length ? cloudKeys.join(', ') : '없음', ok: true },
|
|
4383
4454
|
{ icon: env.os === 'Darwin' ? '🍎' : '🐧',
|
|
4384
4455
|
label: '운영체제',
|
|
@@ -4607,10 +4678,6 @@ const chatViewport = document.getElementById('chat-viewport');
|
|
|
4607
4678
|
let _mcpCurrentTab = 'registry';
|
|
4608
4679
|
|
|
4609
4680
|
async function openMcpModal() {
|
|
4610
|
-
if (getCurrentMode() === 'default') {
|
|
4611
|
-
showToast('고급 모드에서 사용할 수 있습니다.');
|
|
4612
|
-
return;
|
|
4613
|
-
}
|
|
4614
4681
|
document.getElementById('mcp-modal-overlay').classList.add('open');
|
|
4615
4682
|
await renderMcpModal(_mcpCurrentTab);
|
|
4616
4683
|
}
|