ltcai 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -28,6 +28,30 @@ Lattice AI/
28
28
 
29
29
  ---
30
30
 
31
+ ## 언어 지원
32
+
33
+ 웹 UI는 **한국어 / 영어** 전환을 지원합니다.
34
+
35
+ - 로그인 페이지 우측 상단 **🌐 Languages** 버튼
36
+ - 메인 화면 헤더 **🌐** 버튼
37
+ - 선택한 언어는 브라우저에 저장됩니다
38
+
39
+ ---
40
+
41
+ ## 플랫폼 지원
42
+
43
+ | 기능 | macOS (Apple Silicon) | Windows / Linux |
44
+ |------|:---:|:---:|
45
+ | 웹 UI / 클라우드 모델 (OpenAI, Groq 등) | ✅ | ✅ |
46
+ | VS Code / Cursor 확장 | ✅ | ✅ |
47
+ | Telegram 봇 | ✅ | ✅ |
48
+ | MLX 로컬 모델 (Gemma, Qwen 등) | ✅ | ❌ Apple Silicon 전용 |
49
+ | Ollama / vLLM / LM Studio 연동 | ✅ | ✅ |
50
+
51
+ > Windows / Linux에서 로컬 모델을 사용하려면 서버 실행 후 웹 UI(`http://localhost:4825`)에서 Ollama 등을 설치할 수 있습니다.
52
+
53
+ ---
54
+
31
55
  ## 빠른 시작
32
56
 
33
57
  ### 설치 & 실행
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ltcai",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Lattice AI local MLX/cloud LLM workspace server",
5
5
  "bin": {
6
6
  "ltcai": "bin/ltcai.js",
package/server.py CHANGED
@@ -1262,6 +1262,41 @@ async def change_password(req: ChangePasswordRequest, request: Request):
1262
1262
  save_users(users)
1263
1263
  return {"status": "ok", "message": "비밀번호가 변경되었습니다."}
1264
1264
 
1265
+ class UpdateProfileRequest(BaseModel):
1266
+ name: Optional[str] = None
1267
+ nickname: Optional[str] = None
1268
+
1269
+ @app.patch("/account/profile")
1270
+ async def update_profile(req: UpdateProfileRequest, request: Request):
1271
+ email = require_user(request)
1272
+ if not email:
1273
+ raise HTTPException(status_code=401, detail="인증이 필요합니다.")
1274
+ if req.name is not None and not req.name.strip():
1275
+ raise HTTPException(status_code=400, detail="이름을 입력해주세요.")
1276
+ if req.nickname is not None and not req.nickname.strip():
1277
+ raise HTTPException(status_code=400, detail="닉네임을 입력해주세요.")
1278
+ users = load_users()
1279
+ user = users.get(email)
1280
+ if not user:
1281
+ raise HTTPException(status_code=404, detail="사용자를 찾을 수 없습니다.")
1282
+ if req.name is not None:
1283
+ users[email]["name"] = req.name.strip()
1284
+ if req.nickname is not None:
1285
+ users[email]["nickname"] = req.nickname.strip()
1286
+ save_users(users)
1287
+ return {"status": "ok", "name": users[email]["name"], "nickname": users[email]["nickname"]}
1288
+
1289
+ @app.get("/account/profile")
1290
+ async def get_profile(request: Request):
1291
+ email = require_user(request)
1292
+ if not email:
1293
+ raise HTTPException(status_code=401, detail="인증이 필요합니다.")
1294
+ users = load_users()
1295
+ user = users.get(email)
1296
+ if not user:
1297
+ raise HTTPException(status_code=404, detail="사용자를 찾을 수 없습니다.")
1298
+ return {"email": email, "name": user.get("name", ""), "nickname": user.get("nickname", "")}
1299
+
1265
1300
  @app.get("/admin/summary")
1266
1301
  async def admin_summary(request: Request):
1267
1302
  _, users = require_admin(request)
@@ -1279,6 +1314,23 @@ async def admin_summary(request: Request):
1279
1314
  "last_message_at": last_timestamp,
1280
1315
  }
1281
1316
 
1317
+ @app.get("/admin/stats")
1318
+ async def admin_stats(request: Request):
1319
+ require_admin(request)
1320
+ history = get_history()
1321
+ from collections import defaultdict
1322
+ daily: dict = defaultdict(lambda: {"user": 0, "assistant": 0})
1323
+ for item in history:
1324
+ ts = item.get("timestamp", "")
1325
+ day = ts[:10] if ts else "unknown"
1326
+ role = item.get("role", "")
1327
+ if role in ("user", "assistant"):
1328
+ daily[day][role] += 1
1329
+ sorted_days = sorted(daily.keys())[-14:]
1330
+ return {
1331
+ "daily": [{"date": d, "user": daily[d]["user"], "assistant": daily[d]["assistant"]} for d in sorted_days]
1332
+ }
1333
+
1282
1334
  @app.get("/admin/users")
1283
1335
  async def admin_users(request: Request):
1284
1336
  _, users = require_admin(request)
@@ -1333,6 +1385,17 @@ async def admin_delete_user(email: str, request: Request):
1333
1385
  save_users(users)
1334
1386
  return {"status": "ok", "deleted": deleted}
1335
1387
 
1388
+ @app.get("/admin/invite-link")
1389
+ async def admin_invite_link(request: Request):
1390
+ require_admin(request)
1391
+ host = request.headers.get("host", f"localhost:{PORT}")
1392
+ scheme = "https" if request.headers.get("x-forwarded-proto") == "https" else "http"
1393
+ if INVITE_GATE_ENABLED:
1394
+ url = f"{scheme}://{host}/?code={INVITE_CODE}"
1395
+ else:
1396
+ url = f"{scheme}://{host}/"
1397
+ return {"invite_url": url, "invite_code": INVITE_CODE, "gate_enabled": INVITE_GATE_ENABLED}
1398
+
1336
1399
  # ── Invitation Logic ────────────────────────────────────────────────────────
1337
1400
  INVITE_CODE = env_value("LATTICEAI_INVITE_CODE", "gemma-lattice-ai")
1338
1401
  INVITE_GATE_ENABLED = env_bool("LATTICEAI_INVITE_GATE_ENABLED", default=False)
package/static/admin.html CHANGED
@@ -522,6 +522,7 @@
522
522
  .field.full { grid-column: auto; }
523
523
  }
524
524
  </style>
525
+ <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
525
526
  </head>
526
527
 
527
528
  <body>
@@ -578,6 +579,18 @@
578
579
  </div>
579
580
  </section>
580
581
 
