ltcai 0.1.3 → 0.1.8

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.
@@ -0,0 +1,57 @@
1
+ # Skill: <name>
2
+
3
+ ## 메타데이터
4
+ - **버전**: 0.1.0
5
+ - **카테고리**: [coding | data | document | web | system | analysis]
6
+ - **위험도**: [low | medium | high] <!-- low=읽기, medium=쓰기, high=실행/삭제 -->
7
+ - **필요 권한**: [none | local_read | local_write | exec | admin]
8
+
9
+ ## 설명
10
+ 한 줄 설명.
11
+
12
+ ## 입력 스키마
13
+ ```json
14
+ {
15
+ "required": ["field1"],
16
+ "optional": ["field2"],
17
+ "properties": {
18
+ "field1": { "type": "string", "description": "..." },
19
+ "field2": { "type": "integer", "default": 10 }
20
+ }
21
+ }
22
+ ```
23
+
24
+ ## 출력 스키마
25
+ ```json
26
+ {
27
+ "success": true,
28
+ "result": "...",
29
+ "artifacts": []
30
+ }
31
+ ```
32
+
33
+ ## 실행 조건
34
+ - 사전 조건 (예: 모델 로드 필요, 파일 존재 여부)
35
+ - 후처리 조건
36
+
37
+ ## 예제
38
+
39
+ ### 성공 케이스
40
+ **입력**: `{ "field1": "예시값" }`
41
+ **출력**: `{ "success": true, "result": "..." }`
42
+
43
+ ### 실패 케이스
44
+ **입력**: `{ "field1": "" }`
45
+ **출력**: `{ "success": false, "error": "field1 is required" }`
46
+
47
+ ## 실패 처리
48
+ | 에러 코드 | 원인 | 처리 방법 |
49
+ |-----------|------|-----------|
50
+ | `INVALID_INPUT` | 필수 필드 누락 | 입력 검증 후 재시도 |
51
+ | `PERMISSION_DENIED` | 권한 없음 | 관리자에게 문의 |
52
+ | `TIMEOUT` | 실행 시간 초과 | 작업 분할 후 재시도 |
53
+
54
+ ## 테스트 케이스
55
+ ```python
56
+ # tests/skills/test_<name>.py 참조
57
+ ```
@@ -0,0 +1,76 @@
1
+ # Skill: code_review
2
+
3
+ ## 메타데이터
4
+ - **버전**: 0.1.0
5
+ - **카테고리**: coding
6
+ - **위험도**: low
7
+ - **필요 권한**: local_read
8
+
9
+ ## 설명
10
+ 파일 또는 코드 스니펫을 LLM에 전달해 버그, 보안 이슈, 성능, 스타일을 리뷰한다.
11
+
12
+ ## 입력 스키마
13
+ ```json
14
+ {
15
+ "required": ["target"],
16
+ "optional": ["focus", "lang", "max_lines"],
17
+ "properties": {
18
+ "target": { "type": "string", "description": "절대 파일 경로 또는 코드 스니펫 문자열" },
19
+ "focus": { "type": "array", "items": { "type": "string", "enum": ["bug", "security", "performance", "style"] }, "default": ["bug", "security"], "description": "리뷰 초점 목록" },
20
+ "lang": { "type": "string", "description": "언어 힌트 (python, js, go …). 미입력시 자동 감지" },
21
+ "max_lines": { "type": "integer", "default": 500, "description": "분석할 최대 줄 수" }
22
+ }
23
+ }
24
+ ```
25
+
26
+ ## 출력 스키마
27
+ ```json
28
+ {
29
+ "success": true,
30
+ "result": {
31
+ "summary": "...",
32
+ "issues": [
33
+ { "severity": "high|medium|low", "line": 42, "category": "security", "message": "..." }
34
+ ],
35
+ "score": 85
36
+ }
37
+ }
38
+ ```
39
+
40
+ ## 실행 조건
41
+ - LLM 모델이 로드되어 있어야 함 (`/mode` 응답의 `model` 필드 비어있지 않음)
42
+ - 파일 대상인 경우 파일이 존재해야 함
43
+
44
+ ## 예제
45
+
46
+ ### 성공 케이스
47
+ **입력**: `{ "target": "~/project/server.py", "focus": ["security"] }`
48
+ **출력**:
49
+ ```json
50
+ {
51
+ "success": true,
52
+ "result": {
53
+ "summary": "1개의 심각한 보안 이슈 발견",
54
+ "issues": [{ "severity": "high", "line": 102, "category": "security", "message": "SQL 쿼리에 사용자 입력이 직접 삽입됨" }],
55
+ "score": 60
56
+ }
57
+ }
58
+ ```
59
+
60
+ ### 실패 케이스
61
+ **입력**: `{ "target": "" }`
62
+ **출력**: `{ "success": false, "error": "INVALID_INPUT", "message": "target is required" }`
63
+
64
+ ## 실패 처리
65
+ | 에러 코드 | 원인 | 처리 방법 |
66
+ |-----------|------|-----------|
67
+ | `INVALID_INPUT` | target 비어 있음 | 파일 경로 또는 코드 입력 |
68
+ | `FILE_NOT_FOUND` | 파일 경로 존재하지 않음 | 경로 확인 |
69
+ | `MODEL_NOT_LOADED` | LLM 미로드 | `/model` 명령으로 모델 선택 |
70
+ | `SIZE_LIMIT` | max_lines 초과 파일 | max_lines 값 조정 또는 파일 분할 |
71
+
72
+ ## 테스트 케이스
73
+ ```python
74
+ # tests/unit/test_tools.py::test_code_review_snippet
75
+ # tests/integration/test_api.py::test_agent_code_review_file
76
+ ```
@@ -0,0 +1,79 @@
1
+ # Skill: data_analysis
2
+
3
+ ## 메타데이터
4
+ - **버전**: 0.1.0
5
+ - **카테고리**: data
6
+ - **위험도**: low
7
+ - **필요 권한**: local_read
8
+
9
+ ## 설명
10
+ CSV/Excel/JSON 파일을 읽어 기초 통계, 컬럼 요약, 이상치 탐지를 수행하고 인사이트를 제공한다.
11
+
12
+ ## 입력 스키마
13
+ ```json
14
+ {
15
+ "required": ["path"],
16
+ "optional": ["columns", "analysis_type", "max_rows"],
17
+ "properties": {
18
+ "path": { "type": "string", "description": "분석할 파일 절대 경로 (.csv, .xlsx, .json)" },
19
+ "columns": { "type": "array", "items": { "type": "string" }, "description": "분석할 컬럼 목록. 미입력시 전체" },
20
+ "analysis_type": { "type": "array", "items": { "type": "string", "enum": ["summary", "outlier", "correlation", "trend"] }, "default": ["summary"], "description": "수행할 분석 유형" },
21
+ "max_rows": { "type": "integer", "default": 10000, "description": "처리할 최대 행 수" }
22
+ }
23
+ }
24
+ ```
25
+
26
+ ## 출력 스키마
27
+ ```json
28
+ {
29
+ "success": true,
30
+ "result": {
31
+ "shape": [100, 5],
32
+ "columns": ["col1", "col2"],
33
+ "summary": { "col1": { "mean": 42.0, "std": 3.1, "min": 10, "max": 99 } },
34
+ "outliers": { "col1": [99, 10] },
35
+ "insights": "..."
36
+ }
37
+ }
38
+ ```
39
+
40
+ ## 실행 조건
41
+ - pandas, openpyxl 패키지 설치 필요 (requirements.txt 포함)
42
+ - 파일이 존재해야 하며 .csv/.xlsx/.json 형식이어야 함
43
+
44
+ ## 예제
45
+
46
+ ### 성공 케이스
47
+ **입력**: `{ "path": "~/data/sales.csv", "analysis_type": ["summary", "outlier"] }`
48
+ **출력**:
49
+ ```json
50
+ {
51
+ "success": true,
52
+ "result": {
53
+ "shape": [500, 4],
54
+ "columns": ["date", "revenue", "units", "region"],
55
+ "summary": { "revenue": { "mean": 15000.0, "std": 4200.0, "min": 200, "max": 89000 } },
56
+ "outliers": { "revenue": [89000] },
57
+ "insights": "revenue 컬럼에서 1개의 이상치(89000) 발견. 평균 대비 17.6 표준편차."
58
+ }
59
+ }
60
+ ```
61
+
62
+ ### 실패 케이스
63
+ **입력**: `{ "path": "~/data/photo.png" }`
64
+ **출력**: `{ "success": false, "error": "UNSUPPORTED_FORMAT", "message": "Supported formats: csv, xlsx, json" }`
65
+
66
+ ## 실패 처리
67
+ | 에러 코드 | 원인 | 처리 방법 |
68
+ |-----------|------|-----------|
69
+ | `INVALID_INPUT` | path 누락 | 파일 경로 입력 |
70
+ | `FILE_NOT_FOUND` | 경로에 파일 없음 | 경로 확인 |
71
+ | `UNSUPPORTED_FORMAT` | csv/xlsx/json 이외 형식 | 지원 형식으로 변환 후 재시도 |
72
+ | `PARSE_ERROR` | 파일 파싱 실패 | 파일 인코딩/형식 확인 |
73
+ | `SIZE_LIMIT` | max_rows 초과 | max_rows 값 조정 |
74
+
75
+ ## 테스트 케이스
76
+ ```python
77
+ # tests/unit/test_tools.py::test_data_analysis_csv_summary
78
+ # tests/unit/test_tools.py::test_data_analysis_unsupported_format
79
+ ```
@@ -0,0 +1,68 @@
1
+ # Skill: file_edit
2
+
3
+ ## 메타데이터
4
+ - **버전**: 0.1.0
5
+ - **카테고리**: system
6
+ - **위험도**: medium
7
+ - **필요 권한**: local_write
8
+
9
+ ## 설명
10
+ 로컬 파일을 읽고 특정 범위를 편집한 뒤 저장한다. 원본 백업을 `.bak` 파일로 생성한다.
11
+
12
+ ## 입력 스키마
13
+ ```json
14
+ {
15
+ "required": ["path", "new_content"],
16
+ "optional": ["start_line", "end_line", "backup"],
17
+ "properties": {
18
+ "path": { "type": "string", "description": "절대 경로 또는 ~/로 시작하는 경로" },
19
+ "new_content": { "type": "string", "description": "교체할 새 내용" },
20
+ "start_line": { "type": "integer", "description": "편집 시작 줄 번호 (1-indexed). 없으면 전체 교체" },
21
+ "end_line": { "type": "integer", "description": "편집 종료 줄 번호 (포함). 없으면 start_line 단일 줄" },
22
+ "backup": { "type": "boolean", "default": true, "description": "false면 .bak 파일 생성 생략" }
23
+ }
24
+ }
25
+ ```
26
+
27
+ ## 출력 스키마
28
+ ```json
29
+ {
30
+ "success": true,
31
+ "result": {
32
+ "path": "...",
33
+ "lines_changed": 3,
34
+ "backup_path": "....bak"
35
+ }
36
+ }
37
+ ```
38
+
39
+ ## 실행 조건
40
+ - 대상 파일이 존재해야 함
41
+ - BINARY_EXTS(png, jpg, zip 등) 파일은 거부
42
+ - 파일 크기 10 MB 이하
43
+
44
+ ## 예제
45
+
46
+ ### 성공 케이스
47
+ **입력**: `{ "path": "~/project/config.py", "new_content": "DEBUG = False\n", "start_line": 5, "end_line": 5 }`
48
+ **출력**: `{ "success": true, "result": { "path": "...", "lines_changed": 1, "backup_path": "...config.py.bak" } }`
49
+
50
+ ### 실패 케이스
51
+ **입력**: `{ "path": "~/photo.png", "new_content": "..." }`
52
+ **출력**: `{ "success": false, "error": "BINARY_FILE", "message": "Binary files cannot be edited as text" }`
53
+
54
+ ## 실패 처리
55
+ | 에러 코드 | 원인 | 처리 방법 |
56
+ |-----------|------|-----------|
57
+ | `INVALID_INPUT` | path 또는 new_content 누락 | 필드 확인 후 재시도 |
58
+ | `FILE_NOT_FOUND` | 경로에 파일 없음 | 경로 확인 |
59
+ | `BINARY_FILE` | 바이너리 파일 편집 시도 | 텍스트 파일만 지원 |
60
+ | `PERMISSION_DENIED` | 파일 쓰기 권한 없음 | 관리자 권한 확인 |
61
+ | `SIZE_LIMIT` | 파일 10 MB 초과 | 파일 분할 후 재시도 |
62
+
63
+ ## 테스트 케이스
64
+ ```python
65
+ # tests/unit/test_tools.py::test_file_edit_full_replace
66
+ # tests/unit/test_tools.py::test_file_edit_line_range
67
+ # tests/unit/test_tools.py::test_file_edit_binary_rejected
68
+ ```
@@ -0,0 +1,74 @@
1
+ # Skill: web_search
2
+
3
+ ## 메타데이터
4
+ - **버전**: 0.1.0
5
+ - **카테고리**: web
6
+ - **위험도**: low
7
+ - **필요 권한**: none
8
+
9
+ ## 설명
10
+ 외부 검색 엔진(DuckDuckGo/Brave)을 통해 웹 검색을 수행하고 상위 결과를 반환한다.
11
+
12
+ ## 입력 스키마
13
+ ```json
14
+ {
15
+ "required": ["query"],
16
+ "optional": ["num_results", "lang"],
17
+ "properties": {
18
+ "query": { "type": "string", "description": "검색어" },
19
+ "num_results": { "type": "integer", "default": 5, "description": "반환할 결과 수 (1-20)" },
20
+ "lang": { "type": "string", "default": "ko-KR", "description": "검색 언어 로케일" }
21
+ }
22
+ }
23
+ ```
24
+
25
+ ## 출력 스키마
26
+ ```json
27
+ {
28
+ "success": true,
29
+ "result": {
30
+ "query": "...",
31
+ "results": [
32
+ { "title": "...", "url": "...", "snippet": "..." }
33
+ ]
34
+ }
35
+ }
36
+ ```
37
+
38
+ ## 실행 조건
39
+ - 네트워크 연결 필요
40
+ - 외부 API 키 불필요 (DuckDuckGo instant answer API 사용)
41
+
42
+ ## 예제
43
+
44
+ ### 성공 케이스
45
+ **입력**: `{ "query": "FastAPI 비동기 처리", "num_results": 3 }`
46
+ **출력**:
47
+ ```json
48
+ {
49
+ "success": true,
50
+ "result": {
51
+ "query": "FastAPI 비동기 처리",
52
+ "results": [
53
+ { "title": "FastAPI - Async", "url": "https://fastapi.tiangolo.com/async/", "snippet": "..." }
54
+ ]
55
+ }
56
+ }
57
+ ```
58
+
59
+ ### 실패 케이스
60
+ **입력**: `{ "query": "" }`
61
+ **출력**: `{ "success": false, "error": "INVALID_INPUT", "message": "query is required" }`
62
+
63
+ ## 실패 처리
64
+ | 에러 코드 | 원인 | 처리 방법 |
65
+ |-----------|------|-----------|
66
+ | `INVALID_INPUT` | query 비어 있음 | 검색어 입력 후 재시도 |
67
+ | `NETWORK_ERROR` | 외부 API 연결 실패 | 잠시 후 재시도 |
68
+ | `TIMEOUT` | 5초 초과 | 검색어 단순화 후 재시도 |
69
+
70
+ ## 테스트 케이스
71
+ ```python
72
+ # tests/unit/test_tools.py::test_web_search_returns_results
73
+ # tests/integration/test_api.py::test_agent_web_search
74
+ ```
@@ -2,8 +2,13 @@
2
2
  <html lang="ko">
