ltcai 0.1.0 → 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
@@ -1,7 +1,12 @@
1
1
  # Lattice AI
2
2
 
3
- Local/cloud LLM workspace server with MLX, Ollama, vLLM, OpenAI-compatible providers,
4
- BYOK API keys, MCP recommendations, and editor extensions for VS Code, Cursor, and Antigravity.
3
+ Local/cloud LLM workspace server Apple Silicon MLX, OpenAI-compatible providers, MCP, VS Code/Cursor extension, Telegram bot.
4
+
5
+ ```bash
6
+ pip install ltcai # PyPI
7
+ npm install -g ltcai # npm
8
+ LTCAI # → http://localhost:4825
9
+ ```
5
10
 
6
11
  ---
7
12
 
@@ -9,26 +14,53 @@ BYOK API keys, MCP recommendations, and editor extensions for VS Code, Cursor, a
9
14
 
10
15
  ```
11
16
  Lattice AI/
12
- ├── server.py # FastAPI bridge server (port 4825)
13
- ├── llm_router.py # local/cloud model router
14
- ├── tools.py # local workspace tools
15
- ├── static/ # web UI
17
+ ├── server.py # FastAPI 브릿지 서버 (port 4825)
18
+ ├── llm_router.py # 로컬/클라우드 모델 라우터
19
+ ├── tools.py # 워크스페이스 도구 (파일, 터미널, 스크린샷 등)
20
+ ├── p_reinforce.py # P-Reinforce 지식 정원 엔진
21
+ ├── telegram_bot.py # 로컬 AI Telegram 미러 봇
22
+ ├── codex_telegram_bot.py # 클라우드 Codex Telegram 봇
23
+ ├── static/ # 웹 UI (indexd.html), 어드민 패널 (admin.html)
16
24
  ├── bin/ltcai.js # npm CLI entrypoint
17
- ├── pyproject.toml # PyPI metadata
18
- └── vscode-extension/ # VS Code/Cursor/Antigravity extension
25
+ ├── pyproject.toml # PyPI 메타데이터
26
+ └── vscode-extension/ # VS Code / Cursor / Antigravity 확장
19
27
  ```
20
28
 
21
29
  ---
22
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
+
23
55
  ## 빠른 시작
24
56
 
25
- ### 1. 서버 설치 & 실행
57
+ ### 설치 & 실행
26
58
 
27
59
  ```bash
28
- # PyPI
60
+ # PyPI (기본 — 클라우드 모델만)
29
61
  pip install ltcai
30
62
 
31
- # 로컬 MLX까지 함께 쓰려면
63
+ # PyPI (Apple Silicon MLX 포함)
32
64
  pip install "ltcai[local]"
33
65
 
34
66
  # npm
@@ -36,117 +68,214 @@ npm install -g ltcai
36
68
 
37
69
  # 서버 실행
38
70
  LTCAI
39
- # → http://localhost:4825 에서 실행됨
71
+ # → http://localhost:4825
40
72
  ```
41
73
 
42
- 개발 중에는 설치 없이도 실행할 수 있습니다.
74
+ #### 개발 모드
43
75
 
44
76
  ```bash
45
77
  python ltcai_cli.py
46
- python ltcai_cli.py --reload
47
- LTCAI doctor
78
+ python ltcai_cli.py --reload # 코드 변경 시 자동 재시작
79
+
80
+ LTCAI doctor # 의존성 및 환경 체크
48
81
  ```
49
82
 
50
- `npm install -g ltcai`로 설치한 경우 첫 실행 시 `~/.ltcai/npm-python`에 Python 가상환경을 만들고
51
- `requirements.txt`를 설치합니다. 자동 설치를 끄려면 `LTCAI_SKIP_NPM_BOOTSTRAP=1`을 설정하세요.
83
+ npm으로 설치한 경우 첫 실행 시 `~/.ltcai/npm-python`에 Python 가상환경을 자동으로 생성합니다.
84
+ 자동 설치를 끄려면 `LTCAI_SKIP_NPM_BOOTSTRAP=1`을 설정하세요.
85
+
86
+ 런타임 데이터는 기본적으로 `~/.ltcai/`에 저장됩니다. 경로 변경: `LATTICEAI_DATA_DIR=/path/to/data`
52
87
 
53
- Lattice AI stores runtime data in `~/.ltcai/` by default. Override it with
54
- `LATTICEAI_DATA_DIR=/path/to/data` when running `LTCAI`.
88
+ ---
55
89
 
56
- ### 2. 모델 로드 (터미널 or 확장 프로그램에서)
90
+ ## 로컬 모드 (Apple Silicon)
57
91
 
58
92
  ```bash
59
- # 터미널에서 직접
60
- curl -X POST http://localhost:4825/models/load \
61
- -H "Content-Type: application/json" \
62
- -d '{"model_id": "mlx-community/Qwen2.5-Coder-7B-Instruct-4bit"}'
93
+ LATTICEAI_MODE=local \
94
+ LATTICEAI_LOCAL_MODEL=mlx-community/gemma-4-26b-a4b-it-4bit \
95
+ LTCAI
63
96
  ```