582
+ <section class="panel" style="margin-bottom:0;">
583
+ <div class="panel-header">
584
+ <div>
585
+ <h3>메시지 활동 (최근 14일)</h3>
586
+ <p>사용자 메시지와 AI 응답 수를 날짜별로 표시합니다.</p>
587
+ </div>
588
+ </div>
589
+ <div class="panel-body" style="padding:16px 20px;">
590
+ <canvas id="activity-chart" height="80"></canvas>
591
+ </div>
592
+ </section>
593
+
581
594
  <section class="panel-grid">
582
595
  <article class="panel">
583
596
  <div class="panel-header">
@@ -668,6 +681,26 @@
668
681
  </div>
669
682
  </section>
670
683
 
684
+ <section class="panel">
685
+ <div class="panel-header">
686
+ <div>
687
+ <h3>초대 링크</h3>
688
+ <p>새 사용자를 초대할 링크를 확인하고 복사합니다.</p>
689
+ </div>
690
+ </div>
691
+ <div class="panel-body">
692
+ <div style="display:flex;gap:8px;align-items:center;">
693
+ <input id="invite-link-input" type="text" readonly
694
+ style="flex:1;background:rgba(0,0,0,0.3);border:1px solid rgba(255,255,255,0.08);border-radius:8px;color:#f8fafc;padding:8px 12px;font-size:13px;outline:none;">
695
+ <button onclick="copyInviteLink()" id="copy-invite-btn"
696
+ style="background:#6366f1;color:#fff;border:none;border-radius:8px;padding:8px 16px;cursor:pointer;font-size:13px;white-space:nowrap;transition:opacity .15s;">
697
+ 복사
698
+ </button>
699
+ </div>
700
+ <div id="invite-gate-info" style="font-size:12px;color:#94a3b8;margin-top:8px;"></div>
701
+ </div>
702
+ </section>
703
+
671
704
  <section class="panel">
672
705
  <div class="panel-header">
673
706
  <div>
@@ -710,6 +743,45 @@
710
743
  };
711
744
  }
712
745
 
