ltcai 0.1.2 → 0.1.3
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/package.json +3 -3
- package/server.py +13 -6
- package/static/account.html +449 -0
- package/static/admin.html +214 -67
- package/static/{indexd.html → chat.html} +51 -111
- package/static/index.html +0 -270
|
@@ -335,6 +335,8 @@
|
|
|
335
335
|
background: rgba(29,42,60,0.82);
|
|
336
336
|
backdrop-filter: blur(20px);
|
|
337
337
|
-webkit-backdrop-filter: blur(20px);
|
|
338
|
+
position: relative;
|
|
339
|
+
z-index: 50;
|
|
338
340
|
}
|
|
339
341
|
|
|
340
342
|
.header-left, .header-pills {
|
|
@@ -2807,43 +2809,6 @@
|
|
|
2807
2809
|
</div>
|
|
2808
2810
|
<div class="bg-grid"></div>
|
|
2809
2811
|
|
|
2810
|
-
<div id="auth-overlay" class="auth-overlay">
|
|
2811
|
-
<div class="auth-orb auth-orb-1"></div>
|
|
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>
|
|
2820
|
-
<div class="auth-card">
|
|
2821
|
-
<div id="login-form">
|
|
2822
|
-
<div class="auth-logo"><i class="ti ti-brain"></i></div>
|
|
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>
|
|
2830
|
-
</div>
|
|
2831
|
-
<div id="register-form" style="display: none;">
|
|
2832
|
-
<div class="auth-logo"><i class="ti ti-user-plus"></i></div>
|
|
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>
|
|
2844
|
-
</div>
|
|
2845
|
-
</div>
|
|
2846
|
-
</div>
|
|
2847
2812
|
|
|
2848
2813
|
<div class="app-layout">
|
|
2849
2814
|
<!-- Sidebar -->
|
|
@@ -3351,7 +3316,6 @@
|
|
|
3351
3316
|
if (typeof acquireVsCodeApi === 'function') {
|
|
3352
3317
|
vscode = acquireVsCodeApi();
|
|
3353
3318
|
console.log("VS Code API Acquired");
|
|
3354
|
-
document.getElementById('auth-overlay').style.display = 'none'; // VS Code에서는 로그인 건너뜀
|
|
3355
3319
|
}
|
|
3356
3320
|
} catch (e) { /* Not in VS Code */ }
|
|
3357
3321
|
|
|
@@ -3363,80 +3327,12 @@
|
|
|
3363
3327
|
}
|
|
3364
3328
|
});
|
|
3365
3329
|
|
|
3366
|
-
function
|
|
3367
|
-
|
|
3368
|
-
document.getElementById('register-form').style.display = showRegister ? 'block' : 'none';
|
|
3369
|
-
}
|
|
3370
|
-
|
|
3371
|
-
async function handleRegister() {
|
|
3372
|
-
const email = document.getElementById('reg-email').value.trim();
|
|
3373
|
-
const password = document.getElementById('reg-pw').value;
|
|
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 = '가입하기'; }
|
|
3402
|
-
}
|
|
3403
|
-
|
|
3404
|
-
async function handleLogin() {
|
|
3405
|
-
const email = document.getElementById('login-email').value;
|
|
3406
|
-
const password = document.getElementById('login-pw').value;
|
|
3407
|
-
const res = await apiFetch('/login', {
|
|
3408
|
-
method: 'POST',
|
|
3409
|
-
headers: { 'Content-Type': 'application/json' },
|
|
3410
|
-
body: JSON.stringify({ email, password })
|
|
3411
|
-
});
|
|
3412
|
-
if (res.ok) {
|
|
3413
|
-
const data = await res.json();
|
|
3414
|
-
currentUserNickname = data.nickname;
|
|
3415
|
-
currentUserEmail = data.email;
|
|
3416
|
-
localStorage.setItem('ltcai_user_email', currentUserEmail);
|
|
3417
|
-
localStorage.setItem('ltcai_user_nickname', currentUserNickname);
|
|
3418
|
-
localStorage.setItem('ltcai_is_admin', data.is_admin ? 'true' : 'false');
|
|
3419
|
-
isAdmin = Boolean(data.is_admin);
|
|
3420
|
-
document.getElementById('user-nickname-display').innerText = currentUserNickname;
|
|
3421
|
-
const av = document.getElementById('user-avatar-initial');
|
|
3422
|
-
if (av) av.textContent = (currentUserNickname || 'G')[0].toUpperCase();
|
|
3423
|
-
document.getElementById('admin-btn').style.display = 'flex';
|
|
3424
|
-
document.getElementById('security-admin-meta').textContent = isAdmin ? '관리자 권한 있음' : '관리자 대시보드 접근';
|
|
3425
|
-
document.getElementById('auth-overlay').style.display = 'none';
|
|
3426
|
-
loadVpcStatus();
|
|
3427
|
-
restoreCurrentConversation().then(() => {
|
|
3428
|
-
if (!chatViewport.querySelector('.message')) {
|
|
3429
|
-
addMessage('ai', `반갑습니다, <b>${currentUserNickname}</b>님!`);
|
|
3430
|
-
}
|
|
3431
|
-
});
|
|
3432
|
-
} else { alert("로그인 실패!"); }
|
|
3433
|
-
}
|
|
3434
|
-
|
|
3435
|
-
function logout() {
|
|
3330
|
+
async function logout() {
|
|
3331
|
+
try { await apiFetch('/logout', { method: 'POST' }); } catch (_) {}
|
|
3436
3332
|
localStorage.removeItem('ltcai_user_email');
|
|
3437
3333
|
localStorage.removeItem('ltcai_user_nickname');
|
|
3438
3334
|
localStorage.removeItem('ltcai_is_admin');
|
|
3439
|
-
location.
|
|
3335
|
+
window.location.href = '/account';
|
|
3440
3336
|
}
|
|
3441
3337
|
|
|
3442
3338
|
const I18N = {
|
|
@@ -3461,7 +3357,7 @@
|
|
|
3461
3357
|
btn_save: '저장', btn_change: '변경', btn_cancel: '취소',
|
|
3462
3358
|
// ops 스트립
|
|
3463
3359
|
vpc_not_set: '설정 안 됨', vpc_click_to_set: '클릭하여 VPC 연결 설정',
|
|
3464
|
-
security_monitor: '민감정보 감시', admin_dashboard_access: '관리자 대시보드 접근',
|
|
3360
|
+
security_monitor: '민감정보 감시', admin_dashboard_access: '관리자 대시보드 접근', admin_has_rights: '관리자 권한 있음',
|
|
3465
3361
|
// 빈 화면
|
|
3466
3362
|
empty_title: '무엇을 만들까요?',
|
|
3467
3363
|
empty_sub: '로컬 모델, 이미지 분석, 코드 생성, 프라이빗 VPC — 모든 걸 한 화면에서 이어가세요.',
|
|
@@ -3500,7 +3396,7 @@
|
|
|
3500
3396
|
btn_save: 'Save', btn_change: 'Change', btn_cancel: 'Cancel',
|
|
3501
3397
|
// Ops strip
|
|
3502
3398
|
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',
|
|
3399
|
+
security_monitor: 'Sensitive data monitor', admin_dashboard_access: 'Admin dashboard access', admin_has_rights: 'Has admin rights',
|
|
3504
3400
|
// Empty state
|
|
3505
3401
|
empty_title: 'What would you like to build?',
|
|
3506
3402
|
empty_sub: 'Local models, image analysis, code generation, private VPC — all in one workspace.',
|
|
@@ -4051,9 +3947,27 @@
|
|
|
4051
3947
|
}
|
|
4052
3948
|
|
|
4053
3949
|
async function openAdminPanel() {
|
|
3950
|
+
if (!isAdmin) {
|
|
3951
|
+
showToast(currentLang === 'ko' ? '관리자 권한이 없습니다.' : 'Admin access required.');
|
|
3952
|
+
return;
|
|
3953
|
+
}
|
|
4054
3954
|
window.location.href = '/admin';
|
|
4055
3955
|
}
|
|
4056
3956
|
|
|
3957
|
+
function showToast(msg) {
|
|
3958
|
+
let t = document.getElementById('ltcai-toast');
|
|
3959
|
+
if (!t) {
|
|
3960
|
+
t = document.createElement('div');
|
|
3961
|
+
t.id = 'ltcai-toast';
|
|
3962
|
+
t.style.cssText = 'position:fixed;bottom:28px;left:50%;transform:translateX(-50%);background:#1e2330;color:#f8fafc;border:1px solid rgba(255,255,255,0.12);border-radius:10px;padding:10px 18px;font-size:13px;font-weight:600;z-index:9999;box-shadow:0 8px 24px rgba(0,0,0,0.4);pointer-events:none;transition:opacity .2s;';
|
|
3963
|
+
document.body.appendChild(t);
|
|
3964
|
+
}
|
|
3965
|
+
t.textContent = msg;
|
|
3966
|
+
t.style.opacity = '1';
|
|
3967
|
+
clearTimeout(t._timer);
|
|
3968
|
+
t._timer = setTimeout(() => { t.style.opacity = '0'; }, 2200);
|
|
3969
|
+
}
|
|
3970
|
+
|
|
4057
3971
|
function closeAdminPanel() {
|
|
4058
3972
|
document.getElementById('admin-overlay').style.display = 'none';
|
|
4059
3973
|
}
|
|
@@ -5546,6 +5460,32 @@
|
|
|
5546
5460
|
|
|
5547
5461
|
document.getElementById('new-chat-btn').onclick = startNewChat;
|
|
5548
5462
|
applyI18n();
|
|
5463
|
+
|
|
5464
|
+
// Session check — redirect to /account if not logged in
|
|
5465
|
+
(async function restoreSession() {
|
|
5466
|
+
try {
|
|
5467
|
+
const res = await apiFetch('/account/profile');
|
|
5468
|
+
if (res.ok) {
|
|
5469
|
+
const data = await res.json();
|
|
5470
|
+
currentUserEmail = data.email;
|
|
5471
|
+
currentUserNickname = data.nickname || data.name || data.email;
|
|
5472
|
+
isAdmin = Boolean(data.is_admin);
|
|
5473
|
+
localStorage.setItem('ltcai_user_email', currentUserEmail);
|
|
5474
|
+
localStorage.setItem('ltcai_user_nickname', currentUserNickname);
|
|
5475
|
+
localStorage.setItem('ltcai_is_admin', isAdmin ? 'true' : 'false');
|
|
5476
|
+
document.getElementById('user-nickname-display').innerText = currentUserNickname;
|
|
5477
|
+
const av = document.getElementById('user-avatar-initial');
|
|
5478
|
+
if (av) av.textContent = (currentUserNickname || 'G')[0].toUpperCase();
|
|
5479
|
+
document.getElementById('admin-btn').style.display = 'flex';
|
|
5480
|
+
document.getElementById('security-admin-meta').textContent = isAdmin ? t('admin_has_rights') : t('admin_dashboard_access');
|
|
5481
|
+
} else {
|
|
5482
|
+
window.location.href = '/account';
|
|
5483
|
+
}
|
|
5484
|
+
} catch (_) {
|
|
5485
|
+
window.location.href = '/account';
|
|
5486
|
+
}
|
|
5487
|
+
})();
|
|
5488
|
+
|
|
5549
5489
|
loadModelStatus();
|
|
5550
5490
|
loadVpcStatus();
|
|
5551
5491
|
restoreCurrentConversation();
|
package/static/index.html
DELETED
|
@@ -1,270 +0,0 @@
|
|
|
1
|
-
<!DOCTYPE html>
|
|
2
|
-
<html lang="ko">
|
|
3
|
-
<head>
|
|
4
|
-
<meta charset="UTF-8">
|
|
5
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
-
<title>Lattice AI | Local Hub</title>
|
|
7
|
-
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600&family=Inter:wght@400;500&display=swap" rel="stylesheet">
|
|
8
|
-
<style>
|
|
9
|
-
:root {
|
|
10
|
-
--bg-color: #0f172a;
|
|
11
|
-
--glass-bg: rgba(30, 41, 59, 0.7);
|
|
12
|
-
--primary-accent: #6366f1;
|
|
13
|
-
--secondary-accent: #a855f7;
|
|
14
|
-
--text-main: #f8fafc;
|
|
15
|
-
--text-dim: #94a3b8;
|
|
16
|
-
--bot-msg-bg: rgba(51, 65, 85, 0.5);
|
|
17
|
-
--user-msg-bg: linear-gradient(135deg, #6366f1 0%, #a855f7 100%);
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
* {
|
|
21
|
-
margin: 0;
|
|
22
|
-
padding: 0;
|
|
23
|
-
box-sizing: border-box;
|
|
24
|
-
font-family: 'Inter', sans-serif;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
body {
|
|
28
|
-
background-color: var(--bg-color);
|
|
29
|
-
background-image:
|
|
30
|
-
radial-gradient(at 0% 0%, rgba(99, 102, 241, 0.15) 0px, transparent 50%),
|
|
31
|
-
radial-gradient(at 100% 100%, rgba(168, 85, 247, 0.15) 0px, transparent 50%);
|
|
32
|
-
height: 100vh;
|
|
33
|
-
display: flex;
|
|
34
|
-
flex-direction: column;
|
|
35
|
-
color: var(--text-main);
|
|
36
|
-
overflow: hidden;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
header {
|
|
40
|
-
padding: 1.5rem 2rem;
|
|
41
|
-
background: var(--glass-bg);
|
|
42
|
-
backdrop-filter: blur(12px);
|
|
43
|
-
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
|
44
|
-
display: flex;
|
|
45
|
-
justify-content: space-between;
|
|
46
|
-
align-items: center;
|
|
47
|
-
z-index: 10;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
.logo {
|
|
51
|
-
font-family: 'Outfit', sans-serif;
|
|
52
|
-
font-weight: 600;
|
|
53
|
-
font-size: 1.5rem;
|
|
54
|
-
background: linear-gradient(to right, #818cf8, #c084fc);
|
|
55
|
-
-webkit-background-clip: text;
|
|
56
|
-
-webkit-text-fill-color: transparent;
|
|
57
|
-
display: flex;
|
|
58
|
-
align-items: center;
|
|
59
|
-
gap: 0.5rem;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
.status-badge {
|
|
63
|
-
display: flex;
|
|
64
|
-
align-items: center;
|
|
65
|
-
gap: 0.5rem;
|
|
66
|
-
font-size: 0.85rem;
|
|
67
|
-
padding: 0.4rem 0.8rem;
|
|
68
|
-
background: rgba(34, 197, 94, 0.1);
|
|
69
|
-
color: #4ade80;
|
|
70
|
-
border-radius: 20px;
|
|
71
|
-
border: 1px solid rgba(34, 197, 94, 0.2);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
.status-dot {
|
|
75
|
-
width: 8px;
|
|
76
|
-
height: 8px;
|
|
77
|
-
background-color: #4ade80;
|
|
78
|
-
border-radius: 50%;
|
|
79
|
-
box-shadow: 0 0 8px #4ade80;
|
|
80
|
-
animation: pulse 2s infinite;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
@keyframes pulse {
|
|
84
|
-
0% { opacity: 1; }
|
|
85
|
-
50% { opacity: 0.4; }
|
|
86
|
-
100% { opacity: 1; }
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
#chat-container {
|
|
90
|
-
flex: 1;
|
|
91
|
-
overflow-y: auto;
|
|
92
|
-
padding: 2rem;
|
|
93
|
-
display: flex;
|
|
94
|
-
flex-direction: column;
|
|
95
|
-
gap: 1.5rem;
|
|
96
|
-
scroll-behavior: smooth;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
.message {
|
|
100
|
-
max-width: 80%;
|
|
101
|
-
padding: 1rem 1.25rem;
|
|
102
|
-
border-radius: 1.25rem;
|
|
103
|
-
line-height: 1.6;
|
|
104
|
-
font-size: 0.95rem;
|
|
105
|
-
position: relative;
|
|
106
|
-
animation: slideUp 0.3s ease-out;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
@keyframes slideUp {
|
|
110
|
-
from { opacity: 0; transform: translateY(10px); }
|
|
111
|
-
to { opacity: 1; transform: translateY(0); }
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
.user-message {
|
|
115
|
-
align-self: flex-end;
|
|
116
|
-
background: var(--user-msg-bg);
|
|
117
|
-
color: white;
|
|
118
|
-
border-bottom-right-radius: 0.25rem;
|
|
119
|
-
box-shadow: 0 4px 15px rgba(99, 102, 241, 0.3);
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
.bot-message {
|
|
123
|
-
align-self: flex-start;
|
|
124
|
-
background: var(--bot-msg-bg);
|
|
125
|
-
backdrop-filter: blur(5px);
|
|
126
|
-
border: 1px solid rgba(255, 255, 255, 0.05);
|
|
127
|
-
border-bottom-left-radius: 0.25rem;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
.input-area {
|
|
131
|
-
padding: 2rem;
|
|
132
|
-
background: linear-gradient(to top, var(--bg-color), transparent);
|
|
133
|
-
position: relative;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
.input-wrapper {
|
|
137
|
-
max-width: 900px;
|
|
138
|
-
margin: 0 auto;
|
|
139
|
-
background: var(--glass-bg);
|
|
140
|
-
backdrop-filter: blur(20px);
|
|
141
|
-
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
142
|
-
border-radius: 1.5rem;
|
|
143
|
-
padding: 0.75rem;
|
|
144
|
-
display: flex;
|
|
145
|
-
gap: 0.75rem;
|
|
146
|
-
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
|
|
147
|
-
transition: transform 0.2s, border-color 0.2s;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
textarea {
|
|
151
|
-
flex: 1;
|
|
152
|
-
background: transparent;
|
|
153
|
-
border: none;
|
|
154
|
-
color: white;
|
|
155
|
-
padding: 0.75rem;
|
|
156
|
-
resize: none;
|
|
157
|
-
outline: none;
|
|
158
|
-
font-size: 1rem;
|
|
159
|
-
max-height: 150px;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
#send-btn {
|
|
163
|
-
background: var(--primary-accent);
|
|
164
|
-
color: white;
|
|
165
|
-
border: none;
|
|
166
|
-
width: 45px;
|
|
167
|
-
height: 45px;
|
|
168
|
-
border-radius: 12px;
|
|
169
|
-
cursor: pointer;
|
|
170
|
-
display: flex;
|
|
171
|
-
align-items: center;
|
|
172
|
-
justify-content: center;
|
|
173
|
-
transition: all 0.2s;
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
#send-btn:hover { background: var(--secondary-accent); transform: scale(1.05); }
|
|
177
|
-
|
|
178
|
-
.typing { display: flex; gap: 4px; padding: 0.5rem; }
|
|
179
|
-
.dot { width: 6px; height: 6px; background: var(--text-dim); border-radius: 50%; animation: bounce 1.4s infinite ease-in-out; }
|
|
180
|
-
.dot:nth-child(2) { animation-delay: 0.2s; }
|
|
181
|
-
.dot:nth-child(3) { animation-delay: 0.4s; }
|
|
182
|
-
|
|
183
|
-
@keyframes bounce { 0%, 80%, 100% { transform: scale(0); } 40% { transform: scale(1); } }
|
|
184
|
-
</style>
|
|
185
|
-
</head>
|
|
186
|
-
<body>
|
|
187
|
-
<header>
|
|
188
|
-
<div class="logo">
|
|
189
|
-
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a10 10 0 1 0 10 10H12V2z"></path><path d="M12 12L2.1 12.1"></path><path d="M12 12v10"></path><path d="M12 12l8.1-5.4"></path></svg>
|
|
190
|
-
Lattice AI | Premium
|
|
191
|
-
</div>
|
|
192
|
-
<div class="status-badge"><div class="status-dot"></div>Local Hub Active</div>
|
|
193
|
-
</header>
|
|
194
|
-
|
|
195
|
-
<div id="chat-container">
|
|
196
|
-
<div class="message bot-message">
|
|
197
|
-
✨ 안녕하세요! 제가 정성껏 만든 프리미엄 로컬 허브입니다. <br>
|
|
198
|
-
브라우저에서 직접 Gemma 4와 대화해 보세요!
|
|
199
|
-
</div>
|
|
200
|
-
</div>
|
|
201
|
-
|
|
202
|
-
<div class="input-area">
|
|
203
|
-
<div class="input-wrapper">
|
|
204
|
-
<textarea id="user-input" placeholder="메시지를 입력하세요..." rows="1"></textarea>
|
|
205
|
-
<button id="send-btn">
|
|
206
|
-
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"></line><polygon points="22 2 15 22 11 13 2 9 22 2"></polygon></svg>
|
|
207
|
-
</button>
|
|
208
|
-
</div>
|
|
209
|
-
</div>
|
|
210
|
-
|
|
211
|
-
<script>
|
|
212
|
-
const chatContainer = document.getElementById('chat-container');
|
|
213
|
-
const userInput = document.getElementById('user-input');
|
|
214
|
-
const sendBtn = document.getElementById('send-btn');
|
|
215
|
-
|
|
216
|
-
userInput.addEventListener('input', () => {
|
|
217
|
-
userInput.style.height = 'auto';
|
|
218
|
-
userInput.style.height = userInput.scrollHeight + 'px';
|
|
219
|
-
});
|
|
220
|
-
|
|
221
|
-
async function sendMessage() {
|
|
222
|
-
const message = userInput.value.trim();
|
|
223
|
-
if (!message) return;
|
|
224
|
-
addMessage(message, 'user');
|
|
225
|
-
userInput.value = '';
|
|
226
|
-
userInput.style.height = 'auto';
|
|
227
|
-
const loadingMsg = addLoadingMessage();
|
|
228
|
-
chatContainer.scrollTop = chatContainer.scrollHeight;
|
|
229
|
-
try {
|
|
230
|
-
const response = await fetch('/chat', {
|
|
231
|
-
method: 'POST',
|
|
232
|
-
headers: { 'Content-Type': 'application/json' },
|
|
233
|
-
body: JSON.stringify({ message: message, stream: false })
|
|
234
|
-
});
|
|
235
|
-
const data = await response.json();
|
|
236
|
-
loadingMsg.remove();
|
|
237
|
-
addMessage(data.response, 'bot');
|
|
238
|
-
} catch (error) {
|
|
239
|
-
loadingMsg.remove();
|
|
240
|
-
addMessage('❌ 서버 연결 실패', 'bot');
|
|
241
|
-
}
|
|
242
|
-
chatContainer.scrollTop = chatContainer.scrollHeight;
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
function addMessage(text, side) {
|
|
246
|
-
const div = document.createElement('div');
|
|
247
|
-
div.className = `message ${side}-message`;
|
|
248
|
-
div.innerHTML = text.replace(/\n/g, '<br>');
|
|
249
|
-
chatContainer.appendChild(div);
|
|
250
|
-
return div;
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
function addLoadingMessage() {
|
|
254
|
-
const div = document.createElement('div');
|
|
255
|
-
div.className = 'message bot-message typing';
|
|
256
|
-
div.innerHTML = '<div class="dot"></div><div class="dot"></div><div class="dot"></div>';
|
|
257
|
-
chatContainer.appendChild(div);
|
|
258
|
-
return div;
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
sendBtn.addEventListener('click', sendMessage);
|
|
262
|
-
userInput.addEventListener('keydown', (e) => {
|
|
263
|
-
if (e.key === 'Enter' && !e.shiftKey) {
|
|
264
|
-
e.preventDefault();
|
|
265
|
-
sendMessage();
|
|
266
|
-
}
|
|
267
|
-
});
|
|
268
|
-
</script>
|
|
269
|
-
</body>
|
|
270
|
-
</html>
|