ltcai 0.1.3 → 0.1.4
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 +29 -0
- package/package.json +1 -1
- package/requirements.txt +1 -0
- package/server.py +154 -12
- package/static/account.html +60 -0
- package/static/chat.html +248 -13
package/README.md
CHANGED
|
@@ -10,6 +10,35 @@ LTCAI # → http://localhost:4825
|
|
|
10
10
|
|
|
11
11
|
---
|
|
12
12
|
|
|
13
|
+
## v0.1.4 변경사항
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
- 세션 영속성 — 서버 재시작 후에도 로그인 유지
|
|
17
|
+
- SSO 로그인 — Entra ID / Okta OIDC 지원 (`OIDC_DISCOVERY_URL`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`)
|
|
18
|
+
- 채팅 히스토리 검색 — 사이드바 검색창으로 대화 내용 키워드 검색
|
|
19
|
+
- 대화 삭제 — 사이드바 각 대화에 삭제 버튼
|
|
20
|
+
- MCP 서버 관리 UI — 사이드바 "MCP 관리" 버튼으로 설치/목록 모달
|
|
21
|
+
- VS Code 인라인 Diff 뷰 — Edit Selection 결과를 diff로 먼저 확인 후 Apply/Discard
|
|
22
|
+
- VS Code 현재 파일 첨부 — `Lattice AI: Attach Current File to Chat` 명령 추가
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## v0.1.3 변경사항
|
|
27
|
+
|
|
28
|
+
### Added
|
|
29
|
+
- 프로필 수정 (`PATCH /account/profile`) — 이름·닉네임 변경
|
|
30
|
+
- 회원가입 폼 — 비밀번호 확인 필드, 인라인 에러 메시지
|
|
31
|
+
- 어드민 패널 초대 링크 섹션 — 원클릭 복사
|
|
32
|
+
- 어드민 대시보드 메시지 활동 차트 (Chart.js, 최근 14일)
|
|
33
|
+
- 웹 UI 한국어 / 영어 전환 (`🌐 Languages` 버튼, localStorage 저장)
|
|
34
|
+
|
|
35
|
+
### Fixed
|
|
36
|
+
- 로그아웃 시 `/logout` API 호출로 서버 세션 쿠키 정상 만료
|
|
37
|
+
- 인증(`account.html`)과 채팅(`chat.html`) UI 분리 — 레거시 파일 제거
|
|
38
|
+
- 채팅 헤더에서 언어 선택 드롭다운이 ops-strip을 가리는 문제 수정
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
13
42
|
## 아키텍처
|
|
14
43
|
|
|
15
44
|
```
|
package/package.json
CHANGED
package/requirements.txt
CHANGED
package/server.py
CHANGED
|
@@ -175,6 +175,31 @@ PUBLIC_MODEL = env_value("LATTICEAI_PUBLIC_MODEL", env_value("LATTICEAI_DEFAULT_
|
|
|
175
175
|
LOCAL_MODEL = env_value("LATTICEAI_LOCAL_MODEL", "mlx-community/gemma-4-26b-a4b-it-4bit")
|
|
176
176
|
LOCAL_DRAFT_MODEL = env_value("LATTICEAI_LOCAL_DRAFT_MODEL", "")
|
|
177
177
|
|
|
178
|
+
# ── SSO / OIDC config ─────────────────────────────────────────────────────────
|
|
179
|
+
SSO_DISCOVERY_URL = env_value("OIDC_DISCOVERY_URL", "")
|
|
180
|
+
SSO_CLIENT_ID = env_value("OIDC_CLIENT_ID", "")
|
|
181
|
+
SSO_CLIENT_SECRET = env_value("OIDC_CLIENT_SECRET", "")
|
|
182
|
+
SSO_REDIRECT_URI = env_value("OIDC_REDIRECT_URI", "http://localhost:4825/auth/sso/callback")
|
|
183
|
+
SSO_PROVIDER_NAME = env_value("OIDC_PROVIDER_NAME", "SSO")
|
|
184
|
+
_sso_discovery_cache: Optional[Dict] = None
|
|
185
|
+
_sso_states: Dict[str, float] = {} # state → timestamp (CSRF protection)
|
|
186
|
+
|
|
187
|
+
async def _get_sso_discovery() -> Optional[Dict]:
|
|
188
|
+
global _sso_discovery_cache
|
|
189
|
+
if _sso_discovery_cache:
|
|
190
|
+
return _sso_discovery_cache
|
|
191
|
+
if not SSO_DISCOVERY_URL:
|
|
192
|
+
return None
|
|
193
|
+
try:
|
|
194
|
+
import httpx as _httpx
|
|
195
|
+
async with _httpx.AsyncClient() as c:
|
|
196
|
+
r = await c.get(SSO_DISCOVERY_URL, timeout=10)
|
|
197
|
+
_sso_discovery_cache = r.json()
|
|
198
|
+
except Exception as e:
|
|
199
|
+
logging.warning("SSO discovery failed: %s", e)
|
|
200
|
+
return None
|
|
201
|
+
return _sso_discovery_cache
|
|
202
|
+
|
|
178
203
|
# ── Password hashing (stdlib scrypt, no extra deps) ────────────────────────────
|
|
179
204
|
def hash_password(password: str) -> str:
|
|
180
205
|
salt = secrets.token_hex(16)
|
|
@@ -199,27 +224,54 @@ def verify_and_migrate_password(email: str, plain: str, stored: str, users: Dict
|
|
|
199
224
|
return True
|
|
200
225
|
return False
|
|
201
226
|
|
|
202
|
-
# ── Session store (
|
|
227
|
+
# ── Session store (file-backed, survives restarts) ────────────────────────────
|
|
203
228
|
_SESSION_TTL = 60 * 60 * 24 * 7 # 7 days
|
|
204
|
-
|
|
229
|
+
_sessions_lock = threading.Lock()
|
|
230
|
+
|
|
231
|
+
def _sessions_file() -> Path:
|
|
232
|
+
return DATA_DIR / "sessions.json"
|
|
233
|
+
|
|
234
|
+
def _load_sessions() -> Dict[str, tuple]:
|
|
235
|
+
try:
|
|
236
|
+
f = _sessions_file()
|
|
237
|
+
if f.exists():
|
|
238
|
+
raw = json.loads(f.read_text())
|
|
239
|
+
return {k: tuple(v) for k, v in raw.items()}
|
|
240
|
+
except Exception:
|
|
241
|
+
pass
|
|
242
|
+
return {}
|
|
243
|
+
|
|
244
|
+
def _persist_sessions(sessions: Dict[str, tuple]) -> None:
|
|
245
|
+
try:
|
|
246
|
+
_sessions_file().write_text(json.dumps({k: list(v) for k, v in sessions.items()}, ensure_ascii=False))
|
|
247
|
+
except Exception:
|
|
248
|
+
pass
|
|
249
|
+
|
|
250
|
+
_sessions: Dict[str, tuple] = _load_sessions()
|
|
205
251
|
|
|
206
252
|
def create_session(email: str) -> str:
|
|
207
253
|
token = secrets.token_urlsafe(32)
|
|
208
|
-
|
|
254
|
+
with _sessions_lock:
|
|
255
|
+
_sessions[token] = (email, time.time())
|
|
256
|
+
_persist_sessions(_sessions)
|
|
209
257
|
return token
|
|
210
258
|
|
|
211
259
|
def get_session_email(token: str) -> Optional[str]:
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
260
|
+
with _sessions_lock:
|
|
261
|
+
entry = _sessions.get(token)
|
|
262
|
+
if entry is None:
|
|
263
|
+
return None
|
|
264
|
+
email, created_at = entry
|
|
265
|
+
if time.time() - created_at > _SESSION_TTL:
|
|
266
|
+
_sessions.pop(token, None)
|
|
267
|
+
_persist_sessions(_sessions)
|
|
268
|
+
return None
|
|
269
|
+
return email
|
|
220
270
|
|
|
221
271
|
def invalidate_session(token: str) -> None:
|
|
222
|
-
|
|
272
|
+
with _sessions_lock:
|
|
273
|
+
_sessions.pop(token, None)
|
|
274
|
+
_persist_sessions(_sessions)
|
|
223
275
|
|
|
224
276
|
# ── User Management Logic ──────────────────────────────────────────────────
|
|
225
277
|
BASE_DIR = Path(__file__).resolve().parent
|
|
@@ -1232,6 +1284,79 @@ async def login(req: UserLogin):
|
|
|
1232
1284
|
response.set_cookie(key="session_token", value=token, httponly=True, samesite="lax", max_age=60 * 60 * 24 * 7)
|
|
1233
1285
|
return response
|
|
1234
1286
|
|
|
1287
|
+
@app.get("/auth/sso/config")
|
|
1288
|
+
async def sso_config():
|
|
1289
|
+
enabled = bool(SSO_DISCOVERY_URL and SSO_CLIENT_ID and SSO_CLIENT_SECRET)
|
|
1290
|
+
return {"enabled": enabled, "provider_name": SSO_PROVIDER_NAME if enabled else ""}
|
|
1291
|
+
|
|
1292
|
+
@app.get("/auth/sso/login")
|
|
1293
|
+
async def sso_login():
|
|
1294
|
+
from urllib.parse import urlencode
|
|
1295
|
+
from fastapi.responses import RedirectResponse as _Redirect
|
|
1296
|
+
discovery = await _get_sso_discovery()
|
|
1297
|
+
if not discovery:
|
|
1298
|
+
raise HTTPException(status_code=503, detail="SSO가 설정되지 않았습니다.")
|
|
1299
|
+
state = secrets.token_urlsafe(16)
|
|
1300
|
+
_sso_states[state] = time.time()
|
|
1301
|
+
params = urlencode({
|
|
1302
|
+
"client_id": SSO_CLIENT_ID,
|
|
1303
|
+
"response_type": "code",
|
|
1304
|
+
"redirect_uri": SSO_REDIRECT_URI,
|
|
1305
|
+
"scope": "openid email profile",
|
|
1306
|
+
"state": state,
|
|
1307
|
+
})
|
|
1308
|
+
return _Redirect(f"{discovery['authorization_endpoint']}?{params}")
|
|
1309
|
+
|
|
1310
|
+
@app.get("/auth/sso/callback")
|
|
1311
|
+
async def sso_callback(code: str = "", state: str = "", error: str = ""):
|
|
1312
|
+
from fastapi.responses import RedirectResponse as _Redirect
|
|
1313
|
+
import base64 as _b64
|
|
1314
|
+
if error:
|
|
1315
|
+
return _Redirect(f"/?sso_error={error}")
|
|
1316
|
+
ts = _sso_states.pop(state, None)
|
|
1317
|
+
if ts is None or time.time() - ts > 300:
|
|
1318
|
+
raise HTTPException(status_code=400, detail="유효하지 않은 SSO 상태입니다.")
|
|
1319
|
+
discovery = await _get_sso_discovery()
|
|
1320
|
+
if not discovery:
|
|
1321
|
+
raise HTTPException(status_code=503, detail="SSO 설정 오류입니다.")
|
|
1322
|
+
import httpx as _httpx
|
|
1323
|
+
async with _httpx.AsyncClient() as c:
|
|
1324
|
+
r = await c.post(discovery["token_endpoint"], data={
|
|
1325
|
+
"grant_type": "authorization_code",
|
|
1326
|
+
"code": code,
|
|
1327
|
+
"redirect_uri": SSO_REDIRECT_URI,
|
|
1328
|
+
"client_id": SSO_CLIENT_ID,
|
|
1329
|
+
"client_secret": SSO_CLIENT_SECRET,
|
|
1330
|
+
}, headers={"Accept": "application/json"}, timeout=15)
|
|
1331
|
+
tokens = r.json()
|
|
1332
|
+
id_token = tokens.get("id_token")
|
|
1333
|
+
if not id_token:
|
|
1334
|
+
raise HTTPException(status_code=400, detail="ID 토큰을 받지 못했습니다.")
|
|
1335
|
+
# Decode JWT payload (no signature verification — trust IdP redirect)
|
|
1336
|
+
padded = id_token.split(".")[1] + "=="
|
|
1337
|
+
payload = json.loads(_b64.urlsafe_b64decode(padded))
|
|
1338
|
+
email = payload.get("email") or payload.get("preferred_username") or payload.get("upn") or ""
|
|
1339
|
+
if not email:
|
|
1340
|
+
raise HTTPException(status_code=400, detail="이메일을 확인할 수 없습니다.")
|
|
1341
|
+
users = load_users()
|
|
1342
|
+
if email not in users:
|
|
1343
|
+
is_first = len(users) == 0
|
|
1344
|
+
users[email] = {
|
|
1345
|
+
"password": "",
|
|
1346
|
+
"name": payload.get("name", email.split("@")[0]),
|
|
1347
|
+
"nickname": payload.get("given_name", email.split("@")[0]),
|
|
1348
|
+
"role": "admin" if is_first else "user",
|
|
1349
|
+
"disabled": False,
|
|
1350
|
+
"sso": True,
|
|
1351
|
+
}
|
|
1352
|
+
save_users(users)
|
|
1353
|
+
if users[email].get("disabled"):
|
|
1354
|
+
raise HTTPException(status_code=403, detail="비활성화된 계정입니다.")
|
|
1355
|
+
token = create_session(email)
|
|
1356
|
+
resp = _Redirect("/chat", status_code=302)
|
|
1357
|
+
resp.set_cookie("session_token", token, httponly=True, samesite="lax", max_age=_SESSION_TTL)
|
|
1358
|
+
return resp
|
|
1359
|
+
|
|
1235
1360
|
@app.post("/logout")
|
|
1236
1361
|
async def logout(request: Request):
|
|
1237
1362
|
token = _extract_bearer_token(request)
|
|
@@ -2405,6 +2530,23 @@ async def delete_history(request: Request, keep_last: int = 0):
|
|
|
2405
2530
|
require_user(request)
|
|
2406
2531
|
return clear_history(keep_last)
|
|
2407
2532
|
|
|
2533
|
+
@app.get("/history/search")
|
|
2534
|
+
async def search_history(q: str, request: Request):
|
|
2535
|
+
"""키워드로 채팅 히스토리를 검색합니다."""
|
|
2536
|
+
require_user(request)
|
|
2537
|
+
if not q or not q.strip():
|
|
2538
|
+
return {"results": [], "query": q}
|
|
2539
|
+
q_lower = q.strip().lower()
|
|
2540
|
+
history = get_history()
|
|
2541
|
+
matches = [item for item in history if q_lower in (item.get("content") or "").lower()]
|
|
2542
|
+
grouped: Dict[str, Dict] = {}
|
|
2543
|
+
for item in matches:
|
|
2544
|
+
cid = item.get("conversation_id") or "legacy"
|
|
2545
|
+
if cid not in grouped:
|
|
2546
|
+
grouped[cid] = {"conversation_id": cid, "title": conversation_title(item), "messages": []}
|
|
2547
|
+
grouped[cid]["messages"].append(item)
|
|
2548
|
+
return {"results": list(grouped.values())[-30:], "query": q}
|
|
2549
|
+
|
|
2408
2550
|
|
|
2409
2551
|
async def _stream_chat(req: ChatRequest, context: str = "", image_data: str = None) -> AsyncIterator[str]:
|
|
2410
2552
|
full_response = ""
|
package/static/account.html
CHANGED
|
@@ -164,6 +164,34 @@
|
|
|
164
164
|
.switch a { color: var(--accent); text-decoration: none; font-weight: 700; }
|
|
165
165
|
.switch a:hover { text-decoration: underline; }
|
|
166
166
|
|
|
167
|
+
.sso-divider {
|
|
168
|
+
display: flex; align-items: center; gap: 10px;
|
|
169
|
+
margin: 14px 0 10px;
|
|
170
|
+
color: var(--faint); font-size: 11.5px;
|
|
171
|
+
}
|
|
172
|
+
.sso-divider::before, .sso-divider::after {
|
|
173
|
+
content: ''; flex: 1;
|
|
174
|
+
height: 1px; background: rgba(255,255,255,0.07);
|
|
175
|
+
}
|
|
176
|
+
.sso-btn {
|
|
177
|
+
width: 100%;
|
|
178
|
+
padding: 12px;
|
|
179
|
+
background: rgba(255,255,255,0.04);
|
|
180
|
+
border: 1px solid rgba(255,255,255,0.1);
|
|
181
|
+
color: var(--text);
|
|
182
|
+
border-radius: 10px;
|
|
183
|
+
cursor: pointer;
|
|
184
|
+
font-weight: 600;
|
|
185
|
+
font-size: 13.5px;
|
|
186
|
+
font-family: inherit;
|
|
187
|
+
display: flex; align-items: center; justify-content: center; gap: 8px;
|
|
188
|
+
transition: all .18s;
|
|
189
|
+
}
|
|
190
|
+
.sso-btn:hover {
|
|
191
|
+
background: rgba(255,255,255,0.08);
|
|
192
|
+
border-color: rgba(34,211,160,0.3);
|
|
193
|
+
}
|
|
194
|
+
|
|
167
195
|
.msg {
|
|
168
196
|
font-size: 12px;
|
|
169
197
|
min-height: 18px;
|
|
@@ -238,6 +266,13 @@
|
|
|
238
266
|
<input class="input" type="password" id="login-pw" placeholder="비밀번호" onkeydown="if(event.key==='Enter')doLogin()">
|
|
239
267
|
<div class="msg" id="login-msg"></div>
|
|
240
268
|
<button class="submit" id="login-btn" onclick="doLogin()" data-ko="로그인" data-en="Log in">로그인</button>
|
|
269
|
+
<div id="sso-section" style="display:none;">
|
|
270
|
+
<div class="sso-divider" id="sso-divider-text">또는</div>
|
|
271
|
+
<button class="sso-btn" id="sso-btn" onclick="doSSOLogin()">
|
|
272
|
+
<i class="ti ti-building"></i>
|
|
273
|
+
<span id="sso-btn-label">SSO로 로그인</span>
|
|
274
|
+
</button>
|
|
275
|
+
</div>
|
|
241
276
|
<p class="switch" id="login-switch">
|
|
242
277
|
<span id="no-account-text">계정이 없으신가요?</span>
|
|
243
278
|
<a href="#" onclick="showSection('register');return false;" id="go-register-link">회원가입</a>
|
|
@@ -281,6 +316,7 @@
|
|
|
281
316
|
err_fill: '모든 항목을 입력해주세요.',
|
|
282
317
|
err_login_fail: '이메일 또는 비밀번호가 틀렸습니다.',
|
|
283
318
|
err_server: '서버 연결 실패',
|
|
319
|
+
sso_divider: '또는', sso_btn: '로 로그인',
|
|
284
320
|
},
|
|
285
321
|
en: {
|
|
286
322
|
login_title: 'Lattice AI', login_sub: 'Local AI Workspace — Apple Silicon',
|
|
@@ -294,6 +330,7 @@
|
|
|
294
330
|
err_fill: 'Please fill in all fields.',
|
|
295
331
|
err_login_fail: 'Invalid email or password.',
|
|
296
332
|
err_server: 'Server connection failed',
|
|
333
|
+
sso_divider: 'or', sso_btn: 'Sign in with',
|
|
297
334
|
}
|
|
298
335
|
};
|
|
299
336
|
|
|
@@ -319,12 +356,33 @@
|
|
|
319
356
|
document.getElementById('reg-name').placeholder = t('ph_name');
|
|
320
357
|
document.getElementById('reg-nick').placeholder = t('ph_nick');
|
|
321
358
|
document.getElementById('lang-label').textContent = lang === 'ko' ? '한국어' : 'English';
|
|
359
|
+
document.getElementById('sso-divider-text').textContent = t('sso_divider');
|
|
360
|
+
const ssoName = window._ssoProviderName || 'SSO';
|
|
361
|
+
document.getElementById('sso-btn-label').textContent =
|
|
362
|
+
lang === 'ko' ? `${ssoName}${t('sso_btn')}` : `${t('sso_btn')} ${ssoName}`;
|
|
322
363
|
['ko', 'en'].forEach(l => {
|
|
323
364
|
const el = document.getElementById(`opt-${l}`);
|
|
324
365
|
if (el) el.classList.toggle('active', l === lang);
|
|
325
366
|
});
|
|
326
367
|
}
|
|
327
368
|
|
|
369
|
+
async function initSSO() {
|
|
370
|
+
try {
|
|
371
|
+
const res = await apiFetch('/auth/sso/config');
|
|
372
|
+
if (!res.ok) return;
|
|
373
|
+
const cfg = await res.json();
|
|
374
|
+
if (cfg.enabled) {
|
|
375
|
+
window._ssoProviderName = cfg.provider_name;
|
|
376
|
+
document.getElementById('sso-section').style.display = '';
|
|
377
|
+
applyI18n();
|
|
378
|
+
}
|
|
379
|
+
} catch {}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function doSSOLogin() {
|
|
383
|
+
window.location.href = '/auth/sso/login';
|
|
384
|
+
}
|
|
385
|
+
|
|
328
386
|
function toggleLang() {
|
|
329
387
|
const m = document.getElementById('lang-menu');
|
|
330
388
|
m.classList.toggle('open');
|
|
@@ -436,6 +494,8 @@
|
|
|
436
494
|
if (r.ok) window.location.href = '/chat';
|
|
437
495
|
}).catch(() => {});
|
|
438
496
|
|
|
497
|
+
initSSO();
|
|
498
|
+
|
|
439
499
|
// Handle invite code in URL
|
|
440
500
|
const urlCode = new URLSearchParams(window.location.search).get('code');
|
|
441
501
|
if (urlCode) {
|
package/static/chat.html
CHANGED
|
@@ -180,6 +180,48 @@
|
|
|
180
180
|
flex-shrink: 0;
|
|
181
181
|
}
|
|
182
182
|
|
|
183
|
+
.sidebar-search {
|
|
184
|
+
padding: 8px 10px;
|
|
185
|
+
border-bottom: 1px solid rgba(255,255,255,0.05);
|
|
186
|
+
}
|
|
187
|
+
.sidebar-search input {
|
|
188
|
+
width: 100%;
|
|
189
|
+
padding: 7px 10px 7px 30px;
|
|
190
|
+
background: rgba(255,255,255,0.04);
|
|
191
|
+
border: 1px solid rgba(255,255,255,0.07);
|
|
192
|
+
border-radius: 8px;
|
|
193
|
+
color: var(--text);
|
|
194
|
+
font-size: 12px;
|
|
195
|
+
font-family: inherit;
|
|
196
|
+
outline: none;
|
|
197
|
+
transition: border-color .15s;
|
|
198
|
+
}
|
|
199
|
+
.sidebar-search input:focus { border-color: rgba(34,211,160,0.4); }
|
|
200
|
+
.sidebar-search input::placeholder { color: var(--faint); }
|
|
201
|
+
.sidebar-search-wrap {
|
|
202
|
+
position: relative;
|
|
203
|
+
}
|
|
204
|
+
.sidebar-search-wrap i {
|
|
205
|
+
position: absolute;
|
|
206
|
+
left: 8px; top: 50%;
|
|
207
|
+
transform: translateY(-50%);
|
|
208
|
+
color: var(--faint);
|
|
209
|
+
font-size: 13px;
|
|
210
|
+
pointer-events: none;
|
|
211
|
+
}
|
|
212
|
+
.history-item-del {
|
|
213
|
+
margin-left: auto;
|
|
214
|
+
opacity: 0;
|
|
215
|
+
color: var(--faint);
|
|
216
|
+
font-size: 13px;
|
|
217
|
+
padding: 2px 4px;
|
|
218
|
+
border-radius: 4px;
|
|
219
|
+
transition: all .15s;
|
|
220
|
+
flex-shrink: 0;
|
|
221
|
+
}
|
|
222
|
+
.history-item:hover .history-item-del { opacity: 1; }
|
|
223
|
+
.history-item-del:hover { color: #ff6b6b; background: rgba(255,107,107,0.12); }
|
|
224
|
+
|
|
183
225
|
.history-container {
|
|
184
226
|
flex: 1;
|
|
185
227
|
overflow-y: auto;
|
|
@@ -420,6 +462,62 @@
|
|
|
420
462
|
justify-content: center;
|
|
421
463
|
}
|
|
422
464
|
.acct-modal-overlay.open { display: flex; }
|
|
465
|
+
|
|
466
|
+
/* MCP 관리 모달 */
|
|
467
|
+
.mcp-modal-overlay {
|
|
468
|
+
display: none;
|
|
469
|
+
position: fixed;
|
|
470
|
+
inset: 0;
|
|
471
|
+
background: rgba(0,0,0,0.6);
|
|
472
|
+
backdrop-filter: blur(4px);
|
|
473
|
+
z-index: 1000;
|
|
474
|
+
align-items: center;
|
|
475
|
+
justify-content: center;
|
|
476
|
+
}
|
|
477
|
+
.mcp-modal-overlay.open { display: flex; }
|
|
478
|
+
.mcp-modal {
|
|
479
|
+
background: var(--surface, #1e293b);
|
|
480
|
+
border: 1px solid rgba(255,255,255,0.08);
|
|
481
|
+
border-radius: 16px;
|
|
482
|
+
width: 100%;
|
|
483
|
+
max-width: 560px;
|
|
484
|
+
max-height: 80vh;
|
|
485
|
+
display: flex;
|
|
486
|
+
flex-direction: column;
|
|
487
|
+
box-shadow: 0 20px 60px rgba(0,0,0,0.5);
|
|
488
|
+
overflow: hidden;
|
|
489
|
+
}
|
|
490
|
+
.mcp-modal-header {
|
|
491
|
+
padding: 18px 20px;
|
|
492
|
+
border-bottom: 1px solid rgba(255,255,255,0.07);
|
|
493
|
+
display: flex; align-items: center; justify-content: space-between;
|
|
494
|
+
}
|
|
495
|
+
.mcp-modal-header h3 { font-size: 15px; font-weight: 700; color: var(--text); }
|
|
496
|
+
.mcp-modal-close { background: none; border: none; color: var(--faint); cursor: pointer; font-size: 18px; padding: 2px 6px; border-radius: 6px; }
|
|
497
|
+
.mcp-modal-close:hover { color: var(--text); background: rgba(255,255,255,0.07); }
|
|
498
|
+
.mcp-modal-body { flex: 1; overflow-y: auto; padding: 16px 20px; }
|
|
499
|
+
.mcp-section-label { font-size: 10px; font-weight: 700; color: var(--faint); text-transform: uppercase; letter-spacing: .08em; margin: 12px 0 8px; }
|
|
500
|
+
.mcp-item {
|
|
501
|
+
display: flex; align-items: center; gap: 12px;
|
|
502
|
+
padding: 11px 14px;
|
|
503
|
+
background: rgba(255,255,255,0.03);
|
|
504
|
+
border: 1px solid rgba(255,255,255,0.06);
|
|
505
|
+
border-radius: 10px;
|
|
506
|
+
margin-bottom: 6px;
|
|
507
|
+
}
|
|
508
|
+
.mcp-item-icon { font-size: 20px; flex-shrink: 0; }
|
|
509
|
+
.mcp-item-info { flex: 1; min-width: 0; }
|
|
510
|
+
.mcp-item-name { font-size: 13px; font-weight: 600; color: var(--text); }
|
|
511
|
+
.mcp-item-desc { font-size: 11px; color: var(--faint); margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
512
|
+
.mcp-item-status { font-size: 10.5px; color: var(--accent); font-weight: 600; }
|
|
513
|
+
.mcp-item-status.inactive { color: var(--faint); }
|
|
514
|
+
.mcp-install-btn {
|
|
515
|
+
padding: 6px 12px; border-radius: 7px; font-size: 12px; font-weight: 600;
|
|
516
|
+
border: 1px solid rgba(34,211,160,0.3); background: rgba(34,211,160,0.08);
|
|
517
|
+
color: var(--accent); cursor: pointer; transition: all .15s; flex-shrink: 0;
|
|
518
|
+
}
|
|
519
|
+
.mcp-install-btn:hover { background: rgba(34,211,160,0.15); }
|
|
520
|
+
.mcp-install-btn.installed { border-color: rgba(255,255,255,0.1); background: rgba(255,255,255,0.04); color: var(--faint); }
|
|
423
521
|
.acct-modal {
|
|
424
522
|
background: var(--surface, #1e293b);
|
|
425
523
|
border: 1px solid rgba(255,255,255,0.08);
|
|
@@ -2827,6 +2925,12 @@
|
|
|
2827
2925
|
<div style="font-size:10.5px;color:var(--faint)">Local Workspace</div>
|
|
2828
2926
|
</div>
|
|
2829
2927
|
</div>
|
|
2928
|
+
<div class="sidebar-search">
|
|
2929
|
+
<div class="sidebar-search-wrap">
|
|
2930
|
+
<i class="ti ti-search"></i>
|
|
2931
|
+
<input type="text" id="history-search-input" placeholder="대화 검색..." oninput="onHistorySearch(this.value)">
|
|
2932
|
+
</div>
|
|
2933
|
+
</div>
|
|
2830
2934
|
<div class="history-container" id="history-container">
|
|
2831
2935
|
<!-- History items -->
|
|
2832
2936
|
</div>
|
|
@@ -2834,6 +2938,7 @@
|
|
|
2834
2938
|
<button id="admin-btn" class="admin-btn" onclick="openAdminPanel()"><i class="ti ti-shield-lock"></i> <span data-i18n="admin_dashboard">관리자 대시보드</span></button>
|
|
2835
2939
|
<button class="status-btn" onclick="openStatusPanel()"><i class="ti ti-info-circle"></i> <span data-i18n="my_status">내 상태 보기</span></button>
|
|
2836
2940
|
<button id="setup-wizard-btn" class="setup-wizard-sidebar-btn" onclick="openSetupWizard()"><i class="ti ti-sparkles"></i> <span data-i18n="auto_setup">자동 설정</span></button>
|
|
2941
|
+
<button class="setup-wizard-sidebar-btn" onclick="openMcpModal()"><i class="ti ti-plug-connected"></i> MCP 관리</button>
|
|
2837
2942
|
<button id="new-chat-btn" class="new-chat-btn"><i class="ti ti-plus"></i> New Chat</button>
|
|
2838
2943
|
</div>
|
|
2839
2944
|
</aside>
|
|
@@ -2909,6 +3014,19 @@
|
|
|
2909
3014
|
</div>
|
|
2910
3015
|
</div>
|
|
2911
3016
|
|
|
3017
|
+
<!-- MCP 관리 모달 -->
|
|
3018
|
+
<div class="mcp-modal-overlay" id="mcp-modal-overlay" onclick="if(event.target===this)closeMcpModal()">
|
|
3019
|
+
<div class="mcp-modal">
|
|
3020
|
+
<div class="mcp-modal-header">
|
|
3021
|
+
<h3><i class="ti ti-plug-connected"></i> MCP 서버 관리</h3>
|
|
3022
|
+
<button class="mcp-modal-close" onclick="closeMcpModal()"><i class="ti ti-x"></i></button>
|
|
3023
|
+
</div>
|
|
3024
|
+
<div class="mcp-modal-body" id="mcp-modal-body">
|
|
3025
|
+
<div style="color:var(--faint);font-size:13px;text-align:center;padding:24px">로딩 중...</div>
|
|
3026
|
+
</div>
|
|
3027
|
+
</div>
|
|
3028
|
+
</div>
|
|
3029
|
+
|
|
2912
3030
|
<section class="ops-strip" aria-label="workspace status">
|
|
2913
3031
|
<div class="ops-card primary interactive" onclick="openModelPanel()">
|
|
2914
3032
|
<div>
|
|
@@ -4926,27 +5044,59 @@
|
|
|
4926
5044
|
}
|
|
4927
5045
|
}
|
|
4928
5046
|
|
|
5047
|
+
function renderHistoryItems(conversations) {
|
|
5048
|
+
if (!conversations.length) {
|
|
5049
|
+
historyContainer.innerHTML = '<div class="history-section-label">CHATS</div><div class="history-empty">아직 저장된 대화가 없습니다.</div>';
|
|
5050
|
+
return;
|
|
5051
|
+
}
|
|
5052
|
+
historyContainer.innerHTML = '<div class="history-section-label">CHATS</div>' + conversations.map(item => `
|
|
5053
|
+
<div class="history-item ${item.id === currentConversationId ? 'active' : ''}" data-conversation-id="${escapeHtml(item.id)}" title="${escapeHtml(item.title || '')}">
|
|
5054
|
+
<i class="ti ti-message-2"></i>
|
|
5055
|
+
<span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis">${escapeHtml(item.title || '새 대화')}</span>
|
|
5056
|
+
<span class="history-item-del" onclick="event.stopPropagation();deleteConversation('${escapeHtml(item.id)}')"><i class="ti ti-trash"></i></span>
|
|
5057
|
+
</div>
|
|
5058
|
+
`).join('');
|
|
5059
|
+
historyContainer.querySelectorAll('.history-item').forEach(item => {
|
|
5060
|
+
item.onclick = () => openConversation(item.dataset.conversationId);
|
|
5061
|
+
});
|
|
5062
|
+
}
|
|
5063
|
+
|
|
4929
5064
|
async function loadHistory() {
|
|
4930
5065
|
try {
|
|
4931
5066
|
const conversations = await fetchConversations();
|
|
4932
|
-
|
|
4933
|
-
historyContainer.innerHTML = '<div class="history-section-label">CHATS</div><div class="history-empty">아직 저장된 대화가 없습니다.</div>';
|
|
4934
|
-
return [];
|
|
4935
|
-
}
|
|
4936
|
-
historyContainer.innerHTML = '<div class="history-section-label">CHATS</div>' + conversations.map(item => `
|
|
4937
|
-
<div class="history-item ${item.id === currentConversationId ? 'active' : ''}" data-conversation-id="${escapeHtml(item.id)}" title="${escapeHtml(item.title || '')}">
|
|
4938
|
-
<i class="ti ti-message-2"></i>
|
|
4939
|
-
<span>${escapeHtml(item.title || '새 대화')}</span>
|
|
4940
|
-
</div>
|
|
4941
|
-
`).join('');
|
|
4942
|
-
historyContainer.querySelectorAll('.history-item').forEach(item => {
|
|
4943
|
-
item.onclick = () => openConversation(item.dataset.conversationId);
|
|
4944
|
-
});
|
|
5067
|
+
renderHistoryItems(conversations);
|
|
4945
5068
|
return conversations;
|
|
4946
5069
|
} catch (e) { }
|
|
4947
5070
|
return [];
|
|
4948
5071
|
}
|
|
4949
5072
|
|
|
5073
|
+
let _searchDebounce = null;
|
|
5074
|
+
async function onHistorySearch(q) {
|
|
5075
|
+
clearTimeout(_searchDebounce);
|
|
5076
|
+
if (!q.trim()) { loadHistory(); return; }
|
|
5077
|
+
_searchDebounce = setTimeout(async () => {
|
|
5078
|
+
try {
|
|
5079
|
+
const res = await apiFetch(`/history/search?q=${encodeURIComponent(q)}`);
|
|
5080
|
+
if (!res.ok) return;
|
|
5081
|
+
const data = await res.json();
|
|
5082
|
+
const results = (data.results || []).map(r => ({
|
|
5083
|
+
id: r.conversation_id,
|
|
5084
|
+
title: r.title || '새 대화',
|
|
5085
|
+
}));
|
|
5086
|
+
renderHistoryItems(results);
|
|
5087
|
+
} catch {}
|
|
5088
|
+
}, 300);
|
|
5089
|
+
}
|
|
5090
|
+
|
|
5091
|
+
async function deleteConversation(conversationId) {
|
|
5092
|
+
if (!confirm('이 대화를 삭제할까요?')) return;
|
|
5093
|
+
try {
|
|
5094
|
+
await apiFetch(`/history/conversations/${encodeURIComponent(conversationId)}`, { method: 'DELETE' });
|
|
5095
|
+
if (currentConversationId === conversationId) startNewConversation();
|
|
5096
|
+
loadHistory();
|
|
5097
|
+
} catch {}
|
|
5098
|
+
}
|
|
5099
|
+
|
|
4950
5100
|
async function restoreCurrentConversation() {
|
|
4951
5101
|
const conversations = await loadHistory();
|
|
4952
5102
|
if (conversations.some(item => item.id === currentConversationId)) {
|
|
@@ -6013,6 +6163,91 @@
|
|
|
6013
6163
|
_footBtns(`<button class="wbtn wbtn-primary" onclick="closeSetupWizard();loadModelStatus()">완료 ✓</button>`);
|
|
6014
6164
|
}
|
|
6015
6165
|
</script>
|
|
6166
|
+
|
|
6167
|
+
<script>
|
|
6168
|
+
// ── MCP 관리 모달 ────────────────────────────────────────────────────────
|
|
6169
|
+
async function openMcpModal() {
|
|
6170
|
+
document.getElementById('mcp-modal-overlay').classList.add('open');
|
|
6171
|
+
await renderMcpModal();
|
|
6172
|
+
}
|
|
6173
|
+
|
|
6174
|
+
function closeMcpModal() {
|
|
6175
|
+
document.getElementById('mcp-modal-overlay').classList.remove('open');
|
|
6176
|
+
}
|
|
6177
|
+
|
|
6178
|
+
async function renderMcpModal() {
|
|
6179
|
+
const body = document.getElementById('mcp-modal-body');
|
|
6180
|
+
body.innerHTML = '<div style="color:var(--faint);font-size:13px;text-align:center;padding:24px">로딩 중...</div>';
|
|
6181
|
+
try {
|
|
6182
|
+
const [installedRes, toolsRes] = await Promise.all([
|
|
6183
|
+
apiFetch('/mcp/installed'),
|
|
6184
|
+
apiFetch('/mcp/tools'),
|
|
6185
|
+
]);
|
|
6186
|
+
const installed = installedRes.ok ? await installedRes.json() : {};
|
|
6187
|
+
const allTools = toolsRes.ok ? await toolsRes.json() : [];
|
|
6188
|
+
|
|
6189
|
+
const installedIds = new Set(Object.keys(installed));
|
|
6190
|
+
const installedItems = allTools.filter(t => installedIds.has(t.id));
|
|
6191
|
+
const availableItems = allTools.filter(t => !installedIds.has(t.id));
|
|
6192
|
+
|
|
6193
|
+
let html = '';
|
|
6194
|
+
|
|
6195
|
+
if (installedItems.length) {
|
|
6196
|
+
html += '<div class="mcp-section-label">설치됨</div>';
|
|
6197
|
+
html += installedItems.map(mcp => `
|
|
6198
|
+
<div class="mcp-item">
|
|
6199
|
+
<div class="mcp-item-icon">${mcp.icon || '🔌'}</div>
|
|
6200
|
+
<div class="mcp-item-info">
|
|
6201
|
+
<div class="mcp-item-name">${escapeHtml(mcp.name || mcp.id)}</div>
|
|
6202
|
+
<div class="mcp-item-desc">${escapeHtml(mcp.description || '')}</div>
|
|
6203
|
+
</div>
|
|
6204
|
+
<span class="mcp-item-status">활성</span>
|
|
6205
|
+
</div>
|
|
6206
|
+
`).join('');
|
|
6207
|
+
}
|
|
6208
|
+
|
|
6209
|
+
if (availableItems.length) {
|
|
6210
|
+
html += '<div class="mcp-section-label">설치 가능</div>';
|
|
6211
|
+
html += availableItems.map(mcp => `
|
|
6212
|
+
<div class="mcp-item" id="mcp-item-${escapeHtml(mcp.id)}">
|
|
6213
|
+
<div class="mcp-item-icon">${mcp.icon || '🔌'}</div>
|
|
6214
|
+
<div class="mcp-item-info">
|
|
6215
|
+
<div class="mcp-item-name">${escapeHtml(mcp.name || mcp.id)}</div>
|
|
6216
|
+
<div class="mcp-item-desc">${escapeHtml(mcp.description || '')}</div>
|
|
6217
|
+
</div>
|
|
6218
|
+
<button class="mcp-install-btn" onclick="installMcp('${escapeHtml(mcp.id)}')">설치</button>
|
|
6219
|
+
</div>
|
|
6220
|
+
`).join('');
|
|
6221
|
+
}
|
|
6222
|
+
|
|
6223
|
+
if (!html) html = '<div style="color:var(--faint);font-size:13px;text-align:center;padding:24px">사용 가능한 MCP 서버가 없습니다.</div>';
|
|
6224
|
+
body.innerHTML = html;
|
|
6225
|
+
} catch (e) {
|
|
6226
|
+
body.innerHTML = `<div style="color:#ff6b6b;font-size:13px;text-align:center;padding:24px">로드 실패: ${escapeHtml(e.message)}</div>`;
|
|
6227
|
+
}
|
|
6228
|
+
}
|
|
6229
|
+
|
|
6230
|
+
async function installMcp(id) {
|
|
6231
|
+
const btn = document.querySelector(`#mcp-item-${CSS.escape(id)} .mcp-install-btn`);
|
|
6232
|
+
if (btn) { btn.disabled = true; btn.textContent = '설치 중...'; }
|
|
6233
|
+
try {
|
|
6234
|
+
const res = await apiFetch('/mcp/install', {
|
|
6235
|
+
method: 'POST',
|
|
6236
|
+
headers: { 'Content-Type': 'application/json' },
|
|
6237
|
+
body: JSON.stringify({ id }),
|
|
6238
|
+
});
|
|
6239
|
+
if (res.ok) {
|
|
6240
|
+
await renderMcpModal();
|
|
6241
|
+
} else {
|
|
6242
|
+
const d = await res.json().catch(() => ({}));
|
|
6243
|
+
if (btn) { btn.disabled = false; btn.textContent = '설치'; }
|
|
6244
|
+
alert(d.detail || '설치 실패');
|
|
6245
|
+
}
|
|
6246
|
+
} catch {
|
|
6247
|
+
if (btn) { btn.disabled = false; btn.textContent = '설치'; }
|
|
6248
|
+
}
|
|
6249
|
+
}
|
|
6250
|
+
</script>
|
|
6016
6251
|
</body>
|
|
6017
6252
|
|
|
6018
6253
|
</html>
|