746
+ let activityChartInstance = null;
747
+ function renderActivityChart(daily) {
748
+ const labels = daily.map(d => d.date);
749
+ const userData = daily.map(d => d.user);
750
+ const aiData = daily.map(d => d.assistant);
751
+ const ctx = document.getElementById('activity-chart').getContext('2d');
752
+ if (activityChartInstance) activityChartInstance.destroy();
753
+ activityChartInstance = new Chart(ctx, {
754
+ type: 'bar',
755
+ data: {
756
+ labels,
757
+ datasets: [
758
+ { label: '사용자', data: userData, backgroundColor: 'rgba(99,102,241,0.7)', borderRadius: 4 },
759
+ { label: 'AI', data: aiData, backgroundColor: 'rgba(168,85,247,0.5)', borderRadius: 4 }
760
+ ]
761
+ },
762
+ options: {
763
+ responsive: true,
764
+ plugins: { legend: { labels: { color: '#94a3b8', font: { size: 12 } } } },
765
+ scales: {
766
+ x: { ticks: { color: '#64748b' }, grid: { color: 'rgba(255,255,255,0.04)' } },
767
+ y: { ticks: { color: '#64748b', stepSize: 1 }, grid: { color: 'rgba(255,255,255,0.04)' }, beginAtZero: true }
768
+ }
769
+ }
770
+ });
771
+ }
772
+
773
+ async function copyInviteLink() {
774
+ const url = document.getElementById('invite-link-input').value;
775
+ const btn = document.getElementById('copy-invite-btn');
776
+ try {
777
+ await navigator.clipboard.writeText(url);
778
+ btn.textContent = '복사됨 ✅';
779
+ setTimeout(() => btn.textContent = '복사', 2000);
780
+ } catch {
781
+ document.getElementById('invite-link-input').select();
782
+ }
783
+ }
784
+
713
785
  function esc(value) {
714
786
  return String(value ?? '')
715
787
  .replace(/&/g, '&amp;')
@@ -926,12 +998,14 @@
926
998
  access.style.display = 'none';
927
999
 
928
1000
  try {
929
- const [healthRes, vpcRes, summaryRes, usersRes, sensitivityRes] = await Promise.all([
1001
+ const [healthRes, vpcRes, summaryRes, usersRes, sensitivityRes, inviteRes, statsRes] = await Promise.all([
930
1002
  apiFetch('/health'),
931
1003
  apiFetch('/vpc/status'),
932
1004
  apiFetch('/admin/summary', { headers: adminHeaders() }),
933
1005
  apiFetch('/admin/users', { headers: adminHeaders() }),
934
- apiFetch('/admin/sensitivity', { headers: adminHeaders() })
1006
+ apiFetch('/admin/sensitivity', { headers: adminHeaders() }),
1007
+ apiFetch('/admin/invite-link', { headers: adminHeaders() }),
1008
+ apiFetch('/admin/stats', { headers: adminHeaders() })
935
1009
  ]);
936
1010
 
937
1011
  const health = healthRes.ok ? await healthRes.json() : null;
@@ -939,11 +1013,20 @@
939
1013
  const summary = summaryRes.ok ? await summaryRes.json() : null;
940
1014
  const users = usersRes.ok ? await usersRes.json() : null;
941
1015
  const sensitivity = sensitivityRes.ok ? await sensitivityRes.json() : null;
1016
+ const invite = inviteRes.ok ? await inviteRes.json() : null;
1017
+ const stats = statsRes.ok ? await statsRes.json() : null;
942
1018
 
943
1019
  renderSummary(health, summary, vpc);
944
1020
  fillVpcForm(vpc);
945
1021
  renderUsers(users);
946
1022
  renderSensitivity(sensitivity);
1023
+ if (invite) {
1024
+ document.getElementById('invite-link-input').value = invite.invite_url;
1025
+ document.getElementById('invite-gate-info').textContent = invite.gate_enabled
1026
+ ? `초대 코드: ${invite.invite_code} — 초대 게이트 활성화됨`
1027
+ : '초대 게이트 비활성화 상태 — 링크 없이도 접근 가능합니다.';
1028
+ }
1029
+ if (stats) renderActivityChart(stats.daily);
947
1030
 
948
1031
  const failedSections = [];
949
1032
  if (!summaryRes.ok) failedSections.push('요약');
@@ -407,7 +407,7 @@
407
407
  background: rgba(255,255,255,0.04);
408
408
  }
409
409
 
410
- .pw-modal-overlay {
410
+ .acct-modal-overlay {
411
411
  display: none;
412
412
  position: fixed;
413
413
  inset: 0;
@@ -417,20 +417,31 @@
417
417
  align-items: center;
418
418
  justify-content: center;
419
419
  }
420
- .pw-modal-overlay.open { display: flex; }
421
- .pw-modal {
420
+ .acct-modal-overlay.open { display: flex; }
421
+ .acct-modal {
422
422
  background: var(--surface, #1e293b);
423
423
  border: 1px solid rgba(255,255,255,0.08);
424
424
  border-radius: 16px;
425
- padding: 28px;
426
425
  width: 100%;
427
- max-width: 360px;
426
+ max-width: 380px;
428
427
  display: flex;
429
428
  flex-direction: column;
430
- gap: 16px;
431
429
  box-shadow: 0 20px 60px rgba(0,0,0,0.5);
430
+ overflow: hidden;
431
+ }
432
+ .acct-tabs {
433
+ display: flex;
434
+ border-bottom: 1px solid rgba(255,255,255,0.07);
435
+ }
436
+ .acct-tab {
437
+ flex: 1; padding: 14px; font-size: 13px; font-weight: 500;
438
+ background: none; border: none; color: var(--muted); cursor: pointer;
439
+ transition: all .15s; border-bottom: 2px solid transparent;
432
440
  }
433
- .pw-modal h3 { font-size: 15px; font-weight: 600; margin: 0; }
441
+ .acct-tab.active { color: var(--text, #f8fafc); border-bottom-color: var(--accent, #6366f1); }
442
+ .acct-body { padding: 24px; display: flex; flex-direction: column; gap: 14px; }
443
+ .acct-tab-panel { display: none; flex-direction: column; gap: 14px; }
444
+ .acct-tab-panel.active { display: flex; }
434
445
  .pw-field { display: flex; flex-direction: column; gap: 5px; }
435
446
  .pw-field label { font-size: 11px; color: var(--muted); }
436
447
  .pw-field input {
@@ -457,6 +468,41 @@
457
468
  .pw-msg.error { color: #f87171; }
458
469
  .pw-msg.success { color: #4ade80; }
459
470
 
471
+ .lang-picker { position: relative; }
472
+ .lang-picker-menu {
473
+ display: none;
474
+ position: absolute;
475
+ top: calc(100% + 6px);
476
+ right: 0;
477
+ background: #1e293b;
478
+ border: 1px solid rgba(255,255,255,0.1);
479
+ border-radius: 10px;
480
+ overflow: hidden;
481
+ box-shadow: 0 8px 24px rgba(0,0,0,0.4);
482
+ z-index: 200;
483
+ min-width: 90px;
484
+ }
485
+ .lang-picker-menu.open { display: block; }
486
+ .lang-option {
487
+ display: flex; align-items: center; gap: 8px;
488
+ padding: 9px 14px; font-size: 13px; cursor: pointer;
489
+ color: var(--muted); transition: background .12s;
490
+ }
491
+ .lang-option:hover { background: rgba(255,255,255,0.06); color: var(--text, #f8fafc); }
492
+ .lang-option.active { color: var(--accent, #6366f1); font-weight: 600; }
493
+
494
+ .auth-lang-picker {
495
+ position: absolute; top: 20px; right: 20px;
496
+ }
497
+ .auth-lang-btn {
498
+ background: rgba(255,255,255,0.07);
499
+ border: 1px solid rgba(255,255,255,0.12);
500
+ color: #94a3b8; font-size: 12px; padding: 5px 10px;
501
+ border-radius: 8px; cursor: pointer; transition: all .15s;
502
+ }
503
+ .auth-lang-btn:hover { background: rgba(255,255,255,0.12); color: #f8fafc; }
504
+ .auth-lang-picker .lang-picker-menu { top: calc(100% + 6px); right: 0; }
505
+
460
506
  .messages-viewport {
461
507
  flex: 1;
462
508
  overflow-y: auto;
@@ -2764,28 +2810,37 @@
2764
2810
  <div id="auth-overlay" class="auth-overlay">
2765
2811
  <div class="auth-orb auth-orb-1"></div>
2766
2812
  <div class="auth-orb auth-orb-2"></div>
2813
+ <div class="auth-lang-picker lang-picker" id="auth-lang-picker">
2814
+ <button class="auth-lang-btn" id="auth-lang-btn" onclick="toggleLangMenu('auth-lang-picker')">🌐 Languages</button>
2815
+ <div class="lang-picker-menu" id="auth-lang-picker-menu">
2816
+ <div class="lang-option" id="auth-lang-ko" onclick="setLang('ko')">🇰🇷 한국어</div>
2817
+ <div class="lang-option" id="auth-lang-en" onclick="setLang('en')">🇺🇸 English</div>
2818
+ </div>
2819
+ </div>
2767
2820
  <div class="auth-card">
2768
2821
  <div id="login-form">
2769
2822
  <div class="auth-logo"><i class="ti ti-brain"></i></div>
2770
- <h2 class="auth-title">Lattice AI</h2>
2771
- <p class="auth-subtitle">Local AI Workspace — Apple Silicon</p>
2772
- <input class="auth-input" type="email" id="login-email" placeholder="이메일 주소">
2773
- <input class="auth-input" type="password" id="login-pw" placeholder="비밀번호">
2774
- <button class="auth-submit" onclick="handleLogin()">로그인</button>
2775
- <p class="auth-switch">계정이 없으신가요?
2776
- <a href="#" onclick="toggleAuth(true)">회원가입</a></p>
2823
+ <h2 class="auth-title" data-i18n="login_title">Lattice AI</h2>
2824
+ <p class="auth-subtitle" data-i18n="login_sub">Local AI Workspace — Apple Silicon</p>
2825
+ <input class="auth-input" type="email" id="login-email" placeholder="이메일 주소" data-i18n-ph="ph_email">
2826
+ <input class="auth-input" type="password" id="login-pw" placeholder="비밀번호" data-i18n-ph="ph_password">
2827
+ <button class="auth-submit" onclick="handleLogin()" data-i18n="btn_login">로그인</button>
2828
+ <p class="auth-switch"><span data-i18n="no_account">계정이 없으신가요?</span>
2829
+ <a href="#" onclick="toggleAuth(true)" data-i18n="go_register">회원가입</a></p>
2777
2830
  </div>
2778
2831
  <div id="register-form" style="display: none;">
2779
2832
  <div class="auth-logo"><i class="ti ti-user-plus"></i></div>
2780
- <h2 class="auth-title">계정 만들기</h2>
2781
- <p class="auth-subtitle">Lattice AI 워크스페이스에 참여하세요</p>
2782
- <input class="auth-input" type="email" id="reg-email" placeholder="이메일 주소">
2783
- <input class="auth-input" type="password" id="reg-pw" placeholder="비밀번호">
2784
- <input class="auth-input" type="text" id="reg-name" placeholder="이름">
2785
- <input class="auth-input" type="text" id="reg-nickname" placeholder="별명">
2786
- <button class="auth-submit" onclick="handleRegister()">가입하기</button>
2787
- <p class="auth-switch">이미 계정이 있나요?
2788
- <a href="#" onclick="toggleAuth(false)">로그인</a></p>
2833
+ <h2 class="auth-title" data-i18n="register_title">계정 만들기</h2>
2834
+ <p class="auth-subtitle" data-i18n="register_sub">Lattice AI 워크스페이스에 참여하세요</p>
2835
+ <input class="auth-input" type="email" id="reg-email" placeholder="이메일 주소" data-i18n-ph="ph_email">
2836
+ <input class="auth-input" type="password" id="reg-pw" placeholder="비밀번호 (4자 이상)" data-i18n-ph="ph_new_pw">
2837
+ <input class="auth-input" type="password" id="reg-pw2" placeholder="비밀번호 확인" data-i18n-ph="ph_pw_confirm">
2838
+ <input class="auth-input" type="text" id="reg-name" placeholder="이름" data-i18n-ph="ph_fullname">
2839
+ <input class="auth-input" type="text" id="reg-nickname" placeholder="닉네임" data-i18n-ph="ph_nick">
2840
+ <div id="reg-msg" style="font-size:12px;min-height:16px;margin-bottom:4px;"></div>
2841
+ <button class="auth-submit" id="reg-submit-btn" onclick="handleRegister()" data-i18n="btn_register">가입하기</button>
2842
+ <p class="auth-switch"><span data-i18n="have_account">이미 계정이 있나요?</span>
2843
+ <a href="#" onclick="toggleAuth(false)" data-i18n="go_login">로그인</a></p>
2789
2844
  </div>
2790
2845
  </div>
2791
2846
  </div>
@@ -2811,9 +2866,9 @@
2811
2866
  <!-- History items -->
2812
2867
  </div>
2813
2868
  <div class="sidebar-footer">
2814
- <button id="admin-btn" class="admin-btn" onclick="openAdminPanel()"><i class="ti ti-shield-lock"></i> 관리자 대시보드</button>
2815
- <button class="status-btn" onclick="openStatusPanel()"><i class="ti ti-info-circle"></i> 상태 보기</button>
2816
- <button id="setup-wizard-btn" class="setup-wizard-sidebar-btn" onclick="openSetupWizard()"><i class="ti ti-sparkles"></i> 자동 설정</button>
2869
+ <button id="admin-btn" class="admin-btn" onclick="openAdminPanel()"><i class="ti ti-shield-lock"></i> <span data-i18n="admin_dashboard">관리자 대시보드</span></button>
2870
+ <button class="status-btn" onclick="openStatusPanel()"><i class="ti ti-info-circle"></i> <span data-i18n="my_status">내 상태 보기</span></button>
2871
+ <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>
2817
2872
  <button id="new-chat-btn" class="new-chat-btn"><i class="ti ti-plus"></i> New Chat</button>
2818
2873
  </div>
2819
2874
  </aside>
@@ -2832,30 +2887,59 @@
2832
2887
  </div>
2833
2888
  <div class="header-pills">
2834
2889
  <div class="status-pill"><i class="ti ti-device-desktop"></i> Local</div>
2835
- <button onclick="openPwModal()" class="logout-btn" title="비밀번호 변경"><i class="ti ti-user"></i></button>
2836
- <button onclick="logout()" class="logout-btn">로그아웃</button>
2890
+ <div class="lang-picker" id="header-lang-picker">
2891
+ <button class="logout-btn" id="lang-btn" onclick="toggleLangMenu('header-lang-picker')" title="Language">🌐</button>
2892
+ <div class="lang-picker-menu" id="header-lang-picker-menu">
2893
+ <div class="lang-option" id="header-lang-ko" onclick="setLang('ko')">🇰🇷 한국어</div>
2894
+ <div class="lang-option" id="header-lang-en" onclick="setLang('en')">🇺🇸 English</div>
2895
+ </div>
2896
+ </div>
2897
+ <button onclick="openAcctModal()" class="logout-btn" title="계정 설정"><i class="ti ti-user"></i></button>
2898
+ <button onclick="logout()" class="logout-btn" data-i18n="logout">로그아웃</button>
2837
2899
  </div>
2838
2900
  </header>
2839
2901
 
2840
- <div class="pw-modal-overlay" id="pw-modal-overlay">
2841
- <div class="pw-modal">
2842
- <h3>🔐 비밀번호 변경</h3>
2843
- <div class="pw-field">
2844
- <label>현재 비밀번호</label>
2845
- <input type="password" id="pw-cur" placeholder="현재 비밀번호">
2846
- </div>
2847
- <div class="pw-field">
2848
- <label>새 비밀번호</label>
2849
- <input type="password" id="pw-new" placeholder="새 비밀번호 (4자 이상)">
2902
+ <div class="acct-modal-overlay" id="acct-modal-overlay">
2903
+ <div class="acct-modal">
2904
+ <div class="acct-tabs">
2905
+ <button class="acct-tab active" id="tab-profile" onclick="switchAcctTab('profile')" data-i18n="tab_profile">프로필</button>
2906
+ <button class="acct-tab" id="tab-password" onclick="switchAcctTab('password')" data-i18n="tab_password">비밀번호</button>
2850
2907
  </div>
2851
- <div class="pw-field">
2852
- <label>새 비밀번호 확인</label>
2853
- <input type="password" id="pw-new2" placeholder="새 비밀번호 재입력">
2854
- </div>
2855
- <div class="pw-msg" id="pw-msg"></div>
2856
- <div class="pw-actions">
2857
- <button class="pw-cancel" onclick="closePwModal()">취소</button>
2858
- <button class="pw-submit" id="pw-submit-btn" onclick="submitPwChange()">변경</button>
2908
+ <div class="acct-body">
2909
+ <div class="acct-tab-panel active" id="panel-profile">
2910
+ <div class="pw-field">
2911
+ <label data-i18n="label_name">이름</label>
2912
+ <input type="text" id="profile-name" placeholder="이름" data-i18n-ph="ph_name">
2913
+ </div>
2914
+ <div class="pw-field">
2915
+ <label data-i18n="label_nickname">닉네임</label>
2916
+ <input type="text" id="profile-nickname" placeholder="닉네임" data-i18n-ph="ph_nickname">
2917
+ </div>
2918
+ <div class="pw-msg" id="profile-msg"></div>
2919
+ <div class="pw-actions">
2920
+ <button class="pw-cancel" onclick="closeAcctModal()" data-i18n="btn_cancel">취소</button>
2921
+ <button class="pw-submit" id="profile-submit-btn" onclick="submitProfileChange()" data-i18n="btn_save">저장</button>
2922
+ </div>
2923
+ </div>
2924
+ <div class="acct-tab-panel" id="panel-password">
2925
+ <div class="pw-field">
2926
+ <label data-i18n="label_cur_pw">현재 비밀번호</label>
2927
+ <input type="password" id="pw-cur" placeholder="현재 비밀번호" data-i18n-ph="ph_cur_pw">
2928
+ </div>
2929
+ <div class="pw-field">
2930
+ <label data-i18n="label_new_pw">새 비밀번호</label>
2931
+ <input type="password" id="pw-new" placeholder="새 비밀번호 (4자 이상)" data-i18n-ph="ph_new_pw">
2932
+ </div>
2933
+ <div class="pw-field">
2934
+ <label data-i18n="label_new_pw2">새 비밀번호 확인</label>
2935
+ <input type="password" id="pw-new2" placeholder="새 비밀번호 재입력" data-i18n-ph="ph_new_pw2">
2936
+ </div>
2937
+ <div class="pw-msg" id="pw-msg"></div>
2938
+ <div class="pw-actions">
2939
+ <button class="pw-cancel" onclick="closeAcctModal()" data-i18n="btn_cancel">취소</button>
2940
+ <button class="pw-submit" id="pw-submit-btn" onclick="submitPwChange()" data-i18n="btn_change">변경</button>
2941
+ </div>
2942
+ </div>
2859
2943
  </div>
2860
2944
  </div>
2861
2945
  </div>
@@ -2872,16 +2956,16 @@
2872
2956
  <div class="ops-card interactive" onclick="openVpcPanel()">
2873
2957
  <div>
2874
2958
  <div class="ops-label">PRIVATE VPC</div>
2875
- <div id="ops-vpc" class="ops-value">설정 안 됨</div>
2876
- <div id="ops-vpc-meta" class="ops-meta">클릭하여 VPC 연결 설정</div>
2959
+ <div id="ops-vpc" class="ops-value" data-i18n="vpc_not_set">설정 안 됨</div>
2960
+ <div id="ops-vpc-meta" class="ops-meta" data-i18n="vpc_click_to_set">클릭하여 VPC 연결 설정</div>
2877
2961
  </div>
2878
2962
  <div class="ops-icon"><i class="ti ti-cloud-lock"></i></div>
2879
2963
  </div>
2880
2964
  <div class="ops-card interactive" onclick="openAdminPanel()">
2881
2965
  <div>
2882
2966
  <div class="ops-label">SECURITY</div>
2883
- <div class="ops-value">민감정보 감시</div>
2884
- <div id="security-admin-meta" class="ops-meta">관리자 대시보드 접근</div>
2967
+ <div class="ops-value" data-i18n="security_monitor">민감정보 감시</div>
2968
+ <div id="security-admin-meta" class="ops-meta" data-i18n="admin_dashboard_access">관리자 대시보드 접근</div>
2885
2969
  </div>
2886
2970
  <div class="ops-icon"><i class="ti ti-shield-check"></i></div>
2887
2971
  </div>
@@ -2890,12 +2974,12 @@
2890
2974
  <div class="messages-viewport" id="chat-viewport">
2891
2975
  <div class="empty-state" id="empty-state">
2892
2976
  <div style="width:64px;height:64px;background:linear-gradient(135deg,rgba(34,211,160,0.18),rgba(129,140,248,0.12));border:1px solid rgba(34,211,160,0.18);border-radius:18px;display:flex;align-items:center;justify-content:center;font-size:28px;color:var(--accent);margin:0 auto 18px;box-shadow:0 0 32px rgba(34,211,160,0.14)"><i class="ti ti-sparkles"></i></div>
2893
- <h1>무엇을 만들까요?</h1>
2894
- <p>로컬 모델, 이미지 분석, 코드 생성, 프라이빗 VPC — 모든 걸 한 화면에서 이어가세요.</p>
2977
+ <h1 data-i18n="empty_title">무엇을 만들까요?</h1>
2978
+ <p data-i18n="empty_sub">로컬 모델, 이미지 분석, 코드 생성, 프라이빗 VPC — 모든 걸 한 화면에서 이어가세요.</p>
2895
2979
  <div class="empty-grid">
2896
- <div class="empty-chip" onclick="document.getElementById('user-input').value='보고서 초안을 만들어줘';document.getElementById('user-input').focus()"><span class="empty-chip-icon"><i class="ti ti-file-text"></i></span>파일 생성 · 코드 초안</div>
2897
- <div class="empty-chip" onclick="document.getElementById('user-input').value='VPC 보안 구성을 점검해줘';document.getElementById('user-input').focus()"><span class="empty-chip-icon"><i class="ti ti-shield-check"></i></span>VPC 보안 구성 점검</div>
2898
- <div class="empty-chip" onclick="document.getElementById('user-input').value='이 내용을 지식베이스에 정리해줘';document.getElementById('user-input').focus()"><span class="empty-chip-icon"><i class="ti ti-brain"></i></span>로컬 지식 정리</div>
2980
+ <div class="empty-chip" id="chip-file" onclick="document.getElementById('user-input').value=t('chip_file_prompt');document.getElementById('user-input').focus()"><span class="empty-chip-icon"><i class="ti ti-file-text"></i></span><span data-i18n="chip_file">파일 생성 · 코드 초안</span></div>
2981
+ <div class="empty-chip" id="chip-vpc" onclick="document.getElementById('user-input').value=t('chip_vpc_prompt');document.getElementById('user-input').focus()"><span class="empty-chip-icon"><i class="ti ti-shield-check"></i></span><span data-i18n="chip_vpc">VPC 보안 구성 점검</span></div>
2982
+ <div class="empty-chip" id="chip-kb" onclick="document.getElementById('user-input').value=t('chip_kb_prompt');document.getElementById('user-input').focus()"><span class="empty-chip-icon"><i class="ti ti-brain"></i></span><span data-i18n="chip_kb">로컬 지식 정리</span></div>
2899
2983
  </div>
2900
2984
  </div>
2901
2985
  </div>
@@ -2918,16 +3002,16 @@
2918
3002
  <i class="ti ti-paperclip" style="font-size: 20px;"></i>
2919
3003
  <input type="file" id="doc-input" accept=".pdf,.docx,.xlsx,.pptx,.txt,.md,.csv" hidden onchange="attachDocument(this)">
2920
3004
  </label>
2921
- <textarea id="user-input" placeholder="Lattice AI에게 작업을 지시하세요..." rows="1"></textarea>
3005
+ <textarea id="user-input" placeholder="Lattice AI에게 작업을 지시하세요..." rows="1" data-i18n-ph="ph_input"></textarea>
2922
3006
  <button class="send-btn" id="send-btn"><i class="ti ti-send"></i></button>
2923
3007
  </div>
2924
3008
  <div class="file-toolbar">
2925
- <span class="file-toolbar-label">파일 만들기</span>
3009
+ <span class="file-toolbar-label" data-i18n="create_file">파일 만들기</span>
2926
3010
  <button class="file-type-btn" onclick="openFileCreate('docx')"><i class="ti ti-file-word"></i> DOCX</button>
2927
3011
  <button class="file-type-btn" onclick="openFileCreate('xlsx')"><i class="ti ti-file-spreadsheet"></i> XLSX</button>
2928
3012
  <button class="file-type-btn" onclick="openFileCreate('pptx')"><i class="ti ti-presentation"></i> PPTX</button>
2929
3013
  <button class="file-type-btn" onclick="openFileCreate('pdf')"><i class="ti ti-file-type-pdf"></i> PDF</button>
2930
- <button class="file-type-btn" onclick="openLocalBrowser()"><i class="ti ti-folder-open"></i> 로컬 파일</button>
3014
+ <button class="file-type-btn" onclick="openLocalBrowser()"><i class="ti ti-folder-open"></i> <span data-i18n="local_files">로컬 파일</span></button>
2931
3015
  </div>
2932
3016
  </div>
2933
3017
  </div>
@@ -2938,8 +3022,8 @@
2938
3022
  <section class="model-panel">
2939
3023
  <div class="model-panel-header">
2940
3024
  <div>
2941
- <h2>모델 스위처</h2>
2942
- <p style="color: var(--muted); font-size: 12px; margin-top: 4px;">실행 엔진을 설치하고, 엔진에 맞는 local/cloud LLM을 선택합니다.</p>
3025
+ <h2 data-i18n="model_switcher">모델 스위처</h2>
3026
+ <p style="color: var(--muted); font-size: 12px; margin-top: 4px;" data-i18n="model_switcher_sub">실행 엔진을 설치하고, 엔진에 맞는 local/cloud LLM을 선택합니다.</p>
2943
3027
  </div>
2944
3028
  <button class="admin-close" onclick="closeModelPanel()"><i class="ti ti-x"></i></button>
2945
3029
  </div>
@@ -2951,12 +3035,12 @@
2951
3035
  <div id="perm-overlay" class="perm-overlay" style="display:none">
2952
3036
  <div class="perm-dialog">
2953
3037
  <div class="perm-icon"><i class="ti ti-shield-lock"></i></div>
2954
- <div class="perm-title" id="perm-title">파일 접근 요청</div>
3038
+ <div class="perm-title" id="perm-title" data-i18n="perm_title">파일 접근 요청</div>
2955
3039
  <div class="perm-path" id="perm-path"></div>
2956
3040
  <div class="perm-desc" id="perm-desc"></div>
2957
3041
  <div class="perm-actions">
2958
- <button class="perm-deny-btn" onclick="resolvePermission(false)">거부</button>
2959
- <button class="perm-allow-btn" onclick="resolvePermission(true)">허용</button>
3042
+ <button class="perm-deny-btn" onclick="resolvePermission(false)" data-i18n="btn_deny">거부</button>
3043
+ <button class="perm-allow-btn" onclick="resolvePermission(true)" data-i18n="btn_allow">허용</button>
2960
3044
  </div>
2961
3045
  </div>
2962
3046
  </div>
@@ -3285,17 +3369,36 @@
3285
3369
  }
3286
3370
 
3287
3371
  async function handleRegister() {
3288
- const email = document.getElementById('reg-email').value;
3372
+ const email = document.getElementById('reg-email').value.trim();
3289
3373
  const password = document.getElementById('reg-pw').value;
3290
- const name = document.getElementById('reg-name').value;
3291
- const nickname = document.getElementById('reg-nickname').value;
3292
- const res = await apiFetch('/register', {
3293
- method: 'POST',
3294
- headers: { 'Content-Type': 'application/json' },
3295
- body: JSON.stringify({ email, password, name, nickname })
3296
- });
3297
- if (res.ok) { alert("가입 완료! 로그인 해주세요."); toggleAuth(false); }
3298
- else { alert("가입 실패: " + (await res.json()).detail); }
3374
+ const password2 = document.getElementById('reg-pw2').value;
3375
+ const name = document.getElementById('reg-name').value.trim();
3376
+ const nickname = document.getElementById('reg-nickname').value.trim();
3377
+ const msg = document.getElementById('reg-msg');
3378
+ const btn = document.getElementById('reg-submit-btn');
3379
+ const setMsg = (text, isError) => {
3380
+ msg.textContent = text;
3381
+ msg.style.color = isError ? '#f87171' : '#4ade80';
3382
+ };
3383
+ if (!email || !password || !password2 || !name || !nickname) return setMsg('모든 항목을 입력해주세요.', true);
3384
+ if (password.length < 4) return setMsg('비밀번호는 4자 이상이어야 합니다.', true);
3385
+ if (password !== password2) return setMsg('비밀번호가 일치하지 않습니다.', true);
3386
+ btn.disabled = true; btn.textContent = '가입 중...';
3387
+ try {
3388
+ const res = await apiFetch('/register', {
3389
+ method: 'POST',
3390
+ headers: { 'Content-Type': 'application/json' },
3391
+ body: JSON.stringify({ email, password, name, nickname })
3392
+ });
3393
+ if (res.ok) {
3394
+ setMsg('✅ 가입 완료! 로그인 해주세요.', false);
3395
+ setTimeout(() => toggleAuth(false), 1200);
3396
+ } else {
3397
+ const data = await res.json();
3398
+ setMsg(data.detail || '가입 실패', true);
3399
+ }
3400
+ } catch { setMsg('서버 연결 실패', true); }
3401
+ finally { btn.disabled = false; btn.textContent = '가입하기'; }
3299
3402
  }
3300
3403
 
3301
3404
  async function handleLogin() {
@@ -3336,22 +3439,197 @@
3336
3439
  location.reload();
3337
3440
  }
3338
3441
 
3339
- function openPwModal() {
3340
- document.getElementById('pw-cur').value = '';
3341
- document.getElementById('pw-new').value = '';
3342
- document.getElementById('pw-new2').value = '';
3343
- const msg = document.getElementById('pw-msg');
3344
- msg.textContent = '';
3345
- msg.className = 'pw-msg';
3346
- document.getElementById('pw-modal-overlay').classList.add('open');
3442
+ const I18N = {
3443
+ ko: {
3444
+ // 인증
3445
+ login_title: 'Lattice AI', login_sub: 'Local AI Workspace — Apple Silicon',
3446
+ ph_email: '이메일 주소', ph_password: '비밀번호', btn_login: '로그인',
3447
+ no_account: '계정이 없으신가요?', go_register: '회원가입',
3448
+ register_title: '계정 만들기', register_sub: 'Lattice AI 워크스페이스에 참여하세요',
3449
+ ph_new_pw: '비밀번호 (4자 이상)', ph_pw_confirm: '비밀번호 확인',
3450
+ ph_fullname: '이름', ph_nick: '닉네임',
3451
+ btn_register: '가입하기', have_account: '이미 계정이 있나요?', go_login: '로그인',
3452
+ // 헤더 / 사이드바
3453
+ logout: '로그아웃', admin_dashboard: '관리자 대시보드',
3454
+ my_status: '내 상태 보기', auto_setup: '자동 설정',
3455
+ // 계정 모달
3456
+ tab_profile: '프로필', tab_password: '비밀번호',
3457
+ label_name: '이름', label_nickname: '닉네임',
3458
+ label_cur_pw: '현재 비밀번호', label_new_pw: '새 비밀번호', label_new_pw2: '새 비밀번호 확인',
3459
+ ph_name: '이름', ph_nickname: '닉네임', ph_cur_pw: '현재 비밀번호',
3460
+ ph_new_pw2: '새 비밀번호 재입력',
3461
+ btn_save: '저장', btn_change: '변경', btn_cancel: '취소',
3462
+ // ops 스트립
3463
+ vpc_not_set: '설정 안 됨', vpc_click_to_set: '클릭하여 VPC 연결 설정',
3464
+ security_monitor: '민감정보 감시', admin_dashboard_access: '관리자 대시보드 접근',
3465
+ // 빈 화면
3466
+ empty_title: '무엇을 만들까요?',
3467
+ empty_sub: '로컬 모델, 이미지 분석, 코드 생성, 프라이빗 VPC — 모든 걸 한 화면에서 이어가세요.',
3468
+ chip_file: '파일 생성 · 코드 초안', chip_vpc: 'VPC 보안 구성 점검', chip_kb: '로컬 지식 정리',
3469
+ chip_file_prompt: '보고서 초안을 만들어줘',
3470
+ chip_vpc_prompt: 'VPC 보안 구성을 점검해줘',
3471
+ chip_kb_prompt: '이 내용을 지식베이스에 정리해줘',
3472
+ // 입력창
3473
+ ph_input: 'Lattice AI에게 작업을 지시하세요...',
3474
+ // 파일 툴바
3475
+ create_file: '파일 만들기', local_files: '로컬 파일',
3476
+ // 패널 제목
3477
+ model_switcher: '모델 스위처',
3478
+ model_switcher_sub: '실행 엔진을 설치하고, 엔진에 맞는 local/cloud LLM을 선택합니다.',
3479
+ // 권한 다이얼로그
3480
+ perm_title: '파일 접근 요청', btn_deny: '거부', btn_allow: '허용',
3481
+ },
3482
+ en: {
3483
+ // Auth
3484
+ login_title: 'Lattice AI', login_sub: 'Local AI Workspace — Apple Silicon',
3485
+ ph_email: 'Email address', ph_password: 'Password', btn_login: 'Log in',
3486
+ no_account: "Don't have an account?", go_register: 'Sign up',
3487
+ register_title: 'Create Account', register_sub: 'Join the Lattice AI workspace',
3488
+ ph_new_pw: 'Password (min. 4 chars)', ph_pw_confirm: 'Confirm password',
3489
+ ph_fullname: 'Full name', ph_nick: 'Nickname',
3490
+ btn_register: 'Sign up', have_account: 'Already have an account?', go_login: 'Log in',
3491
+ // Header / Sidebar
3492
+ logout: 'Logout', admin_dashboard: 'Admin Dashboard',
3493
+ my_status: 'My Status', auto_setup: 'Auto Setup',
3494
+ // Account modal
3495
+ tab_profile: 'Profile', tab_password: 'Password',
3496
+ label_name: 'Name', label_nickname: 'Nickname',
3497
+ label_cur_pw: 'Current Password', label_new_pw: 'New Password', label_new_pw2: 'Confirm New Password',
3498
+ ph_name: 'Name', ph_nickname: 'Nickname', ph_cur_pw: 'Current password',
3499
+ ph_new_pw2: 'Confirm new password',
3500
+ btn_save: 'Save', btn_change: 'Change', btn_cancel: 'Cancel',
3501
+ // Ops strip
3502
+ vpc_not_set: 'Not configured', vpc_click_to_set: 'Click to set up VPC',
3503
+ security_monitor: 'Sensitive data monitor', admin_dashboard_access: 'Admin dashboard access',
3504
+ // Empty state
3505
+ empty_title: 'What would you like to build?',
3506
+ empty_sub: 'Local models, image analysis, code generation, private VPC — all in one workspace.',
3507
+ chip_file: 'Create file · Code draft', chip_vpc: 'Review VPC security', chip_kb: 'Organize knowledge',
3508
+ chip_file_prompt: 'Draft a report for me',
3509
+ chip_vpc_prompt: 'Review my VPC security configuration',
3510
+ chip_kb_prompt: 'Organize this into my knowledge base',
3511
+ // Input
3512
+ ph_input: 'Ask Lattice AI anything...',
3513
+ // File toolbar
3514
+ create_file: 'Create file', local_files: 'Local files',
3515
+ // Panel titles
3516
+ model_switcher: 'Model Switcher',
3517
+ model_switcher_sub: 'Install a runtime engine and select a local/cloud LLM.',
3518
+ // Permission dialog
3519
+ perm_title: 'File Access Request', btn_deny: 'Deny', btn_allow: 'Allow',
3520
+ }
3521
+ };
3522
+ let currentLang = localStorage.getItem('ltcai_lang') || 'ko';
3523
+
3524
+ function t(key) { return (I18N[currentLang] || I18N.ko)[key] || key; }
3525
+
3526
+ function applyI18n() {
3527
+ document.querySelectorAll('[data-i18n]').forEach(el => {
3528
+ el.textContent = t(el.dataset.i18n);
3529
+ });
3530
+ document.querySelectorAll('[data-i18n-ph]').forEach(el => {
3531
+ el.placeholder = t(el.dataset.i18nPh);
3532
+ });
3533
+ // 언어 선택기 active 표시 업데이트
3534
+ ['auth', 'header'].forEach(prefix => {
3535
+ ['ko', 'en'].forEach(lang => {
3536
+ const el = document.getElementById(`${prefix}-lang-${lang}`);
3537
+ if (el) el.classList.toggle('active', lang === currentLang);
3538
+ });
3539
+ });
3540
+ const authBtn = document.getElementById('auth-lang-btn');
3541
+ if (authBtn) authBtn.textContent = `🌐 ${currentLang === 'ko' ? '한국어' : 'English'}`;
3347
3542
  }
3348
- function closePwModal() {
3349
- document.getElementById('pw-modal-overlay').classList.remove('open');
3543
+
3544
+ function toggleLangMenu(pickerId) {
3545
+ const menu = document.getElementById(`${pickerId}-menu`);
3546
+ if (!menu) return;
3547
+ const isOpen = menu.classList.contains('open');
3548
+ document.querySelectorAll('.lang-picker-menu').forEach(m => m.classList.remove('open'));
3549
+ if (!isOpen) menu.classList.add('open');
3350
3550
  }
3551
+
3552
+ function setLang(lang) {
3553
+ currentLang = lang;
3554
+ localStorage.setItem('ltcai_lang', lang);
3555
+ document.querySelectorAll('.lang-picker-menu').forEach(m => m.classList.remove('open'));
3556
+ applyI18n();
3557
+ }
3558
+
3351
3559
  document.addEventListener('click', (e) => {
3352
- const overlay = document.getElementById('pw-modal-overlay');
3353
- if (e.target === overlay) closePwModal();
3560
+ if (!e.target.closest('.lang-picker')) {
3561
+ document.querySelectorAll('.lang-picker-menu').forEach(m => m.classList.remove('open'));
3562
+ }
3354
3563
  });
3564
+
3565
+ function switchAcctTab(tab) {
3566
+ ['profile', 'password'].forEach(t => {
3567
+ document.getElementById(`tab-${t}`).classList.toggle('active', t === tab);
3568
+ document.getElementById(`panel-${t}`).classList.toggle('active', t === tab);
3569
+ });
3570
+ }
3571
+ async function openAcctModal() {
3572
+ ['profile-msg', 'pw-msg'].forEach(id => {
3573
+ const el = document.getElementById(id);
3574
+ el.textContent = ''; el.className = 'pw-msg';
3575
+ });
3576
+ ['pw-cur', 'pw-new', 'pw-new2'].forEach(id => document.getElementById(id).value = '');
3577
+ switchAcctTab('profile');
3578
+ try {
3579
+ const res = await fetch('/account/profile');
3580
+ if (res.ok) {
3581
+ const data = await res.json();
3582
+ document.getElementById('profile-name').value = data.name || '';
3583
+ document.getElementById('profile-nickname').value = data.nickname || '';
3584
+ }
3585
+ } catch {}
3586
+ document.getElementById('acct-modal-overlay').classList.add('open');
3587
+ }
3588
+ function closeAcctModal() {
3589
+ document.getElementById('acct-modal-overlay').classList.remove('open');
3590
+ }
3591
+ document.addEventListener('click', (e) => {
3592
+ const overlay = document.getElementById('acct-modal-overlay');
3593
+ if (e.target === overlay) closeAcctModal();
3594
+ });
3595
+ async function submitProfileChange() {
3596
+ const name = document.getElementById('profile-name').value.trim();
3597
+ const nickname = document.getElementById('profile-nickname').value.trim();
3598
+ const msg = document.getElementById('profile-msg');
3599
+ const btn = document.getElementById('profile-submit-btn');
3600
+ if (!name || !nickname) {
3601
+ msg.textContent = '이름과 닉네임을 입력해주세요.';
3602
+ msg.className = 'pw-msg error';
3603
+ return;
3604
+ }
3605
+ btn.disabled = true; btn.textContent = '저장 중...';
3606
+ try {
3607
+ const res = await fetch('/account/profile', {
3608
+ method: 'PATCH',
3609
+ headers: { 'Content-Type': 'application/json' },
3610
+ body: JSON.stringify({ name, nickname })
3611
+ });
3612
+ const data = await res.json();
3613
+ if (res.ok) {
3614
+ currentUserNickname = data.nickname;
3615
+ localStorage.setItem('ltcai_user_nickname', data.nickname);
3616
+ document.getElementById('user-nickname-display').innerText = data.nickname;
3617
+ const av = document.getElementById('user-avatar-initial');
3618
+ if (av) av.textContent = (data.nickname || 'G')[0].toUpperCase();
3619
+ msg.textContent = '✅ 프로필이 변경되었습니다.';
3620
+ msg.className = 'pw-msg success';
3621
+ setTimeout(closeAcctModal, 1500);
3622
+ } else {
3623
+ msg.textContent = data.detail || '저장 실패';
3624
+ msg.className = 'pw-msg error';
3625
+ }
3626
+ } catch {
3627
+ msg.textContent = '서버 연결 실패';
3628
+ msg.className = 'pw-msg error';
3629
+ } finally {
3630
+ btn.disabled = false; btn.textContent = '저장';
3631
+ }
3632
+ }
3355
3633
  async function submitPwChange() {
3356
3634
  const cur = document.getElementById('pw-cur').value;
3357
3635
  const nw = document.getElementById('pw-new').value;
@@ -3373,8 +3651,7 @@
3373
3651
  msg.className = 'pw-msg error';
3374
3652
  return;
3375
3653
  }
3376
- btn.disabled = true;
3377
- btn.textContent = '변경 중...';
3654
+ btn.disabled = true; btn.textContent = '변경 중...';
3378
3655
  try {
3379
3656
  const res = await fetch('/account/change-password', {
3380
3657
  method: 'POST',
@@ -3385,7 +3662,7 @@
3385
3662
  if (res.ok) {
3386
3663
  msg.textContent = '✅ 비밀번호가 변경되었습니다.';
3387
3664
  msg.className = 'pw-msg success';
3388
- setTimeout(closePwModal, 1500);
3665
+ setTimeout(closeAcctModal, 1500);
3389
3666
  } else {
3390
3667
  msg.textContent = data.detail || '변경 실패';
3391
3668
  msg.className = 'pw-msg error';
@@ -3394,8 +3671,7 @@
3394
3671
  msg.textContent = '서버 연결 실패';
3395
3672
  msg.className = 'pw-msg error';
3396
3673
  } finally {
3397
- btn.disabled = false;
3398
- btn.textContent = '변경';
3674
+ btn.disabled = false; btn.textContent = '변경';
3399
3675
  }
3400
3676
  }
3401
3677
 
@@ -5269,6 +5545,7 @@
5269
5545
  window.addEventListener('focus', tryClipboardReadFallback);
5270
5546
 
5271
5547
  document.getElementById('new-chat-btn').onclick = startNewChat;
5548
+ applyI18n();
5272
5549
  loadModelStatus();
5273
5550
  loadVpcStatus();
5274
5551
  restoreCurrentConversation();