64
97
 
65
- 또는 확장 프로그램에서 `Cmd+Shift+M` 모델 선택
98
+ - MLX 로컬 모델 자동 로드
99
+ - Telegram 미러 봇 활성화 가능
100
+ - 파일/터미널/스크린샷 도구 사용 가능
66
101
 
67
- ### 3. 확장 프로그램 설치
102
+ ---
103
+
104
+ ## 퍼블릭 모드 (클라우드 서버)
105
+
106
+ Render, Fly.io, Railway, VPS 등에서 운영할 때 사용합니다. MLX를 사용하지 않고 클라우드 모델로 동작합니다.
68
107
 
69
108
  ```bash
70
- cd vscode-extension
71
- npm install
72
- npm run build
73
- npm run package:vsix
109
+ LATTICEAI_MODE=public \
110
+ LATTICEAI_ALLOW_LOCAL_MODELS=false \
111
+ LATTICEAI_ENABLE_TELEGRAM=false \
112
+ LATTICEAI_PUBLIC_MODEL=openai:gpt-4o-mini \
113
+ OPENAI_API_KEY=sk-... \
114
+ LATTICEAI_INVITE_CODE=my-secret-code \
115
+ LTCAI
116
+ ```
117
+
118
+ 지원 클라우드 모델 프리픽스:
74
119
 
75
- # VS Code / Cursor / Antigravity에서:
76
- # 1. Extensions 패널 → "..." → "Install from VSIX" 또는
77
- # 2. 로컬 CLI가 있으면:
78
- npm run install:all
120
+ ```
121
+ openai:gpt-4o-mini
122
+ openrouter:openai/gpt-4o-mini
123
+ groq:llama-3.1-8b-instant
124
+ together:meta-llama/Llama-3.3-70B-Instruct-Turbo
79
125
  ```
80
126
 
81
- ---
127
+ ### Docker
128
+
129
+ ```bash
130
+ docker build -t lattice-ai .
131
+ docker run --rm -p 4825:4825 \
132
+ -e OPENAI_API_KEY="$OPENAI_API_KEY" \
133
+ -e LATTICEAI_INVITE_CODE="my-secret-code" \
134
+ -v "$PWD/.data:/data" \
135
+ lattice-ai
136
+ ```
82
137
 
83
- ## 모델/비용 구조
138
+ ### 퍼블릭 서버 체크리스트
84
139
 
85
- - Local LLM: MLX, Ollama, vLLM, LM Studio, llama.cpp
86
- - Cloud LLM: OpenAI, OpenRouter, Groq, Together, xAI OpenAI-compatible provider
87
- - API 비용: 사용자가 본인 API key를 입력하는 BYOK 구조입니다. 사용자별 키로 호출되므로 키 소유자가 사용량을 부담합니다.
88
- - 초대 링크 게이트는 기본 비활성화되어 있습니다. 다시 켜려면 `LATTICEAI_INVITE_GATE_ENABLED=true`를 설정하세요.
140
+ - `LATTICEAI_MODE=public` 설정
141
+ - 클라우드 API 설정 (`OPENAI_API_KEY`)
142
+ - `LATTICEAI_INVITE_CODE`를 비공개 값으로 설정
143
+ - `/data`에 영구 볼륨 마운트
144
+ - HTTPS 리버스 프록시 앞에 두기 (nginx, Caddy 등)
89
145
 
90
- ## 보안 기본값
146
+ ---
91
147
 
92
- - 기본 서버 바인딩은 `127.0.0.1:4825`입니다. 같은 네트워크에서 접속하게 하려면 명시적으로 `LATTICEAI_HOST=0.0.0.0`을 설정하세요.
93
- - CORS는 기본적으로 localhost만 허용합니다. 네트워크 공개가 필요하면 `LATTICEAI_CORS_ALLOW_NETWORK=true`를 명시적으로 설정하세요.
94
- - 사용자 API key는 OS keyring/Keychain에 저장합니다. keyring을 사용할 수 없는 환경에서 평문 저장을 허용하려면 `LATTICEAI_ALLOW_PLAINTEXT_API_KEYS=true`를 직접 설정해야 합니다.
95
- - 히스토리 저장 전 API key/token/password 패턴은 마스킹됩니다.
148
+ ## 모델
96
149
 
97
- ## 지원 모델 예시 (M5 32GB 기준)
150
+ ### 지원 모델 예시 (M-series Mac 기준)
98
151
 
99
152
  | 모델 | 용도 | 크기 | 추천도 |
100
153
  |------|------|------|--------|
