ltcai 0.1.1 → 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/README.md +24 -0
- package/package.json +3 -3
- package/server.py +75 -5
- package/static/account.html +449 -0
- package/static/admin.html +289 -59
- package/static/{indexd.html → chat.html} +359 -142
- package/static/index.html +0 -270
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.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "Lattice AI local MLX/cloud LLM workspace server",
|
|
5
5
|
"bin": {
|
|
6
6
|
"ltcai": "bin/ltcai.js",
|
|
@@ -32,8 +32,8 @@
|
|
|
32
32
|
"telegram_bot.py",
|
|
33
33
|
"tools.py",
|
|
34
34
|
"codex_telegram_bot.py",
|
|
35
|
-
"static/
|
|
36
|
-
"static/
|
|
35
|
+
"static/account.html",
|
|
36
|
+
"static/chat.html",
|
|
37
37
|
"static/admin.html",
|
|
38
38
|
"requirements.txt",
|
|
39
39
|
"README.md"
|
package/server.py
CHANGED
|
@@ -1262,6 +1262,43 @@ 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
|
+
role = get_user_role(email, users)
|
|
1299
|
+
return {"email": email, "name": user.get("name", ""), "nickname": user.get("nickname", ""),
|
|
1300
|
+
"role": role, "is_admin": role == "admin"}
|
|
1301
|
+
|
|
1265
1302
|
@app.get("/admin/summary")
|
|
1266
1303
|
async def admin_summary(request: Request):
|
|
1267
1304
|
_, users = require_admin(request)
|
|
@@ -1279,6 +1316,23 @@ async def admin_summary(request: Request):
|
|
|
1279
1316
|
"last_message_at": last_timestamp,
|
|
1280
1317
|
}
|
|
1281
1318
|
|
|
1319
|
+
@app.get("/admin/stats")
|
|
1320
|
+
async def admin_stats(request: Request):
|
|
1321
|
+
require_admin(request)
|
|
1322
|
+
history = get_history()
|
|
1323
|
+
from collections import defaultdict
|
|
1324
|
+
daily: dict = defaultdict(lambda: {"user": 0, "assistant": 0})
|
|
1325
|
+
for item in history:
|
|
1326
|
+
ts = item.get("timestamp", "")
|
|
1327
|
+
day = ts[:10] if ts else "unknown"
|
|
1328
|
+
role = item.get("role", "")
|
|
1329
|
+
if role in ("user", "assistant"):
|
|
1330
|
+
daily[day][role] += 1
|
|
1331
|
+
sorted_days = sorted(daily.keys())[-14:]
|
|
1332
|
+
return {
|
|
1333
|
+
"daily": [{"date": d, "user": daily[d]["user"], "assistant": daily[d]["assistant"]} for d in sorted_days]
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1282
1336
|
@app.get("/admin/users")
|
|
1283
1337
|
async def admin_users(request: Request):
|
|
1284
1338
|
_, users = require_admin(request)
|
|
@@ -1333,26 +1387,37 @@ async def admin_delete_user(email: str, request: Request):
|
|
|
1333
1387
|
save_users(users)
|
|
1334
1388
|
return {"status": "ok", "deleted": deleted}
|
|
1335
1389
|
|
|
1390
|
+
@app.get("/admin/invite-link")
|
|
1391
|
+
async def admin_invite_link(request: Request):
|
|
1392
|
+
require_admin(request)
|
|
1393
|
+
host = request.headers.get("host", f"localhost:{PORT}")
|
|
1394
|
+
scheme = "https" if request.headers.get("x-forwarded-proto") == "https" else "http"
|
|
1395
|
+
if INVITE_GATE_ENABLED:
|
|
1396
|
+
url = f"{scheme}://{host}/?code={INVITE_CODE}"
|
|
1397
|
+
else:
|
|
1398
|
+
url = f"{scheme}://{host}/"
|
|
1399
|
+
return {"invite_url": url, "invite_code": INVITE_CODE, "gate_enabled": INVITE_GATE_ENABLED}
|
|
1400
|
+
|
|
1336
1401
|
# ── Invitation Logic ────────────────────────────────────────────────────────
|
|
1337
1402
|
INVITE_CODE = env_value("LATTICEAI_INVITE_CODE", "gemma-lattice-ai")
|
|
1338
1403
|
INVITE_GATE_ENABLED = env_bool("LATTICEAI_INVITE_GATE_ENABLED", default=False)
|
|
1339
1404
|
|
|
1340
1405
|
@app.get("/")
|
|
1341
1406
|
async def root(request: Request, code: Optional[str] = None, authorized: Optional[str] = Cookie(None)):
|
|
1342
|
-
"""
|
|
1407
|
+
"""로그인/회원가입 페이지. 초대 게이트 활성화 시 코드 검증 후 진입."""
|
|
1343
1408
|
if not INVITE_GATE_ENABLED:
|
|
1344
|
-
return FileResponse(STATIC_DIR / "
|
|
1409
|
+
return FileResponse(STATIC_DIR / "account.html")
|
|
1345
1410
|
|
|
1346
1411
|
# 1. 이미 쿠키로 인증된 경우
|
|
1347
1412
|
if authorized == "true":
|
|
1348
|
-
return FileResponse(STATIC_DIR / "
|
|
1413
|
+
return FileResponse(STATIC_DIR / "account.html")
|
|
1349
1414
|
|
|
1350
1415
|
# 2. 초대 코드가 일치하는 경우 (최초 진입)
|
|
1351
1416
|
if code == INVITE_CODE:
|
|
1352
|
-
response = FileResponse(STATIC_DIR / "
|
|
1417
|
+
response = FileResponse(STATIC_DIR / "account.html")
|
|
1353
1418
|
response.set_cookie(key="authorized", value="true", httponly=True, samesite="lax", max_age=60*60*24*7)
|
|
1354
1419
|
return response
|
|
1355
|
-
|
|
1420
|
+
|
|
1356
1421
|
# 3. 인증 실패 시 차단 화면
|
|
1357
1422
|
return HTMLResponse(content=f"""
|
|
1358
1423
|
<body style="background:#0f1115; color:white; display:flex; flex-direction:column; align-items:center; justify-content:center; height:100vh; font-family:sans-serif;">
|
|
@@ -1366,6 +1431,11 @@ async def root(request: Request, code: Optional[str] = None, authorized: Optiona
|
|
|
1366
1431
|
""", status_code=403)
|
|
1367
1432
|
|
|
1368
1433
|
|
|
1434
|
+
@app.get("/chat")
|
|
1435
|
+
async def chat_page(request: Request):
|
|
1436
|
+
return FileResponse(STATIC_DIR / "chat.html")
|
|
1437
|
+
|
|
1438
|
+
|
|
1369
1439
|
@app.get("/admin")
|
|
1370
1440
|
async def admin_page():
|
|
1371
1441
|
admin_path = STATIC_DIR / "admin.html"
|
|
@@ -0,0 +1,449 @@
|
|
|
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</title>
|
|
7
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@latest/tabler-icons.min.css">
|
|
8
|
+
<style>
|
|
9
|
+
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap');
|
|
10
|
+
|
|
11
|
+
:root {
|
|
12
|
+
--bg: #080c0a;
|
|
13
|
+
--text: #f3f1e8;
|
|
14
|
+
--faint: #7c8078;
|
|
15
|
+
--accent: #22d3a0;
|
|
16
|
+
--shadow: 0 24px 70px rgba(0,0,0,0.5);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
20
|
+
html, body { height: 100%; }
|
|
21
|
+
body {
|
|
22
|
+
font-family: Inter, -apple-system, BlinkMacSystemFont, sans-serif;
|
|
23
|
+
color: var(--text);
|
|
24
|
+
background:
|
|
25
|
+
linear-gradient(180deg, rgba(255,255,255,0.012), transparent 28%),
|
|
26
|
+
linear-gradient(135deg, #080c0a 0%, #060a08 52%, #0a0d09 100%);
|
|
27
|
+
display: flex;
|
|
28
|
+
align-items: center;
|
|
29
|
+
justify-content: center;
|
|
30
|
+
min-height: 100vh;
|
|
31
|
+
padding: 24px;
|
|
32
|
+
overflow: hidden;
|
|
33
|
+
position: relative;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
body::before {
|
|
37
|
+
content: '';
|
|
38
|
+
position: fixed;
|
|
39
|
+
inset: 0;
|
|
40
|
+
background:
|
|
41
|
+
linear-gradient(rgba(255,255,255,0.022) 1px, transparent 1px),
|
|
42
|
+
linear-gradient(90deg, rgba(255,255,255,0.016) 1px, transparent 1px);
|
|
43
|
+
background-size: 44px 44px;
|
|
44
|
+
mask-image: linear-gradient(180deg, rgba(0,0,0,0.35), rgba(0,0,0,0.06));
|
|
45
|
+
pointer-events: none;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
body::after {
|
|
49
|
+
content: '';
|
|
50
|
+
position: fixed;
|
|
51
|
+
inset: 0;
|
|
52
|
+
background:
|
|
53
|
+
radial-gradient(ellipse 70% 60% at 18% 18%, rgba(34,211,160,0.16) 0%, transparent 55%),
|
|
54
|
+
radial-gradient(ellipse 55% 50% at 82% 82%, rgba(129,140,248,0.13) 0%, transparent 55%);
|
|
55
|
+
pointer-events: none;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.orb {
|
|
59
|
+
position: fixed;
|
|
60
|
+
border-radius: 50%;
|
|
61
|
+
filter: blur(70px);
|
|
62
|
+
pointer-events: none;
|
|
63
|
+
}
|
|
64
|
+
.orb-1 { width: 440px; height: 440px; top: -170px; left: -120px; background: rgba(34,211,160,0.13); }
|
|
65
|
+
.orb-2 { width: 380px; height: 380px; bottom: -130px; right: -90px; background: rgba(129,140,248,0.11); }
|
|
66
|
+
|
|
67
|
+
.card {
|
|
68
|
+
width: min(400px, 100%);
|
|
69
|
+
background: rgba(10,14,12,0.88);
|
|
70
|
+
border: 1px solid rgba(255,255,255,0.08);
|
|
71
|
+
border-radius: 22px;
|
|
72
|
+
padding: 38px 34px;
|
|
73
|
+
box-shadow: var(--shadow), 0 0 0 1px rgba(34,211,160,0.05);
|
|
74
|
+
position: relative;
|
|
75
|
+
z-index: 1;
|
|
76
|
+
backdrop-filter: blur(28px);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
.card::before {
|
|
80
|
+
content: '';
|
|
81
|
+
position: absolute;
|
|
82
|
+
top: 0; left: 50%;
|
|
83
|
+
transform: translateX(-50%);
|
|
84
|
+
width: 55%; height: 1px;
|
|
85
|
+
background: linear-gradient(90deg, transparent, rgba(34,211,160,0.55), transparent);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
.logo {
|
|
89
|
+
width: 54px; height: 54px;
|
|
90
|
+
background: linear-gradient(135deg, #22d3a0, #818cf8);
|
|
91
|
+
border-radius: 15px;
|
|
92
|
+
display: flex; align-items: center; justify-content: center;
|
|
93
|
+
font-size: 26px; color: #040706;
|
|
94
|
+
margin: 0 auto 18px;
|
|
95
|
+
box-shadow: 0 0 32px rgba(34,211,160,0.32), 0 8px 24px rgba(0,0,0,0.4);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.title {
|
|
99
|
+
text-align: center;
|
|
100
|
+
font-size: 23px;
|
|
101
|
+
font-weight: 800;
|
|
102
|
+
margin-bottom: 6px;
|
|
103
|
+
background: linear-gradient(135deg, #fff 40%, rgba(34,211,160,0.9));
|
|
104
|
+
-webkit-background-clip: text;
|
|
105
|
+
-webkit-text-fill-color: transparent;
|
|
106
|
+
background-clip: text;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
.subtitle {
|
|
110
|
+
text-align: center;
|
|
111
|
+
color: var(--faint);
|
|
112
|
+
font-size: 12.5px;
|
|
113
|
+
margin-bottom: 26px;
|
|
114
|
+
line-height: 1.5;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
.input {
|
|
118
|
+
width: 100%;
|
|
119
|
+
padding: 12px 14px;
|
|
120
|
+
margin-bottom: 10px;
|
|
121
|
+
background: rgba(255,255,255,0.04);
|
|
122
|
+
border: 1px solid rgba(255,255,255,0.08);
|
|
123
|
+
color: var(--text);
|
|
124
|
+
border-radius: 10px;
|
|
125
|
+
outline: none;
|
|
126
|
+
font-size: 14px;
|
|
127
|
+
font-family: inherit;
|
|
128
|
+
transition: border-color .15s, box-shadow .15s;
|
|
129
|
+
}
|
|
130
|
+
.input:focus {
|
|
131
|
+
border-color: rgba(34,211,160,0.5);
|
|
132
|
+
box-shadow: 0 0 0 3px rgba(34,211,160,0.08);
|
|
133
|
+
}
|
|
134
|
+
.input::placeholder { color: var(--faint); }
|
|
135
|
+
|
|
136
|
+
.submit {
|
|
137
|
+
width: 100%;
|
|
138
|
+
padding: 13px;
|
|
139
|
+
background: linear-gradient(135deg, #22d3a0, #1ab88c);
|
|
140
|
+
color: #030d09;
|
|
141
|
+
border: none;
|
|
142
|
+
border-radius: 10px;
|
|
143
|
+
cursor: pointer;
|
|
144
|
+
font-weight: 800;
|
|
145
|
+
font-size: 14px;
|
|
146
|
+
font-family: inherit;
|
|
147
|
+
box-shadow: 0 0 20px rgba(34,211,160,0.28), 0 4px 12px rgba(0,0,0,0.3);
|
|
148
|
+
transition: all .18s;
|
|
149
|
+
margin-top: 4px;
|
|
150
|
+
}
|
|
151
|
+
.submit:hover {
|
|
152
|
+
background: linear-gradient(135deg, #2de8b0, #22d3a0);
|
|
153
|
+
box-shadow: 0 0 30px rgba(34,211,160,0.38), 0 4px 14px rgba(0,0,0,0.3);
|
|
154
|
+
transform: translateY(-1px);
|
|
155
|
+
}
|
|
156
|
+
.submit:disabled { opacity: 0.6; cursor: not-allowed; transform: none; }
|
|
157
|
+
|
|
158
|
+
.switch {
|
|
159
|
+
margin-top: 18px;
|
|
160
|
+
text-align: center;
|
|
161
|
+
font-size: 12.5px;
|
|
162
|
+
color: var(--faint);
|
|
163
|
+
}
|
|
164
|
+
.switch a { color: var(--accent); text-decoration: none; font-weight: 700; }
|
|
165
|
+
.switch a:hover { text-decoration: underline; }
|
|
166
|
+
|
|
167
|
+
.msg {
|
|
168
|
+
font-size: 12px;
|
|
169
|
+
min-height: 18px;
|
|
170
|
+
margin-bottom: 6px;
|
|
171
|
+
text-align: center;
|
|
172
|
+
color: #ffbdb8;
|
|
173
|
+
}
|
|
174
|
+
.msg.ok { color: #9be7cd; }
|
|
175
|
+
|
|
176
|
+
/* Lang picker */
|
|
177
|
+
.lang-wrap {
|
|
178
|
+
position: fixed;
|
|
179
|
+
top: 20px;
|
|
180
|
+
right: 24px;
|
|
181
|
+
z-index: 10;
|
|
182
|
+
}
|
|
183
|
+
.lang-btn {
|
|
184
|
+
background: rgba(255,255,255,0.05);
|
|
185
|
+
border: 1px solid rgba(255,255,255,0.1);
|
|
186
|
+
color: var(--text);
|
|
187
|
+
border-radius: 8px;
|
|
188
|
+
padding: 7px 12px;
|
|
189
|
+
font-size: 13px;
|
|
190
|
+
font-family: inherit;
|
|
191
|
+
cursor: pointer;
|
|
192
|
+
transition: background .15s;
|
|
193
|
+
}
|
|
194
|
+
.lang-btn:hover { background: rgba(255,255,255,0.1); }
|
|
195
|
+
.lang-menu {
|
|
196
|
+
display: none;
|
|
197
|
+
position: absolute;
|
|
198
|
+
top: calc(100% + 6px);
|
|
199
|
+
right: 0;
|
|
200
|
+
background: #141a16;
|
|
201
|
+
border: 1px solid rgba(255,255,255,0.1);
|
|
202
|
+
border-radius: 10px;
|
|
203
|
+
padding: 4px;
|
|
204
|
+
min-width: 130px;
|
|
205
|
+
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
|
|
206
|
+
}
|
|
207
|
+
.lang-menu.open { display: block; }
|
|
208
|
+
.lang-opt {
|
|
209
|
+
padding: 7px 10px;
|
|
210
|
+
border-radius: 7px;
|
|
211
|
+
cursor: pointer;
|
|
212
|
+
font-size: 13px;
|
|
213
|
+
color: var(--faint);
|
|
214
|
+
}
|
|
215
|
+
.lang-opt:hover { background: rgba(255,255,255,0.06); color: var(--text); }
|
|
216
|
+
.lang-opt.active { color: var(--accent); font-weight: 700; }
|
|
217
|
+
</style>
|
|
218
|
+
</head>
|
|
219
|
+
<body>
|
|
220
|
+
<div class="orb orb-1"></div>
|
|
221
|
+
<div class="orb orb-2"></div>
|
|
222
|
+
|
|
223
|
+
<div class="lang-wrap">
|
|
224
|
+
<button class="lang-btn" id="lang-btn" onclick="toggleLang()">🌐 <span id="lang-label">한국어</span></button>
|
|
225
|
+
<div class="lang-menu" id="lang-menu">
|
|
226
|
+
<div class="lang-opt" id="opt-ko" onclick="setLang('ko')">🇰🇷 한국어</div>
|
|
227
|
+
<div class="lang-opt" id="opt-en" onclick="setLang('en')">🇺🇸 English</div>
|
|
228
|
+
</div>
|
|
229
|
+
</div>
|
|
230
|
+
|
|
231
|
+
<div class="card">
|
|
232
|
+
<!-- Login form -->
|
|
233
|
+
<div id="login-section">
|
|
234
|
+
<div class="logo"><i class="ti ti-brain"></i></div>
|
|
235
|
+
<h2 class="title" id="login-title">Lattice AI</h2>
|
|
236
|
+
<p class="subtitle" id="login-sub">Local AI Workspace — Apple Silicon</p>
|
|
237
|
+
<input class="input" type="email" id="login-email" placeholder="이메일 주소">
|
|
238
|
+
<input class="input" type="password" id="login-pw" placeholder="비밀번호" onkeydown="if(event.key==='Enter')doLogin()">
|
|
239
|
+
<div class="msg" id="login-msg"></div>
|
|
240
|
+
<button class="submit" id="login-btn" onclick="doLogin()" data-ko="로그인" data-en="Log in">로그인</button>
|
|
241
|
+
<p class="switch" id="login-switch">
|
|
242
|
+
<span id="no-account-text">계정이 없으신가요?</span>
|
|
243
|
+
<a href="#" onclick="showSection('register');return false;" id="go-register-link">회원가입</a>
|
|
244
|
+
</p>
|
|
245
|
+
</div>
|
|
246
|
+
|
|
247
|
+
<!-- Register form -->
|
|
248
|
+
<div id="register-section" style="display:none;">
|
|
249
|
+
<div class="logo"><i class="ti ti-user-plus"></i></div>
|
|
250
|
+
<h2 class="title" id="reg-title">계정 만들기</h2>
|
|
251
|
+
<p class="subtitle" id="reg-sub">Lattice AI 워크스페이스에 참여하세요</p>
|
|
252
|
+
<input class="input" type="email" id="reg-email" placeholder="이메일 주소">
|
|
253
|
+
<input class="input" type="password" id="reg-pw" placeholder="비밀번호 (4자 이상)">
|
|
254
|
+
<input class="input" type="password" id="reg-pw2" placeholder="비밀번호 확인">
|
|
255
|
+
<input class="input" type="text" id="reg-name" placeholder="이름">
|
|
256
|
+
<input class="input" type="text" id="reg-nick" placeholder="닉네임">
|
|
257
|
+
<div class="msg" id="reg-msg"></div>
|
|
258
|
+
<button class="submit" id="reg-btn" onclick="doRegister()">가입하기</button>
|
|
259
|
+
<p class="switch" id="reg-switch">
|
|
260
|
+
<span id="have-account-text">이미 계정이 있나요?</span>
|
|
261
|
+
<a href="#" onclick="showSection('login');return false;" id="go-login-link">로그인</a>
|
|
262
|
+
</p>
|
|
263
|
+
</div>
|
|
264
|
+
</div>
|
|
265
|
+
|
|
266
|
+
<script>
|
|
267
|
+
const API_BASE = window.location.protocol === 'file:' ? 'http://localhost:4825' : '';
|
|
268
|
+
function apiFetch(path, opts = {}) { return fetch(API_BASE + path, opts); }
|
|
269
|
+
|
|
270
|
+
// ── i18n ──────────────────────────────────────────────
|
|
271
|
+
const I18N = {
|
|
272
|
+
ko: {
|
|
273
|
+
login_title: 'Lattice AI', login_sub: 'Local AI Workspace — Apple Silicon',
|
|
274
|
+
ph_email: '이메일 주소', ph_pw: '비밀번호', ph_new_pw: '비밀번호 (4자 이상)',
|
|
275
|
+
ph_pw_confirm: '비밀번호 확인', ph_name: '이름', ph_nick: '닉네임',
|
|
276
|
+
btn_login: '로그인', btn_register: '가입하기',
|
|
277
|
+
no_account: '계정이 없으신가요?', go_register: '회원가입',
|
|
278
|
+
have_account: '이미 계정이 있나요?', go_login: '로그인',
|
|
279
|
+
reg_title: '계정 만들기', reg_sub: 'Lattice AI 워크스페이스에 참여하세요',
|
|
280
|
+
err_pw_mismatch: '비밀번호가 일치하지 않습니다.',
|
|
281
|
+
err_fill: '모든 항목을 입력해주세요.',
|
|
282
|
+
err_login_fail: '이메일 또는 비밀번호가 틀렸습니다.',
|
|
283
|
+
err_server: '서버 연결 실패',
|
|
284
|
+
},
|
|
285
|
+
en: {
|
|
286
|
+
login_title: 'Lattice AI', login_sub: 'Local AI Workspace — Apple Silicon',
|
|
287
|
+
ph_email: 'Email address', ph_pw: 'Password', ph_new_pw: 'Password (min. 4 chars)',
|
|
288
|
+
ph_pw_confirm: 'Confirm password', ph_name: 'Full name', ph_nick: 'Nickname',
|
|
289
|
+
btn_login: 'Log in', btn_register: 'Sign up',
|
|
290
|
+
no_account: "Don't have an account?", go_register: 'Sign up',
|
|
291
|
+
have_account: 'Already have an account?', go_login: 'Log in',
|
|
292
|
+
reg_title: 'Create Account', reg_sub: 'Join the Lattice AI workspace',
|
|
293
|
+
err_pw_mismatch: 'Passwords do not match.',
|
|
294
|
+
err_fill: 'Please fill in all fields.',
|
|
295
|
+
err_login_fail: 'Invalid email or password.',
|
|
296
|
+
err_server: 'Server connection failed',
|
|
297
|
+
}
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
let lang = localStorage.getItem('ltcai_lang') || 'ko';
|
|
301
|
+
function t(k) { return (I18N[lang] || I18N.ko)[k] || k; }
|
|
302
|
+
|
|
303
|
+
function applyI18n() {
|
|
304
|
+
document.getElementById('login-title').textContent = t('login_title');
|
|
305
|
+
document.getElementById('login-sub').textContent = t('login_sub');
|
|
306
|
+
document.getElementById('reg-title').textContent = t('reg_title');
|
|
307
|
+
document.getElementById('reg-sub').textContent = t('reg_sub');
|
|
308
|
+
document.getElementById('login-btn').textContent = t('btn_login');
|
|
309
|
+
document.getElementById('reg-btn').textContent = t('btn_register');
|
|
310
|
+
document.getElementById('no-account-text').textContent = t('no_account');
|
|
311
|
+
document.getElementById('go-register-link').textContent = t('go_register');
|
|
312
|
+
document.getElementById('have-account-text').textContent = t('have_account');
|
|
313
|
+
document.getElementById('go-login-link').textContent = t('go_login');
|
|
314
|
+
document.getElementById('login-email').placeholder = t('ph_email');
|
|
315
|
+
document.getElementById('login-pw').placeholder = t('ph_pw');
|
|
316
|
+
document.getElementById('reg-email').placeholder = t('ph_email');
|
|
317
|
+
document.getElementById('reg-pw').placeholder = t('ph_new_pw');
|
|
318
|
+
document.getElementById('reg-pw2').placeholder = t('ph_pw_confirm');
|
|
319
|
+
document.getElementById('reg-name').placeholder = t('ph_name');
|
|
320
|
+
document.getElementById('reg-nick').placeholder = t('ph_nick');
|
|
321
|
+
document.getElementById('lang-label').textContent = lang === 'ko' ? '한국어' : 'English';
|
|
322
|
+
['ko', 'en'].forEach(l => {
|
|
323
|
+
const el = document.getElementById(`opt-${l}`);
|
|
324
|
+
if (el) el.classList.toggle('active', l === lang);
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function toggleLang() {
|
|
329
|
+
const m = document.getElementById('lang-menu');
|
|
330
|
+
m.classList.toggle('open');
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function setLang(l) {
|
|
334
|
+
lang = l;
|
|
335
|
+
localStorage.setItem('ltcai_lang', l);
|
|
336
|
+
document.getElementById('lang-menu').classList.remove('open');
|
|
337
|
+
applyI18n();
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
document.addEventListener('click', e => {
|
|
341
|
+
if (!e.target.closest('.lang-wrap'))
|
|
342
|
+
document.getElementById('lang-menu').classList.remove('open');
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
function showSection(name) {
|
|
346
|
+
document.getElementById('login-section').style.display = name === 'login' ? '' : 'none';
|
|
347
|
+
document.getElementById('register-section').style.display = name === 'register' ? '' : 'none';
|
|
348
|
+
document.getElementById('login-msg').textContent = '';
|
|
349
|
+
document.getElementById('reg-msg').textContent = '';
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function setMsg(id, text, ok = false) {
|
|
353
|
+
const el = document.getElementById(id);
|
|
354
|
+
el.textContent = text;
|
|
355
|
+
el.className = 'msg' + (ok ? ' ok' : '');
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
async function doLogin() {
|
|
359
|
+
const email = document.getElementById('login-email').value.trim();
|
|
360
|
+
const password = document.getElementById('login-pw').value;
|
|
361
|
+
if (!email || !password) { setMsg('login-msg', t('err_fill')); return; }
|
|
362
|
+
const btn = document.getElementById('login-btn');
|
|
363
|
+
btn.disabled = true;
|
|
364
|
+
btn.textContent = '...';
|
|
365
|
+
try {
|
|
366
|
+
const res = await apiFetch('/login', {
|
|
367
|
+
method: 'POST',
|
|
368
|
+
headers: { 'Content-Type': 'application/json' },
|
|
369
|
+
body: JSON.stringify({ email, password })
|
|
370
|
+
});
|
|
371
|
+
if (res.ok) {
|
|
372
|
+
const data = await res.json();
|
|
373
|
+
localStorage.setItem('ltcai_user_email', data.email);
|
|
374
|
+
localStorage.setItem('ltcai_user_nickname', data.nickname || data.name || data.email);
|
|
375
|
+
localStorage.setItem('ltcai_is_admin', data.is_admin ? 'true' : 'false');
|
|
376
|
+
window.location.href = '/chat';
|
|
377
|
+
} else {
|
|
378
|
+
const data = await res.json().catch(() => ({}));
|
|
379
|
+
setMsg('login-msg', data.detail || t('err_login_fail'));
|
|
380
|
+
btn.disabled = false;
|
|
381
|
+
btn.textContent = t('btn_login');
|
|
382
|
+
}
|
|
383
|
+
} catch {
|
|
384
|
+
setMsg('login-msg', t('err_server'));
|
|
385
|
+
btn.disabled = false;
|
|
386
|
+
btn.textContent = t('btn_login');
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
async function doRegister() {
|
|
391
|
+
const email = document.getElementById('reg-email').value.trim();
|
|
392
|
+
const pw = document.getElementById('reg-pw').value;
|
|
393
|
+
const pw2 = document.getElementById('reg-pw2').value;
|
|
394
|
+
const name = document.getElementById('reg-name').value.trim();
|
|
395
|
+
const nickname = document.getElementById('reg-nick').value.trim();
|
|
396
|
+
if (!email || !pw || !name || !nickname) { setMsg('reg-msg', t('err_fill')); return; }
|
|
397
|
+
if (pw !== pw2) { setMsg('reg-msg', t('err_pw_mismatch')); return; }
|
|
398
|
+
const btn = document.getElementById('reg-btn');
|
|
399
|
+
btn.disabled = true;
|
|
400
|
+
btn.textContent = '...';
|
|
401
|
+
try {
|
|
402
|
+
const res = await apiFetch('/register', {
|
|
403
|
+
method: 'POST',
|
|
404
|
+
headers: { 'Content-Type': 'application/json' },
|
|
405
|
+
body: JSON.stringify({ email, password: pw, name, nickname })
|
|
406
|
+
});
|
|
407
|
+
if (res.ok) {
|
|
408
|
+
setMsg('reg-msg', lang === 'ko' ? '가입 완료! 로그인 중...' : 'Registered! Logging in...', true);
|
|
409
|
+
await apiFetch('/login', {
|
|
410
|
+
method: 'POST',
|
|
411
|
+
headers: { 'Content-Type': 'application/json' },
|
|
412
|
+
body: JSON.stringify({ email, password: pw })
|
|
413
|
+
}).then(r => r.ok ? r.json() : null).then(data => {
|
|
414
|
+
if (data) {
|
|
415
|
+
localStorage.setItem('ltcai_user_email', data.email);
|
|
416
|
+
localStorage.setItem('ltcai_user_nickname', data.nickname || data.name || data.email);
|
|
417
|
+
localStorage.setItem('ltcai_is_admin', data.is_admin ? 'true' : 'false');
|
|
418
|
+
window.location.href = '/chat';
|
|
419
|
+
}
|
|
420
|
+
});
|
|
421
|
+
} else {
|
|
422
|
+
const data = await res.json().catch(() => ({}));
|
|
423
|
+
setMsg('reg-msg', data.detail || '가입 실패');
|
|
424
|
+
btn.disabled = false;
|
|
425
|
+
btn.textContent = t('btn_register');
|
|
426
|
+
}
|
|
427
|
+
} catch {
|
|
428
|
+
setMsg('reg-msg', t('err_server'));
|
|
429
|
+
btn.disabled = false;
|
|
430
|
+
btn.textContent = t('btn_register');
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// If already logged in, skip to chat
|
|
435
|
+
apiFetch('/account/profile').then(r => {
|
|
436
|
+
if (r.ok) window.location.href = '/chat';
|
|
437
|
+
}).catch(() => {});
|
|
438
|
+
|
|
439
|
+
// Handle invite code in URL
|
|
440
|
+
const urlCode = new URLSearchParams(window.location.search).get('code');
|
|
441
|
+
if (urlCode) {
|
|
442
|
+
document.getElementById('reg-email').focus();
|
|
443
|
+
showSection('register');
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
applyI18n();
|
|
447
|
+
</script>
|
|
448
|
+
</body>
|
|
449
|
+
</html>
|