commit 6333ca91b411104450b11d5cdebc78a071c08cda Author: Jay Date: Sat Apr 11 21:58:24 2026 +0900 πŸš€ Connect AI LAB v1.0.0 β€” 100% 둜컬 AI μ½”λ”© μ—μ΄μ „νŠΈ - μ—μ΄μ „νŠΈ μ½”μ–΄: 파일 생성, μ½”λ“œ νŽΈμ§‘, 터미널 μ‹€ν–‰ - 프리미엄 UI: κΈ€λž˜μŠ€λͺ¨ν”Όμ¦˜, shimmer λ‘œλ”©, ꡬ문 κ°•μ‘° - Cmd+L 포컀슀, μ½”λ“œ μ„€λͺ…(우클릭), λŒ€ν™” 내보내기 - VS Code Settings 톡합 (Ollama URL, λͺ¨λΈ, νƒ€μž„μ•„μ›ƒ) - λŒ€ν™” 기둝 영ꡬ μ €μž₯ (workspaceState) - λ©€ν‹°νŒŒμΌ ν”„λ‘œμ νŠΈ μ»¨ν…μŠ€νŠΈ μžλ™ 뢄석 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..60c4d6e --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +out/ +*.vsix +.vscode-test/ +preview.html +.DS_Store diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..85573b7 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,16 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run Extension", + "type": "extensionHost", + "request": "launch", + "args": [ + "--extensionDevelopmentPath=${workspaceFolder}" + ], + "outFiles": [ + "${workspaceFolder}/out/**/*.js" + ] + } + ] +} diff --git a/.vscodeignore b/.vscodeignore new file mode 100644 index 0000000..78ec7b1 --- /dev/null +++ b/.vscodeignore @@ -0,0 +1,18 @@ +.vscode/** +src/** +node_modules/** +!node_modules/axios/** +!node_modules/follow-redirects/** +!node_modules/form-data/** +!node_modules/proxy-from-env/** +!node_modules/mime-db/** +!node_modules/mime-types/** +!node_modules/combined-stream/** +!node_modules/delayed-stream/** +*.ts +*.map +.gitignore +tsconfig.json +*.vsix +assets/icon.svg +preview.html diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..df1bca3 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Connect AI LAB (Jay) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..fc59008 --- /dev/null +++ b/README.md @@ -0,0 +1,109 @@ +# ✦ Connect AI LAB + +**100% 둜컬 Β· 100% μ˜€ν”„λΌμΈ Β· 100% 무료** +VS Code / Cursor / Antigravityμ—μ„œ μž‘λ™ν•˜λŠ” 프리미엄 AI μ½”λ”© μ—μ΄μ „νŠΈ + +--- + +## ✨ 핡심 κΈ°λŠ₯ + +| κΈ°λŠ₯ | μ„€λͺ… | +|:--|:--| +| πŸ“ **파일 μžλ™ 생성** | "포트폴리였 μ‚¬μ΄νŠΈ λ§Œλ“€μ–΄μ€˜" β†’ 폴더/파일 μžλ™ 생성 및 에디터 μ˜€ν”ˆ | +| ✏️ **κΈ°μ‘΄ 파일 νŽΈμ§‘** | "배경색 λ°”κΏ”μ€˜" β†’ ν•΄λ‹Ή μ½”λ“œλ₯Ό μ°Ύμ•„ μ •ν™•νžˆ ꡐ체 | +| πŸ–₯️ **터미널 λͺ…λ Ή μ‹€ν–‰** | "express μ„€μΉ˜ν•΄μ€˜" β†’ `npm install express` μžλ™ μ‹€ν–‰ | +| πŸ” **ν”„λ‘œμ νŠΈ μžλ™ 뢄석** | 파일 ꡬ쑰 + 핡심 파일 λ‚΄μš©μ„ AIκ°€ μžλ™μœΌλ‘œ 읽고 이해 | +| πŸ’Ύ **λŒ€ν™” 기둝 μ €μž₯** | VS Codeλ₯Ό λ‹«μ•˜λ‹€ 열어도 이전 λŒ€ν™”κ°€ κ·ΈλŒ€λ‘œ μœ μ§€ | +| 🎨 **μ½”λ“œ ꡬ문 κ°•μ‘°** | highlight.js 기반 μ „λ¬Έ μ½”λ“œ ν•˜μ΄λΌμ΄νŒ… | + +## πŸ“₯ μ„€μΉ˜ 방법 + +### 방법 1: VSIX 파일 μ„€μΉ˜ (κ°€μž₯ 간단) + +1. [Releases](https://github.com/YOUR_REPO/releases)μ—μ„œ `.vsix` 파일 λ‹€μš΄λ‘œλ“œ +2. VS Code / Cursor / Antigravity μ—΄κΈ° +3. `Cmd+Shift+P` (Mac) λ˜λŠ” `Ctrl+Shift+P` (Windows) +4. `Extensions: Install from VSIX` 검색 β†’ λ‹€μš΄λ°›μ€ 파일 선택 +5. μ™„λ£Œ! πŸŽ‰ + +### 방법 2: μ†ŒμŠ€μ—μ„œ λΉŒλ“œ + +```bash +git clone https://github.com/YOUR_REPO/connect-ai-lab.git +cd connect-ai-lab +npm install +npm run compile +``` + +## βš™οΈ 사전 μ€€λΉ„: Ollama μ„€μΉ˜ + +Connect AI LAB은 둜컬 AI μ„œλ²„μΈ **Ollama**λ₯Ό μ‚¬μš©ν•©λ‹ˆλ‹€. + +### 1. Ollama μ„€μΉ˜ +```bash +# Mac (Homebrew) +brew install ollama + +# λ˜λŠ” 곡식 μ‚¬μ΄νŠΈμ—μ„œ λ‹€μš΄λ‘œλ“œ +# https://ollama.com +``` + +### 2. AI λͺ¨λΈ λ‹€μš΄λ‘œλ“œ +```bash +# Gemma 4 (μΆ”μ²œ, Google μ΅œμ‹  λͺ¨λΈ) +ollama pull gemma4:e2b + +# λ˜λŠ” λ‹€λ₯Έ λͺ¨λΈ +ollama pull llama3.3 +ollama pull deepseek-r1 +ollama pull codestral +``` + +### 3. Ollama μ„œλ²„ μ‹€ν–‰ +```bash +ollama serve +``` + +## πŸš€ μ‚¬μš© 방법 + +1. **폴더 μ—΄κΈ°**: `File β†’ Open Folder` β†’ ν”„λ‘œμ νŠΈ 폴더 선택 +2. **μ‚¬μ΄λ“œλ°” 클릭**: μ™Όμͺ½ λ‘œλ΄‡ μ•„μ΄μ½˜ (πŸ€–) +3. **λŒ€ν™” μ‹œμž‘**: μžμ—°μ–΄λ‘œ μš”μ²­ν•˜λ©΄ AIκ°€ μžλ™μœΌλ‘œ νŒŒμΌμ„ λ§Œλ“€κ³  νŽΈμ§‘ν•©λ‹ˆλ‹€! + +### μ˜ˆμ‹œ ν”„λ‘¬ν”„νŠΈ: +``` +κ°„λ‹¨ν•œ Express μ„œλ²„λ₯Ό λ§Œλ“€μ–΄μ€˜ +이 ν”„λ‘œμ νŠΈμ— λΌμš°ν„° μΆ”κ°€ν•΄μ€˜ +package.json의 description을 λ°”κΏ”μ€˜ +express νŒ¨ν‚€μ§€ μ„€μΉ˜ν•΄μ€˜ +``` + +## βš™οΈ μ„€μ • λ³€κ²½ + +`File > Preferences > Settings`μ—μ„œ "Connect AI LAB" 검색: + +| μ„€μ • | κΈ°λ³Έκ°’ | μ„€λͺ… | +|:--|:--|:--| +| `ollamaUrl` | `http://127.0.0.1:11434` | Ollama μ„œλ²„ μ£Όμ†Œ | +| `defaultModel` | `gemma4:e2b` | κΈ°λ³Έ AI λͺ¨λΈ | +| `maxContextFiles` | `200` | μ»¨ν…μŠ€νŠΈμ— 포함할 μ΅œλŒ€ 파일 수 | +| `requestTimeout` | `300` | AI 응닡 λŒ€κΈ° μ‹œκ°„ (초) | + +## πŸ”’ ν”„λΌμ΄λ²„μ‹œ + +- ❌ ν΄λΌμš°λ“œ μ„œλ²„ μ—†μŒ +- ❌ 데이터 μˆ˜μ§‘ μ—†μŒ +- ❌ 인터넷 μ—°κ²° λΆˆν•„μš” +- βœ… λͺ¨λ“  λ°μ΄ν„°λŠ” λ‚΄ 컴퓨터 μ•ˆμ—μ„œλ§Œ 처리 + +## 🀝 κΈ°μ—¬ + +Pull Request와 Issueλ₯Ό ν™˜μ˜ν•©λ‹ˆλ‹€! + +## πŸ“„ λΌμ΄μ„ μŠ€ + +MIT License β€” 자유둭게 μ‚¬μš©, μˆ˜μ •, 배포 κ°€λŠ₯ν•©λ‹ˆλ‹€. + +--- + +**Made with ❀️ by Connect AI LAB β€” μ—¬λŸ¬λΆ„μ˜ AI λ©˜ν†  Jay** diff --git a/assets/icon.png b/assets/icon.png new file mode 100644 index 0000000..da584e8 Binary files /dev/null and b/assets/icon.png differ diff --git a/assets/icon.svg b/assets/icon.svg new file mode 100644 index 0000000..9142b5a --- /dev/null +++ b/assets/icon.svg @@ -0,0 +1,10 @@ + + + + + + + + + ✦ + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..1282c45 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,343 @@ +{ + "name": "local-ai-coder", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "local-ai-coder", + "version": "0.0.1", + "dependencies": { + "axios": "^1.15.0" + }, + "devDependencies": { + "@types/node": "18.x", + "@types/vscode": "^1.80.0", + "typescript": "^5.1.3" + }, + "engines": { + "vscode": "^1.80.0" + } + }, + "node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/vscode": { + "version": "1.115.0", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.115.0.tgz", + "integrity": "sha512-/M8cdznOlqtMqduHKKlIF00v4eum4ZWKgn8YoPRKcN6PDdvoWeeqDaQSnw63ipDbq1Uzz78Wndk/d0uSPwORfA==", + "dev": true, + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", + "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..267054e --- /dev/null +++ b/package.json @@ -0,0 +1,126 @@ +{ + "name": "connect-ai-lab", + "displayName": "Connect AI LAB", + "description": "100% 둜컬 AI μ½”λ”© μ—μ΄μ „νŠΈ β€” 파일 생성, μ½”λ“œ νŽΈμ§‘, 터미널 싀행을 μ˜€ν”„λΌμΈμœΌλ‘œ. Ollama + Gemma/Llama/DeepSeek 지원.", + "version": "1.0.0", + "publisher": "connectailab", + "license": "MIT", + "icon": "assets/icon.png", + "engines": { + "vscode": "^1.80.0" + }, + "categories": [ + "Machine Learning", + "Programming Languages", + "Chat" + ], + "keywords": [ + "ai", + "local", + "ollama", + "gemma", + "llama", + "deepseek", + "offline", + "agent", + "code-generation", + "connect-ai-lab", + "copilot" + ], + "activationEvents": [], + "main": "./out/extension.js", + "contributes": { + "commands": [ + { + "command": "connect-ai-lab.newChat", + "title": "Connect AI LAB: New Chat", + "icon": "$(add)" + }, + { + "command": "connect-ai-lab.exportChat", + "title": "Connect AI LAB: Export Chat as Markdown" + }, + { + "command": "connect-ai-lab.explainSelection", + "title": "Connect AI LAB: Explain Selected Code" + }, + { + "command": "connect-ai-lab.focusChat", + "title": "Connect AI LAB: Focus Chat Input" + } + ], + "keybindings": [ + { + "command": "connect-ai-lab.focusChat", + "key": "cmd+l", + "mac": "cmd+l" + } + ], + "menus": { + "editor/context": [ + { + "command": "connect-ai-lab.explainSelection", + "when": "editorHasSelection", + "group": "1_modification" + } + ] + }, + "viewsContainers": { + "activitybar": [ + { + "id": "connect-ai-lab-sidebar", + "title": "Connect AI LAB", + "icon": "$(hubot)" + } + ] + }, + "views": { + "connect-ai-lab-sidebar": [ + { + "type": "webview", + "id": "local-ai-chat-view", + "name": "Chat" + } + ] + }, + "configuration": { + "title": "Connect AI LAB", + "properties": { + "connectAiLab.ollamaUrl": { + "type": "string", + "default": "http://127.0.0.1:11434", + "description": "Ollama μ„œλ²„ URL (κΈ°λ³Έκ°’: http://127.0.0.1:11434)" + }, + "connectAiLab.defaultModel": { + "type": "string", + "default": "gemma4:e2b", + "description": "κΈ°λ³Έ AI λͺ¨λΈ 이름 (예: gemma4:e2b, llama3.3, deepseek-r1)" + }, + "connectAiLab.maxContextFiles": { + "type": "number", + "default": 200, + "description": "ν”„λ‘œμ νŠΈ μ»¨ν…μŠ€νŠΈμ— 포함할 μ΅œλŒ€ 파일 수" + }, + "connectAiLab.requestTimeout": { + "type": "number", + "default": 300, + "description": "AI 응닡 λŒ€κΈ° μ‹œκ°„ (초, κΈ°λ³Έκ°’: 300초)" + } + } + } + }, + "scripts": { + "vscode:prepublish": "npm run compile", + "compile": "tsc -p ./", + "watch": "tsc -watch -p ./", + "pretest": "npm run compile" + }, + "devDependencies": { + "@types/node": "18.x", + "@types/vscode": "^1.80.0", + "typescript": "^5.1.3" + }, + "dependencies": { + "axios": "^1.15.0" + } +} diff --git a/src/extension.ts b/src/extension.ts new file mode 100644 index 0000000..ecca13a --- /dev/null +++ b/src/extension.ts @@ -0,0 +1,655 @@ +import * as vscode from 'vscode'; +import axios from 'axios'; +import * as fs from 'fs'; +import * as path from 'path'; + +// ============================================================ +// Connect AI LAB β€” Full Agentic Local AI for VS Code +// 100% Offline Β· File Create Β· File Edit Β· Terminal Β· Multi-file Context +// ============================================================ + +// Settings are read from VS Code configuration (File > Preferences > Settings) +function getConfig() { + const cfg = vscode.workspace.getConfiguration('connectAiLab'); + return { + ollamaBase: cfg.get('ollamaUrl', 'http://127.0.0.1:11434'), + defaultModel: cfg.get('defaultModel', 'gemma4:e2b'), + maxTreeFiles: cfg.get('maxContextFiles', 200), + timeout: cfg.get('requestTimeout', 300) * 1000, + }; +} + +const EXCLUDED_DIRS = new Set([ + 'node_modules', '.git', '.vscode', 'out', 'dist', 'build', + '.next', '.cache', '__pycache__', '.DS_Store', 'coverage', + '.turbo', '.nuxt', '.output', 'vendor', 'target' +]); +const MAX_CONTEXT_SIZE = 40_000; // chars + +const SYSTEM_PROMPT = `You are "Connect AI LAB", a premium agentic AI coding assistant running 100% offline on the user's machine. + +You have THREE powerful agent actions. Use them whenever appropriate: + +━━━ ACTION 1: CREATE NEW FILES ━━━ + +file content here + + +━━━ ACTION 2: EDIT EXISTING FILES ━━━ + +exact text to find in the file +replacement text + +You can have multiple / pairs inside one block. + +━━━ ACTION 3: RUN TERMINAL COMMANDS ━━━ +npm install express + +RULES: +1. ALWAYS respond in the same language the user uses. +2. Use agent actions automatically when the user's request requires creating, editing files, or running commands. +3. Outside of action blocks, briefly explain what you did. +4. For code that is just for explanation (not to be saved), use standard markdown code fences. +5. Be concise, professional, and helpful. +6. When editing files, the text must EXACTLY match existing content in the file.`; + +// ============================================================ +// Extension Activation +// ============================================================ + +export function activate(context: vscode.ExtensionContext) { + console.log('Connect AI LAB extension activated.'); + + const provider = new SidebarChatProvider(context.extensionUri, context); + context.subscriptions.push( + vscode.window.registerWebviewViewProvider('local-ai-chat-view', provider, { + webviewOptions: { retainContextWhenHidden: true } + }) + ); + + // New Chat + context.subscriptions.push( + vscode.commands.registerCommand('connect-ai-lab.newChat', () => { + provider.resetChat(); + }) + ); + + // Export Chat as Markdown + context.subscriptions.push( + vscode.commands.registerCommand('connect-ai-lab.exportChat', async () => { + await provider.exportChat(); + }) + ); + + // Focus Chat Input (Cmd+L) + context.subscriptions.push( + vscode.commands.registerCommand('connect-ai-lab.focusChat', () => { + provider.focusInput(); + }) + ); + + // Explain Selected Code (right-click menu) + context.subscriptions.push( + vscode.commands.registerCommand('connect-ai-lab.explainSelection', () => { + const editor = vscode.window.activeTextEditor; + if (!editor) { return; } + const selection = editor.document.getText(editor.selection); + if (selection.trim()) { + provider.sendPromptFromExtension(`이 μ½”λ“œλ₯Ό λΆ„μ„ν•˜κ³  μ„€λͺ…ν•΄μ€˜:\n\`\`\`\n${selection}\n\`\`\``); + } + }) + ); +} + +export function deactivate() {} + +// ============================================================ +// Sidebar Chat Provider +// ============================================================ + +class SidebarChatProvider implements vscode.WebviewViewProvider { + private _view?: vscode.WebviewView; + private _chatHistory: { role: string; content: string }[] = []; + private _terminal?: vscode.Terminal; + private _ctx: vscode.ExtensionContext; + + // λŒ€ν™” ν‘œμ‹œμš© (system prompt μ œμ™Έ, μœ μ €μ—κ²Œ 보여쀄 κ²ƒλ§Œ μ €μž₯) + private _displayMessages: { text: string; role: string }[] = []; + + constructor(private readonly _extensionUri: vscode.Uri, ctx: vscode.ExtensionContext) { + this._ctx = ctx; + this._restoreHistory(); + } + + /** μ €μž₯된 λŒ€ν™” 기둝 볡원 */ + private _restoreHistory() { + const saved = this._ctx.workspaceState.get<{ chat: any[]; display: any[] }>('chatState'); + if (saved && saved.chat && saved.chat.length > 1) { + this._chatHistory = saved.chat; + this._displayMessages = saved.display || []; + } else { + this._initHistory(); + } + } + + /** λŒ€ν™” 기둝 영ꡬ μ €μž₯ (μ›Œν¬μŠ€νŽ˜μ΄μŠ€ λ‹¨μœ„) */ + private _saveHistory() { + this._ctx.workspaceState.update('chatState', { + chat: this._chatHistory, + display: this._displayMessages + }); + } + + private _initHistory() { + this._chatHistory = [{ role: 'system', content: SYSTEM_PROMPT }]; + this._displayMessages = []; + } + + public resetChat() { + this._initHistory(); + this._saveHistory(); + if (this._view) { + this._view.webview.postMessage({ type: 'clearChat' }); + } + vscode.window.showInformationMessage('Connect AI LAB: μƒˆ λŒ€ν™”κ°€ μ‹œμž‘λ˜μ—ˆμŠ΅λ‹ˆλ‹€.'); + } + + /** λŒ€ν™”λ₯Ό Markdown 파일둜 내보내기 */ + public async exportChat() { + if (this._displayMessages.length === 0) { + vscode.window.showWarningMessage('내보낼 λŒ€ν™”κ°€ μ—†μŠ΅λ‹ˆλ‹€.'); + return; + } + let md = `# Connect AI LAB β€” λŒ€ν™” 기둝\n\n_${new Date().toLocaleString('ko-KR')}_\n\n---\n\n`; + for (const m of this._displayMessages) { + const label = m.role === 'user' ? '**πŸ‘€ You**' : '**✦ Connect AI LAB**'; + md += `### ${label}\n\n${m.text}\n\n---\n\n`; + } + const root = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + if (root) { + const filePath = path.join(root, `chat-export-${Date.now()}.md`); + fs.writeFileSync(filePath, md, 'utf-8'); + const doc = await vscode.workspace.openTextDocument(filePath); + await vscode.window.showTextDocument(doc); + vscode.window.showInformationMessage(`λŒ€ν™”κ°€ ${path.basename(filePath)}둜 μ €μž₯λ˜μ—ˆμŠ΅λ‹ˆλ‹€.`); + } + } + + /** μ±„νŒ… μž…λ ₯창에 포컀슀 (Cmd+L) */ + public focusInput() { + if (this._view) { + this._view.show?.(true); + this._view.webview.postMessage({ type: 'focusInput' }); + } + } + + /** μ™ΈλΆ€μ—μ„œ ν”„λ‘¬ν”„νŠΈ 전솑 (예: μ½”λ“œ 선택 β†’ μ„€λͺ…) */ + public sendPromptFromExtension(prompt: string) { + if (this._view) { + this._view.show?.(true); + // μ•½κ°„μ˜ λ”œλ ˆμ΄ ν›„ 전솑 (λ·°κ°€ 보이기λ₯Ό κΈ°λ‹€λ¦Ό) + setTimeout(() => { + this._view?.webview.postMessage({ type: 'injectPrompt', value: prompt }); + }, 300); + } + } + + // -------------------------------------------------------- + // Webview Lifecycle + // -------------------------------------------------------- + public resolveWebviewView( + webviewView: vscode.WebviewView, + _context: vscode.WebviewViewResolveContext, + _token: vscode.CancellationToken, + ) { + this._view = webviewView; + webviewView.webview.options = { + enableScripts: true, + localResourceRoots: [this._extensionUri], + }; + webviewView.webview.html = this._getHtml(); + + webviewView.webview.onDidReceiveMessage(async (msg) => { + switch (msg.type) { + case 'prompt': + await this._handlePrompt(msg.value, msg.model); + break; + case 'getModels': + await this._sendModels(); + break; + case 'newChat': + this.resetChat(); + break; + case 'ready': + // μ›Ήλ·°κ°€ μ€€λΉ„λ˜λ©΄ μ €μž₯된 λŒ€ν™” 기둝 볡원 + this._restoreDisplayMessages(); + break; + } + }); + } + + // -------------------------------------------------------- + // Fetch installed Ollama models + // -------------------------------------------------------- + private async _sendModels() { + if (!this._view) { return; } + const { ollamaBase, defaultModel } = getConfig(); + try { + const res = await axios.get(`${ollamaBase}/api/tags`); + const models: string[] = res.data.models.map((m: any) => m.name); + this._view.webview.postMessage({ type: 'modelsList', value: models }); + } catch { + this._view.webview.postMessage({ type: 'modelsList', value: [defaultModel] }); + } + } + + /** μ €μž₯된 λŒ€ν™” λ©”μ‹œμ§€λ₯Ό 웹뷰에 λ‹€μ‹œ 전솑 (볡원) */ + private _restoreDisplayMessages() { + if (!this._view || this._displayMessages.length === 0) { return; } + this._view.webview.postMessage({ + type: 'restoreMessages', + value: this._displayMessages + }); + } + + // -------------------------------------------------------- + // Build workspace file tree + read key files + // -------------------------------------------------------- + private _getWorkspaceContext(): string { + const root = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + if (!root) { return ''; } + + // --- 1. File tree --- + const lines: string[] = []; + let count = 0; + + const walk = (dir: string, prefix: string) => { + if (count >= getConfig().maxTreeFiles) { return; } + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { return; } + + entries.sort((a, b) => { + if (a.isDirectory() && !b.isDirectory()) { return -1; } + if (!a.isDirectory() && b.isDirectory()) { return 1; } + return a.name.localeCompare(b.name); + }); + + for (const entry of entries) { + if (count >= getConfig().maxTreeFiles) { break; } + if (EXCLUDED_DIRS.has(entry.name)) { continue; } + if (entry.name.startsWith('.') && entry.isDirectory()) { continue; } + + if (entry.isDirectory()) { + lines.push(`${prefix}πŸ“ ${entry.name}/`); + count++; + walk(path.join(dir, entry.name), prefix + ' '); + } else { + lines.push(`${prefix}πŸ“„ ${entry.name}`); + count++; + } + } + }; + walk(root, ''); + + let result = ''; + if (lines.length > 0) { + result += `\n\n[ν”„λ‘œμ νŠΈ 파일 ꡬ쑰]\n${lines.join('\n')}`; + } + + // --- 2. Auto-read key project files --- + const keyFiles = [ + 'package.json', 'tsconfig.json', 'vite.config.ts', 'vite.config.js', + 'next.config.js', 'next.config.ts', 'README.md', + 'index.html', 'app.js', 'app.ts', 'main.ts', 'main.js', + 'src/index.ts', 'src/index.js', 'src/App.tsx', 'src/App.jsx', + 'src/main.ts', 'src/main.js' + ]; + let totalRead = 0; + const MAX_AUTO_READ = 15_000; // chars total + + for (const kf of keyFiles) { + if (totalRead >= MAX_AUTO_READ) { break; } + const abs = path.join(root, kf); + if (fs.existsSync(abs)) { + try { + const content = fs.readFileSync(abs, 'utf-8'); + if (content.length < 5000) { + result += `\n\n[파일 λ‚΄μš©: ${kf}]\n\`\`\`\n${content}\n\`\`\``; + totalRead += content.length; + } + } catch { /* skip */ } + } + } + + return result; + } + + // -------------------------------------------------------- + // Handle user prompt β†’ Ollama β†’ agent actions β†’ response + // -------------------------------------------------------- + private async _handlePrompt(prompt: string, modelName: string) { + if (!this._view) { return; } + + try { + // 1. Context: active editor content + const editor = vscode.window.activeTextEditor; + let contextBlock = ''; + if (editor && editor.document.uri.scheme === 'file') { + const text = editor.document.getText(); + const name = path.basename(editor.document.fileName); + if (text.trim().length > 0 && text.length < MAX_CONTEXT_SIZE) { + contextBlock = `\n\n[Currently open file: ${name}]\n\`\`\`\n${text}\n\`\`\``; + } + } + + // 2. Context: workspace file tree + key file contents + const workspaceCtx = this._getWorkspaceContext(); + + // 3. Push user message + this._chatHistory.push({ + role: 'user', + content: prompt + contextBlock + workspaceCtx + }); + + // μ €μž₯용: μœ μ € λ©”μ‹œμ§€ 기둝 (ν”„λ‘¬ν”„νŠΈλ§Œ, μ»¨ν…μŠ€νŠΈ μ œμ™Έ) + this._displayMessages.push({ text: prompt, role: 'user' }); + + // 4. Call Ollama + const { ollamaBase, defaultModel, timeout } = getConfig(); + const response = await axios.post(`${ollamaBase}/api/chat`, { + model: modelName || defaultModel, + messages: this._chatHistory, + stream: false, + }, { timeout }); + + const aiMessage: string = response.data.message.content; + this._chatHistory.push({ role: 'assistant', content: aiMessage }); + + // 5. Execute agent actions + const report = this._executeActions(aiMessage); + + // 6. Send to webview + let output = aiMessage; + if (report.length > 0) { + output += `\n\n---\nπŸ“¦ **μ—μ΄μ „νŠΈ μž‘μ—… κ²°κ³Ό**\n${report.join('\n')}`; + } + this._view.webview.postMessage({ type: 'response', value: output }); + + // μ €μž₯용: AI 응닡 기둝 + this._displayMessages.push({ text: output, role: 'ai' }); + this._saveHistory(); + + } catch (error: any) { + const errMsg = error.code === 'ECONNREFUSED' + ? '⚠️ Ollama μ„œλ²„μ— μ—°κ²°ν•  수 μ—†μŠ΅λ‹ˆλ‹€.\nν„°λ―Έλ„μ—μ„œ `ollama serve`λ₯Ό μ‹€ν–‰ν•΄μ£Όμ„Έμš”.' + : `⚠️ 였λ₯˜: ${error.message}`; + this._view.webview.postMessage({ type: 'error', value: errMsg }); + } + } + + // -------------------------------------------------------- + // Execute ALL agent actions from AI response + // -------------------------------------------------------- + private _executeActions(aiMessage: string): string[] { + const report: string[] = []; + const rootPath = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + + if (!rootPath) { + const hasActions = /([\s\S]*?)<\/create_file>/g; + let match: RegExpExecArray | null; + let firstCreatedFile = ''; + + while ((match = createRegex.exec(aiMessage)) !== null) { + const relPath = match[1].trim(); + const content = match[2].replace(/^\n/, ''); // remove leading newline only + try { + const absPath = path.join(rootPath, relPath); + const dir = path.dirname(absPath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.writeFileSync(absPath, content, 'utf-8'); + report.push(`βœ… 생성: ${relPath}`); + if (!firstCreatedFile) { firstCreatedFile = absPath; } + } catch (err: any) { + report.push(`❌ 생성 μ‹€νŒ¨: ${relPath} β€” ${err.message}`); + } + } + + // Open first created file + if (firstCreatedFile) { + vscode.window.showTextDocument(vscode.Uri.file(firstCreatedFile), { preview: false }); + } + + // ACTION 2: Edit files + const editRegex = /([\s\S]*?)<\/edit_file>/g; + while ((match = editRegex.exec(aiMessage)) !== null) { + const relPath = match[1].trim(); + const body = match[2]; + const absPath = path.join(rootPath, relPath); + + if (!fs.existsSync(absPath)) { + report.push(`❌ νŽΈμ§‘ μ‹€νŒ¨: ${relPath} β€” 파일이 μ‘΄μž¬ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.`); + continue; + } + + try { + let fileContent = fs.readFileSync(absPath, 'utf-8'); + const findReplaceRegex = /([\s\S]*?)<\/find>\s*([\s\S]*?)<\/replace>/g; + let frMatch: RegExpExecArray | null; + let editCount = 0; + + while ((frMatch = findReplaceRegex.exec(body)) !== null) { + const findText = frMatch[1]; + const replaceText = frMatch[2]; + if (fileContent.includes(findText)) { + fileContent = fileContent.replace(findText, replaceText); + editCount++; + } else { + report.push(`⚠️ ${relPath}: μΌμΉ˜ν•˜λŠ” ν…μŠ€νŠΈλ₯Ό μ°Ύμ§€ λͺ»ν–ˆμŠ΅λ‹ˆλ‹€.`); + } + } + + if (editCount > 0) { + fs.writeFileSync(absPath, fileContent, 'utf-8'); + report.push(`✏️ νŽΈμ§‘ μ™„λ£Œ: ${relPath} (${editCount}건 μˆ˜μ •)`); + // Open edited file + vscode.window.showTextDocument(vscode.Uri.file(absPath), { preview: false }); + } + } catch (err: any) { + report.push(`❌ νŽΈμ§‘ μ‹€νŒ¨: ${relPath} β€” ${err.message}`); + } + } + + // ACTION 3: Run commands + const cmdRegex = /([\s\S]*?)<\/run_command>/g; + while ((match = cmdRegex.exec(aiMessage)) !== null) { + const cmd = match[1].trim(); + try { + if (!this._terminal || this._terminal.exitStatus !== undefined) { + this._terminal = vscode.window.createTerminal({ + name: 'πŸš€ Connect AI LAB', + cwd: rootPath + }); + } + this._terminal.show(); + this._terminal.sendText(cmd); + report.push(`πŸ–₯️ μ‹€ν–‰: ${cmd}`); + } catch (err: any) { + report.push(`❌ λͺ…λ Ή μ‹€νŒ¨: ${cmd} β€” ${err.message}`); + } + } + + // Show notification + const successCount = report.filter(r => r.startsWith('βœ…') || r.startsWith('✏️') || r.startsWith('πŸ–₯️')).length; + if (successCount > 0) { + vscode.window.showInformationMessage(`Connect AI LAB: ${successCount}개 μ—μ΄μ „νŠΈ μž‘μ—… μ™„λ£Œ!`); + } + + return report; + } + + + // ============================================================ + // Webview HTML β€” Premium UI v2 + // ============================================================ + private _getHtml(): string { + return ` + +Connect AI LAB + + +
Connect AI LAB
+
+
+ +
Connect AI LAB
+
100% \ub85c\uceec \u00b7 100% \uc624\ud504\ub77c\uc778 \u00b7 100% \ubb34\ub8cc
\ud504\ub85c\uc81d\ud2b8\ub97c \uc774\ud574\ud558\uace0, \ucf54\ub4dc\ub97c \uc791\uc131\ud558\uace0, \uc2e4\ud589\ud569\ub2c8\ub2e4.
+
\ud83d\udcc1 \ud30c\uc77c \uc0dd\uc131
\u270f\ufe0f \ucf54\ub4dc \ud3b8\uc9d1
\ud83d\udda5\ufe0f \ud130\ubbf8\ub110
\ud83d\udd0d \ubd84\uc11d
+
+ + + + +
+
+ +
+