3
3
  <head>
4
4
  <meta charset="UTF-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
6
6
  <title>Lattice AI</title>
7
+ <link rel="manifest" href="/manifest.json">
8
+ <meta name="theme-color" content="#2d5a3d">
9
+ <meta name="apple-mobile-web-app-capable" content="yes">
10
+ <link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
11
+ <link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32.png">
7
12
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@latest/tabler-icons.min.css">
8
13
  <style>
9
14
  @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap');
@@ -164,6 +169,34 @@
164
169
  .switch a { color: var(--accent); text-decoration: none; font-weight: 700; }
165
170
  .switch a:hover { text-decoration: underline; }
166
171
 
172
+ .sso-divider {
173
+ display: flex; align-items: center; gap: 10px;
174
+ margin: 14px 0 10px;
175
+ color: var(--faint); font-size: 11.5px;
176
+ }
177
+ .sso-divider::before, .sso-divider::after {
178
+ content: ''; flex: 1;
179
+ height: 1px; background: rgba(255,255,255,0.07);
180
+ }
181
+ .sso-btn {
182
+ width: 100%;
183
+ padding: 12px;
184
+ background: rgba(255,255,255,0.04);
185
+ border: 1px solid rgba(255,255,255,0.1);
186
+ color: var(--text);
187
+ border-radius: 10px;
188
+ cursor: pointer;
189
+ font-weight: 600;
190
+ font-size: 13.5px;
191
+ font-family: inherit;
192
+ display: flex; align-items: center; justify-content: center; gap: 8px;
193
+ transition: all .18s;
194
+ }
195
+ .sso-btn:hover {
196
+ background: rgba(255,255,255,0.08);
197
+ border-color: rgba(34,211,160,0.3);
198
+ }
199
+
167
200
  .msg {
168
201
  font-size: 12px;
169
202
  min-height: 18px;
@@ -238,6 +271,13 @@
238
271
  <input class="input" type="password" id="login-pw" placeholder="비밀번호" onkeydown="if(event.key==='Enter')doLogin()">
239
272
  <div class="msg" id="login-msg"></div>
240
273
  <button class="submit" id="login-btn" onclick="doLogin()" data-ko="로그인" data-en="Log in">로그인</button>
274
+ <div id="sso-section" style="display:none;">
275
+ <div class="sso-divider" id="sso-divider-text">또는</div>
276
+ <button class="sso-btn" id="sso-btn" onclick="doSSOLogin()">
277
+ <i class="ti ti-building"></i>
278
+ <span id="sso-btn-label">SSO로 로그인</span>
279
+ </button>
280
+ </div>
241
281
  <p class="switch" id="login-switch">
242
282
  <span id="no-account-text">계정이 없으신가요?</span>
243
283
  <a href="#" onclick="showSection('register');return false;" id="go-register-link">회원가입</a>
@@ -265,7 +305,12 @@
265
305
 
266
306
  <script>
267
307
  const API_BASE = window.location.protocol === 'file:' ? 'http://localhost:4825' : '';
268
- function apiFetch(path, opts = {}) { return fetch(API_BASE + path, opts); }
308
+ function apiFetch(path, opts = {}) {
309
+ const headers = { ...(opts.headers || {}) };
310
+ const token = localStorage.getItem('ltcai_session_token') || '';
311
+ if (token && !headers.Authorization) headers.Authorization = `Bearer ${token}`;
312
+ return fetch(API_BASE + path, { credentials: 'include', ...opts, headers });
313
+ }
269
314
 
270
315
  // ── i18n ──────────────────────────────────────────────
271
316
  const I18N = {
@@ -281,6 +326,7 @@
281
326
  err_fill: '모든 항목을 입력해주세요.',
282
327
  err_login_fail: '이메일 또는 비밀번호가 틀렸습니다.',
283
328
  err_server: '서버 연결 실패',
329
+ sso_divider: '또는', sso_btn: '로 로그인',
284
330
  },
285
331
  en: {
286
332
  login_title: 'Lattice AI', login_sub: 'Local AI Workspace — Apple Silicon',
@@ -294,6 +340,7 @@
294
340
  err_fill: 'Please fill in all fields.',
295
341
  err_login_fail: 'Invalid email or password.',
296
342
  err_server: 'Server connection failed',
343
+ sso_divider: 'or', sso_btn: 'Sign in with',
297
344
  }
298
345
  };