101
- | `mlx-community/Qwen2.5-Coder-7B-Instruct-4bit` | 코딩 | ~4GB | ⭐⭐⭐ |
102
- | `mlx-community/Qwen2.5-Coder-14B-Instruct-4bit` | 코딩 | ~8GB | ⭐⭐⭐⭐ |
154
+ | `mlx-community/gemma-4-26b-a4b-it-4bit` | 범용/코딩 | ~14GB | ⭐⭐⭐⭐⭐ |
103
155
  | `mlx-community/Qwen2.5-Coder-32B-Instruct-4bit` | 코딩 | ~18GB | ⭐⭐⭐⭐⭐ |
104
- | `mlx-community/Llama-3.1-8B-Instruct-4bit` | 범용 | ~4.5GB| ⭐⭐⭐ |
105
- | `mlx-community/DeepSeek-R1-0528-4bit` | 추론 | ~38GB | ⭐⭐⭐⭐ |
106
- | `mlx-community/Phi-4-4bit` | 코딩 | ~8GB | ⭐⭐⭐⭐ |
107
- | `mlx-community/gemma-3-27b-it-4bit` | 범용 | ~15GB | ⭐⭐⭐ |
156
+ | `mlx-community/Qwen2.5-Coder-14B-Instruct-4bit` | 코딩 | ~8GB | ⭐⭐⭐⭐ |
157
+ | `mlx-community/Qwen2.5-Coder-7B-Instruct-4bit` | 코딩 | ~4GB | ⭐⭐⭐ |
158
+ | `mlx-community/DeepSeek-R1-0528-4bit` | 추론 | ~38GB | ⭐⭐⭐⭐ |
159
+ | `mlx-community/Phi-4-4bit` | 코딩 | ~8GB | ⭐⭐⭐⭐ |
160
+ | `mlx-community/Llama-3.1-8B-Instruct-4bit` | 범용 | ~4.5GB | ⭐⭐⭐ |
108
161
 
109
- > **M5 32GB 추천**: Qwen2.5-Coder-32B-Instruct-4bit (18GB) 32GB에서 여유롭게 동작
162
+ > **32GB Mac 추천**: gemma-4-26b-a4b-it-4bit — 빠르고 뛰어난 범용 성능
163
+
164
+ ### 멀티모델 핫스왑
165
+
166
+ ```bash
167
+ # 모델 로드
168
+ curl -X POST localhost:4825/models/load \
169
+ -H "Content-Type: application/json" \
170
+ -d '{"model_id": "mlx-community/Qwen2.5-Coder-14B-Instruct-4bit"}'
171
+
172
+ # 즉시 전환 (재로드 없음)
173
+ curl -X POST localhost:4825/models/switch/mlx-community%2FQwen2.5-Coder-14B-Instruct-4bit
174
+
175
+ # 언로드
176
+ curl -X DELETE localhost:4825/models/unload/mlx-community%2FQwen2.5-Coder-14B-Instruct-4bit
177
+ ```
110
178
 
111
179
  ---
112
180
 
113
- ## 멀티모델 핫스왑
181
+ ## 에디터 확장
114
182
 
115
- 여러 모델을 동시에 메모리에 올려두고 즉시 전환 가능:
183
+ | 마켓플레이스 | 링크 |
184
+ |---|---|
185
+ | VS Code / Cursor | [marketplace.visualstudio.com](https://marketplace.visualstudio.com/items?itemName=parktaesoo.ltcai) |
186
+ | Antigravity / VSCodium | [open-vsx.org](https://open-vsx.org/extension/parktaesoo/ltcai) |
116
187
 
117
- ```bash
118
- # 모델 A 로드
119
- curl -X POST localhost:4825/models/load -d '{"model_id":"mlx-community/Qwen2.5-Coder-7B-Instruct-4bit"}'
188
+ ### 수동 설치 (VSIX)
120
189
 
121
- # 모델 B도 함께 로드
122
- curl -X POST localhost:4825/models/load -d '{"model_id":"mlx-community/Llama-3.1-8B-Instruct-4bit"}'
190
+ ```bash
191
+ cd vscode-extension
192
+ npm install
193
+ npm run build
194
+ npm run package:vsix
123
195
 
124
- # B A 즉시 전환 (재로드 없음)
125
- curl -X POST localhost:4825/models/switch/mlx-community%2FQwen2.5-Coder-7B-Instruct-4bit
196
+ # 설치 (모든 에디터 번에)
197
+ npm run install:all
126
198
 
127
- # 메모리 해제
128
- curl -X DELETE localhost:4825/models/unload/mlx-community%2FLlama-3.1-8B-Instruct-4bit
199
+ # 또는 에디터에서: Extensions → "..." → "Install from VSIX"
129
200
  ```
130
201
 
131
- ---
132
-
133
- ## 키보드 단축키
202
+ ### 키보드 단축키
134
203
 
135
204
  | 단축키 | 기능 |
136
205
  |--------|------|
137
206
  | `Cmd+Shift+A` | 채팅 패널 열기 |
138
- | `Cmd+Shift+E` | 선택 코드 편집 (선택 필요) |
207
+ | `Cmd+Shift+E` | 선택 코드 편집 |
139
208
  | `Cmd+Shift+M` | 모델 로드 / 전환 |
140
- | 우클릭 메뉴 | Explain / Edit / Garden에 저장 |
209
+ | 우클릭 메뉴 | Explain / Edit / Knowledge Garden에 저장 |
210
+
211
+ ---
212
+
213
+ ## Telegram 봇
214
+
215
+ ### 1. 로컬 AI 봇 (local 모드)
216
+
217
+ 로컬 Lattice AI 서버와 대화하고 웹 채팅을 Telegram으로 미러링합니다.
218
+
219
+ ```bash
220
+ LATTICEAI_TELEGRAM_BOT_TOKEN=your-token LTCAI
221
+ ```
222
+
223
+ ### 2. Codex 클라우드 봇
224
+
225
+ Telegram에서 GPT 기반 개발 어시스턴트와 대화하고, 선택적으로 GitHub 이슈를 생성합니다.
226
+
227
+ ```bash
228
+ CODEX_TELEGRAM_BOT_TOKEN=your-token \
229
+ OPENAI_API_KEY=sk-... \
230
+ CODEX_OPENAI_MODEL=gpt-4o \
231
+ python codex_telegram_bot.py
232
+ ```
233
+
234
+ Telegram 명령어: `/start` `/reset` `/issue 제목`
235
+
236
+ 선택적으로 GitHub 이슈 연동:
237
+
238
+ ```bash
239
+ GITHUB_TOKEN=ghp-... GITHUB_REPO=owner/repo
240
+ ```
141
241
 
142
242
  ---
143
243
 
144
- ## P-Reinforce 지식 정원사
244
+ ## 보안
145
245
 
146
- 지식은 `~/.ltcai-ai-brain/`에 자동 분류 저장:
246
+ - **인증**: 모든 `/tools/*`, `/agent`, `/mcp/*`, `/local/*` 등 민감 엔드포인트는 로그인 세션 필요 (`REQUIRE_AUTH=true` 시)
247
+ - **세션**: 7일 TTL, 서버 메모리 저장 (재시작 시 로그아웃)
248
+ - **바인딩**: 기본 `127.0.0.1:4825` — 외부 접근 허용 시 명시적으로 `LATTICEAI_HOST=0.0.0.0` 설정
249
+ - **CORS**: 기본 localhost만 허용 — 외부 허용 시 `LATTICEAI_CORS_ALLOW_NETWORK=true`
250
+ - **API 키**: OS keyring/Keychain 저장 — 평문 저장 허용 시 `LATTICEAI_ALLOW_PLAINTEXT_API_KEYS=true`
251
+ - **히스토리**: 저장 전 API key/token/password 패턴 자동 마스킹
252
+ - **쿠키**: `HttpOnly + SameSite=Lax` (CSRF 방어)
253
+
254
+ ---
255
+
256
+ ## 어드민 패널
257
+
258
+ `http://localhost:4825/admin` — 관리자 계정으로 로그인 후 접근 가능
259
+
260
+ - 사용자 목록 및 역할 관리 (admin / user)
261
+ - 사용자 비활성화 / 삭제
262
+ - 대시보드 (메모리, 모델, 시스템 상태)
263
+
264
+ > **첫 번째로 가입한 계정이 자동으로 admin이 됩니다.** 서버를 처음 실행한 후 `/register` 또는 웹 UI에서 회원가입하면 됩니다. 이후 추가 admin은 어드민 패널에서 지정할 수 있습니다.
265
+ >
266
+ > 환경변수로 admin을 고정할 수도 있습니다:
267
+ > ```bash
268
+ > LATTICEAI_ADMIN_EMAILS=you@example.com LTCAI
269
+ > ```
270
+
271
+ ---
272
+
273
+ ## P-Reinforce 지식 정원
274
+
275
+ 코드/텍스트를 `~/.ltcai-brain/`에 자동 분류 저장합니다.
147
276
 
148
277
  ```
149
- ~/.ltcai-ai-brain/
278
+ ~/.ltcai-brain/
150
279
  ├── INDEX.md
151
280
  ├── 00_Raw/ # 원시 데이터, 아이디어
152
281
  ├── 10_Wiki/ # 검증된 개념, 레퍼런스
@@ -155,7 +284,15 @@ curl -X DELETE localhost:4825/models/unload/mlx-community%2FLlama-3.1-8B-Instruc
155
284
  └── 40_Log/ # 날짜별 작업 로그
156
285
  ```
157
286
 
158
- 사용법: 에디터에서 텍스트 선택 → 우클릭 → **"Save to Knowledge Garden"**
287
+ 에디터에서 텍스트 선택 → 우클릭 → **"Save to Knowledge Garden"**
288
+
289
+ 또는 API:
290
+
291
+ ```bash
292
+ curl -X POST localhost:4825/garden \
293
+ -H "Content-Type: application/json" \
294
+ -d '{"content": "학습한 내용", "category": "10_Wiki"}'
295
+ ```
159
296
 
160
297
  ---
161
298
 
@@ -165,35 +302,51 @@ curl -X DELETE localhost:4825/models/unload/mlx-community%2FLlama-3.1-8B-Instruc
165
302
  |--------|------|------|
166
303
  | GET | `/health` | 서버 상태, 현재 모델 |
167
304
  | GET | `/models` | 추천 모델 목록 + 로드 상태 |
168
- | POST | `/models/load` | 모델 로드 (캐시 지원) |
305
+ | POST | `/models/load` | 모델 로드 |
169
306
  | POST | `/models/switch/{id}` | 활성 모델 전환 |
170
307
  | DELETE | `/models/unload/{id}` | 모델 언로드 |
171
- | POST | `/chat` | 생성 (stream=true/false) |
172
- | POST | `/garden` | P-Reinforce 저장 |
308
+ | POST | `/chat` | 채팅 생성 (`stream=true/false`) |
309
+ | POST | `/agent` | 파일 생성/수정 에이전트 |
310
+ | POST | `/garden` | 지식 정원 저장 |
173
311
  | GET | `/garden/tree` | 지식 트리 조회 |
312
+ | GET | `/tools/list_dir` | 디렉토리 목록 |
313
+ | POST | `/tools/run_command` | 터미널 명령 실행 |
314
+ | GET | `/mcp/installed` | 설치된 MCP 목록 |
174
315
 
175
316
  ---
176
317
 
177
- ## 자동 시작 설정 (선택)
318
+ ## 자동 시작 (Mac)
178
319
 
179
320
  ```bash
180
- # launchd plist Mac 부팅시 자동 시작
181
- cat > ~/Library/LaunchAgents/com.ltcai.mlx.plist << 'EOF'
321
+ cat > ~/Library/LaunchAgents/com.ltcai.plist << 'EOF'
182
322
  <?xml version="1.0" encoding="UTF-8"?>
183
323
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
184
324
  <plist version="1.0">
185
325
  <dict>
186
- <key>Label</key><string>com.ltcai.mlx</string>
326
+ <key>Label</key><string>com.ltcai</string>
187
327
  <key>ProgramArguments</key>
188
328
  <array>
189
- <string>/usr/bin/python3</string>
190
- <string>/path/to/LTCAI-ai-mlx/server/server.py</string>
329
+ <string>/usr/local/bin/LTCAI</string>
191
330
  </array>
192
331
  <key>RunAtLoad</key><true/>
193
332
  <key>KeepAlive</key><true/>
333
+ <key>StandardOutPath</key><string>/tmp/ltcai.log</string>
334
+ <key>StandardErrorPath</key><string>/tmp/ltcai.err</string>
194
335
  </dict>
195
336
  </plist>
196
337
  EOF
197
338
 
198
- launchctl load ~/Library/LaunchAgents/com.ltcai.mlx.plist
339
+ launchctl load ~/Library/LaunchAgents/com.ltcai.plist
199
340
  ```
341
+
342
+ 또는 동봉된 스크립트 사용:
343
+
344
+ ```bash
345
+ ./start_ai.sh # 자동 재시작 + caffeinate (슬립 방지)
346
+ ```
347
+
348
+ ---
349
+
350
+ ## 라이선스
351
+
352
+ MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ltcai",
3
- "version": "0.1.0",
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
@@ -1241,6 +1241,62 @@ async def logout(request: Request):
1241
1241
  response.delete_cookie("session_token")
1242
1242
  return response
1243
1243
 
1244
+ class ChangePasswordRequest(BaseModel):
1245
+ current_password: str
1246
+ new_password: str
1247
+
1248
+ @app.post("/account/change-password")
1249
+ async def change_password(req: ChangePasswordRequest, request: Request):
1250
+ email = require_user(request)
1251
+ if not email:
1252
+ raise HTTPException(status_code=401, detail="인증이 필요합니다.")
1253
+ if len(req.new_password) < 4:
1254
+ raise HTTPException(status_code=400, detail="새 비밀번호는 4자 이상이어야 합니다.")
1255
+ users = load_users()
1256
+ user = users.get(email)
1257
+ if not user:
1258
+ raise HTTPException(status_code=404, detail="사용자를 찾을 수 없습니다.")
1259
+ if not verify_and_migrate_password(email, req.current_password, user.get("password", ""), users):
1260
+ raise HTTPException(status_code=401, detail="현재 비밀번호가 틀렸습니다.")
1261
+ users[email]["password"] = hash_password(req.new_password)
1262
+ save_users(users)
1263
+ return {"status": "ok", "message": "비밀번호가 변경되었습니다."}
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
+
1244
1300
  @app.get("/admin/summary")
1245
1301
  async def admin_summary(request: Request):
1246
1302
  _, users = require_admin(request)
@@ -1258,6 +1314,23 @@ async def admin_summary(request: Request):
1258
1314
  "last_message_at": last_timestamp,
1259
1315
  }
1260
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
+
1261
1334
  @app.get("/admin/users")
1262
1335
  async def admin_users(request: Request):
1263
1336
  _, users = require_admin(request)
@@ -1312,6 +1385,17 @@ async def admin_delete_user(email: str, request: Request):
1312
1385
  save_users(users)
1313
1386
  return {"status": "ok", "deleted": deleted}
1314
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
+
1315
1399
  # ── Invitation Logic ────────────────────────────────────────────────────────
1316
1400
  INVITE_CODE = env_value("LATTICEAI_INVITE_CODE", "gemma-lattice-ai")
1317
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,6 +407,102 @@
407
407
  background: rgba(255,255,255,0.04);
408
408
  }
409
409
 
410
+ .acct-modal-overlay {
411
+ display: none;
412
+ position: fixed;
413
+ inset: 0;
414
+ background: rgba(0,0,0,0.6);
415
+ backdrop-filter: blur(4px);
416
+ z-index: 1000;
417
+ align-items: center;
418
+ justify-content: center;
419
+ }
420
+ .acct-modal-overlay.open { display: flex; }
421
+ .acct-modal {
422
+ background: var(--surface, #1e293b);
423
+ border: 1px solid rgba(255,255,255,0.08);
424
+ border-radius: 16px;
425
+ width: 100%;
426
+ max-width: 380px;
427
+ display: flex;
428
+ flex-direction: column;
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;
440
+ }
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; }
445
+ .pw-field { display: flex; flex-direction: column; gap: 5px; }
446
+ .pw-field label { font-size: 11px; color: var(--muted); }
447
+ .pw-field input {
448
+ background: rgba(0,0,0,0.3);
449
+ border: 1px solid rgba(255,255,255,0.08);
450
+ border-radius: 8px;
451
+ color: var(--text, #f8fafc);
452
+ padding: 8px 12px;
453
+ font-size: 13px;
454
+ outline: none;
455
+ transition: border-color .15s;
456
+ }
457
+ .pw-field input:focus { border-color: var(--accent, #6366f1); }
458
+ .pw-actions { display: flex; gap: 8px; }
459
+ .pw-actions button {
460
+ flex: 1; padding: 8px; border-radius: 8px; border: none;
461
+ cursor: pointer; font-size: 13px; font-weight: 500; transition: all .15s;
462
+ }
463
+ .pw-cancel { background: rgba(255,255,255,0.06); color: var(--muted); }
464
+ .pw-cancel:hover { background: rgba(255,255,255,0.1); }
465
+ .pw-submit { background: var(--accent, #6366f1); color: #fff; }
466
+ .pw-submit:hover { opacity: 0.85; }
467
+ .pw-msg { font-size: 12px; min-height: 16px; }
468
+ .pw-msg.error { color: #f87171; }
469
+ .pw-msg.success { color: #4ade80; }
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
+
410
506
  .messages-viewport {
411
507
  flex: 1;
412
508
  overflow-y: auto;
@@ -2714,28 +2810,37 @@
2714
2810
  <div id="auth-overlay" class="auth-overlay">
2715
2811
  <div class="auth-orb auth-orb-1"></div>
2716
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>
2717
2820
  <div class="auth-card">
2718
2821
  <div id="login-form">
2719
2822
  <div class="auth-logo"><i class="ti ti-brain"></i></div>
2720
- <h2 class="auth-title">Lattice AI</h2>
2721
- <p class="auth-subtitle">Local AI Workspace — Apple Silicon</p>
2722
- <input class="auth-input" type="email" id="login-email" placeholder="이메일 주소">
2723
- <input class="auth-input" type="password" id="login-pw" placeholder="비밀번호">
2724
- <button class="auth-submit" onclick="handleLogin()">로그인</button>
2725
- <p class="auth-switch">계정이 없으신가요?
2726
- <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>
2727
2830
  </div>
2728
2831
  <div id="register-form" style="display: none;">
2729
2832
  <div class="auth-logo"><i class="ti ti-user-plus"></i></div>
2730
- <h2 class="auth-title">계정 만들기</h2>
2731
- <p class="auth-subtitle">Lattice AI 워크스페이스에 참여하세요</p>
2732
- <input class="auth-input" type="email" id="reg-email" placeholder="이메일 주소">
2733
- <input class="auth-input" type="password" id="reg-pw" placeholder="비밀번호">
2734
- <input class="auth-input" type="text" id="reg-name" placeholder="이름">
2735
- <input class="auth-input" type="text" id="reg-nickname" placeholder="별명">
2736
- <button class="auth-submit" onclick="handleRegister()">가입하기</button>
2737
- <p class="auth-switch">이미 계정이 있나요?
2738
- <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>
2739
2844
  </div>
2740
2845
  </div>
2741
2846
  </div>
@@ -2761,9 +2866,9 @@
2761
2866
  <!-- History items -->
2762
2867
  </div>
2763
2868
  <div class="sidebar-footer">
2764
- <button id="admin-btn" class="admin-btn" onclick="openAdminPanel()"><i class="ti ti-shield-lock"></i> 관리자 대시보드</button>
2765
- <button class="status-btn" onclick="openStatusPanel()"><i class="ti ti-info-circle"></i> 상태 보기</button>
2766
- <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>
2767
2872
  <button id="new-chat-btn" class="new-chat-btn"><i class="ti ti-plus"></i> New Chat</button>
2768
2873
  </div>
2769
2874
  </aside>
@@ -2782,10 +2887,63 @@
2782
2887
  </div>
2783
2888
  <div class="header-pills">
2784
2889
  <div class="status-pill"><i class="ti ti-device-desktop"></i> Local</div>
2785
- <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>
2786
2899
  </div>
2787
2900
  </header>
2788
2901
 
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>
2907
+ </div>
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>
2943
+ </div>
2944
+ </div>
2945
+ </div>
2946
+
2789
2947
  <section class="ops-strip" aria-label="workspace status">
2790
2948
  <div class="ops-card primary interactive" onclick="openModelPanel()">
2791
2949
  <div>
@@ -2798,16 +2956,16 @@
2798
2956
  <div class="ops-card interactive" onclick="openVpcPanel()">
2799
2957
  <div>
2800
2958
  <div class="ops-label">PRIVATE VPC</div>
2801
- <div id="ops-vpc" class="ops-value">설정 안 됨</div>
2802
- <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>
2803
2961
  </div>
2804
2962
  <div class="ops-icon"><i class="ti ti-cloud-lock"></i></div>
2805
2963
  </div>
2806
2964
  <div class="ops-card interactive" onclick="openAdminPanel()">
2807
2965
  <div>
2808
2966
  <div class="ops-label">SECURITY</div>
2809
- <div class="ops-value">민감정보 감시</div>
2810
- <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>
2811
2969
  </div>
2812
2970
  <div class="ops-icon"><i class="ti ti-shield-check"></i></div>
2813
2971
  </div>
@@ -2816,12 +2974,12 @@
2816
2974
  <div class="messages-viewport" id="chat-viewport">
2817
2975
  <div class="empty-state" id="empty-state">
2818
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>
2819
- <h1>무엇을 만들까요?</h1>
2820
- <p>로컬 모델, 이미지 분석, 코드 생성, 프라이빗 VPC — 모든 걸 한 화면에서 이어가세요.</p>
2977
+ <h1 data-i18n="empty_title">무엇을 만들까요?</h1>
2978
+ <p data-i18n="empty_sub">로컬 모델, 이미지 분석, 코드 생성, 프라이빗 VPC — 모든 걸 한 화면에서 이어가세요.</p>
2821
2979
  <div class="empty-grid">
2822
- <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>
2823
- <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>
2824
- <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>
2825
2983
  </div>
2826
2984
  </div>
2827
2985
  </div>
@@ -2844,16 +3002,16 @@
2844
3002
  <i class="ti ti-paperclip" style="font-size: 20px;"></i>
2845
3003
  <input type="file" id="doc-input" accept=".pdf,.docx,.xlsx,.pptx,.txt,.md,.csv" hidden onchange="attachDocument(this)">
2846
3004
  </label>
2847
- <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>
2848
3006
  <button class="send-btn" id="send-btn"><i class="ti ti-send"></i></button>
2849
3007
  </div>
2850
3008
  <div class="file-toolbar">
2851
- <span class="file-toolbar-label">파일 만들기</span>
3009
+ <span class="file-toolbar-label" data-i18n="create_file">파일 만들기</span>
2852
3010
  <button class="file-type-btn" onclick="openFileCreate('docx')"><i class="ti ti-file-word"></i> DOCX</button>
2853
3011
  <button class="file-type-btn" onclick="openFileCreate('xlsx')"><i class="ti ti-file-spreadsheet"></i> XLSX</button>
2854
3012
  <button class="file-type-btn" onclick="openFileCreate('pptx')"><i class="ti ti-presentation"></i> PPTX</button>
2855
3013
  <button class="file-type-btn" onclick="openFileCreate('pdf')"><i class="ti ti-file-type-pdf"></i> PDF</button>
2856
- <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>
2857
3015
  </div>
2858
3016
  </div>
2859
3017
  </div>
@@ -2864,8 +3022,8 @@
2864
3022
  <section class="model-panel">
2865
3023
  <div class="model-panel-header">
2866
3024
  <div>
2867
- <h2>모델 스위처</h2>
2868
- <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>
2869
3027
  </div>
2870
3028
  <button class="admin-close" onclick="closeModelPanel()"><i class="ti ti-x"></i></button>
2871
3029
  </div>
@@ -2877,12 +3035,12 @@
2877
3035
  <div id="perm-overlay" class="perm-overlay" style="display:none">
2878
3036
  <div class="perm-dialog">
2879
3037
  <div class="perm-icon"><i class="ti ti-shield-lock"></i></div>
2880
- <div class="perm-title" id="perm-title">파일 접근 요청</div>
3038
+ <div class="perm-title" id="perm-title" data-i18n="perm_title">파일 접근 요청</div>
2881
3039
  <div class="perm-path" id="perm-path"></div>
2882
3040
  <div class="perm-desc" id="perm-desc"></div>
2883
3041
  <div class="perm-actions">
2884
- <button class="perm-deny-btn" onclick="resolvePermission(false)">거부</button>
2885
- <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>
2886
3044
  </div>
2887
3045
  </div>
2888
3046
  </div>
@@ -3211,17 +3369,36 @@
3211
3369
  }
3212
3370
 
3213
3371
  async function handleRegister() {
3214
- const email = document.getElementById('reg-email').value;
3372
+ const email = document.getElementById('reg-email').value.trim();
3215
3373
  const password = document.getElementById('reg-pw').value;
3216
- const name = document.getElementById('reg-name').value;
3217
- const nickname = document.getElementById('reg-nickname').value;
3218
- const res = await apiFetch('/register', {
3219
- method: 'POST',
3220
- headers: { 'Content-Type': 'application/json' },
3221
- body: JSON.stringify({ email, password, name, nickname })
3222
- });
3223
- if (res.ok) { alert("가입 완료! 로그인 해주세요."); toggleAuth(false); }
3224
- 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 = '가입하기'; }
3225
3402
  }
3226
3403
 
3227
3404
  async function handleLogin() {
@@ -3262,6 +3439,242 @@
3262
3439
  location.reload();
3263
3440
  }
3264
3441
 
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'}`;
3542
+ }
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');
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
+
3559
+ document.addEventListener('click', (e) => {
3560
+ if (!e.target.closest('.lang-picker')) {
3561
+ document.querySelectorAll('.lang-picker-menu').forEach(m => m.classList.remove('open'));
3562
+ }
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
+ }
3633
+ async function submitPwChange() {
3634
+ const cur = document.getElementById('pw-cur').value;
3635
+ const nw = document.getElementById('pw-new').value;
3636
+ const nw2 = document.getElementById('pw-new2').value;
3637
+ const msg = document.getElementById('pw-msg');
3638
+ const btn = document.getElementById('pw-submit-btn');
3639
+ if (!cur || !nw || !nw2) {
3640
+ msg.textContent = '모든 항목을 입력해주세요.';
3641
+ msg.className = 'pw-msg error';
3642
+ return;
3643
+ }
3644
+ if (nw !== nw2) {
3645
+ msg.textContent = '새 비밀번호가 일치하지 않습니다.';
3646
+ msg.className = 'pw-msg error';
3647
+ return;
3648
+ }
3649
+ if (nw.length < 4) {
3650
+ msg.textContent = '새 비밀번호는 4자 이상이어야 합니다.';
3651
+ msg.className = 'pw-msg error';
3652
+ return;
3653
+ }
3654
+ btn.disabled = true; btn.textContent = '변경 중...';
3655
+ try {
3656
+ const res = await fetch('/account/change-password', {
3657
+ method: 'POST',
3658
+ headers: { 'Content-Type': 'application/json' },
3659
+ body: JSON.stringify({ current_password: cur, new_password: nw })
3660
+ });
3661
+ const data = await res.json();
3662
+ if (res.ok) {
3663
+ msg.textContent = '✅ 비밀번호가 변경되었습니다.';
3664
+ msg.className = 'pw-msg success';
3665
+ setTimeout(closeAcctModal, 1500);
3666
+ } else {
3667
+ msg.textContent = data.detail || '변경 실패';
3668
+ msg.className = 'pw-msg error';
3669
+ }
3670
+ } catch {
3671
+ msg.textContent = '서버 연결 실패';
3672
+ msg.className = 'pw-msg error';
3673
+ } finally {
3674
+ btn.disabled = false; btn.textContent = '변경';
3675
+ }
3676
+ }
3677
+
3265
3678
  function adminHeaders() {
3266
3679
  return {
3267
3680
  'Content-Type': 'application/json',
@@ -5132,6 +5545,7 @@
5132
5545
  window.addEventListener('focus', tryClipboardReadFallback);
5133
5546
 
5134
5547
  document.getElementById('new-chat-btn').onclick = startNewChat;
5548
+ applyI18n();
5135
5549
  loadModelStatus();
5136
5550
  loadVpcStatus();
5137
5551
  restoreCurrentConversation();