299
346
 
@@ -319,12 +366,33 @@
319
366
  document.getElementById('reg-name').placeholder = t('ph_name');
320
367
  document.getElementById('reg-nick').placeholder = t('ph_nick');
321
368
  document.getElementById('lang-label').textContent = lang === 'ko' ? '한국어' : 'English';
369
+ document.getElementById('sso-divider-text').textContent = t('sso_divider');
370
+ const ssoName = window._ssoProviderName || 'SSO';
371
+ document.getElementById('sso-btn-label').textContent =
372
+ lang === 'ko' ? `${ssoName}${t('sso_btn')}` : `${t('sso_btn')} ${ssoName}`;
322
373
  ['ko', 'en'].forEach(l => {
323
374
  const el = document.getElementById(`opt-${l}`);
324
375
  if (el) el.classList.toggle('active', l === lang);
325
376
  });
326
377
  }
327
378
 
379
+ async function initSSO() {
380
+ try {
381
+ const res = await apiFetch('/auth/sso/config');
382
+ if (!res.ok) return;
383
+ const cfg = await res.json();
384
+ if (cfg.enabled) {
385
+ window._ssoProviderName = cfg.provider_name;
386
+ document.getElementById('sso-section').style.display = '';
387
+ applyI18n();
388
+ }
389
+ } catch {}
390
+ }
391
+
392
+ function doSSOLogin() {
393
+ window.location.href = '/auth/sso/login';
394
+ }
395
+
328
396
  function toggleLang() {
329
397
  const m = document.getElementById('lang-menu');
330
398
  m.classList.toggle('open');
@@ -373,6 +441,7 @@
373
441
  localStorage.setItem('ltcai_user_email', data.email);
374
442
  localStorage.setItem('ltcai_user_nickname', data.nickname || data.name || data.email);
375
443
  localStorage.setItem('ltcai_is_admin', data.is_admin ? 'true' : 'false');
444
+ if (data.token) localStorage.setItem('ltcai_session_token', data.token);
376
445
  window.location.href = '/chat';
377
446
  } else {
378
447
  const data = await res.json().catch(() => ({}));
@@ -415,6 +484,7 @@
415
484
  localStorage.setItem('ltcai_user_email', data.email);
416
485
  localStorage.setItem('ltcai_user_nickname', data.nickname || data.name || data.email);
417
486
  localStorage.setItem('ltcai_is_admin', data.is_admin ? 'true' : 'false');
487
+ if (data.token) localStorage.setItem('ltcai_session_token', data.token);
418
488
  window.location.href = '/chat';
419
489
  }
420
490
  });
@@ -436,6 +506,8 @@
436
506
  if (r.ok) window.location.href = '/chat';
437
507
  }).catch(() => {});
438
508
 
509
+ initSSO();
510
+
439
511
  // Handle invite code in URL
440
512
  const urlCode = new URLSearchParams(window.location.search).get('code');
441
513
  if (urlCode) {