Files
connectai/src/extension.ts
T

671 lines
36 KiB
TypeScript

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<string>('ollamaUrl', 'http://127.0.0.1:11434'),
defaultModel: cfg.get<string>('defaultModel', 'gemma4:e2b'),
maxTreeFiles: cfg.get<number>('maxContextFiles', 200),
timeout: cfg.get<number>('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 ━━━
<create_file path="relative/path/file.ext">
file content here
</create_file>
━━━ ACTION 2: EDIT EXISTING FILES ━━━
<edit_file path="relative/path/file.ext">
<find>exact text to find in the file</find>
<replace>replacement text</replace>
</edit_file>
You can have multiple <find>/<replace> pairs inside one <edit_file> block.
━━━ ACTION 3: RUN TERMINAL COMMANDS ━━━
<run_command>npm install express</run_command>
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 <find> text must EXACTLY match existing content in the file.`;
// ============================================================
// Extension Activation
// ============================================================
export function activate(context: vscode.ExtensionContext) {
vscode.window.showInformationMessage('🔥 Connect AI LAB V2 활성화 완료!');
console.log('Connect AI LAB extension activated.');
const provider = new SidebarChatProvider(context.extensionUri, context);
context.subscriptions.push(
vscode.window.registerWebviewViewProvider('connect-ai-lab-v2-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],
};
// 중요: HTML을 그리기 전에 메시지 리스너를 먼저 붙여야 Race Condition이 발생하지 않습니다!
webviewView.webview.onDidReceiveMessage(async (msg) => {
switch (msg.type) {
case 'getModels':
await this._sendModels();
break;
case 'prompt':
await this._handlePrompt(msg.value, msg.model);
break;
case 'newChat':
this.resetChat();
break;
case 'ready':
// 웹뷰가 준비되면 저장된 대화 기록 복원
this._restoreDisplayMessages();
break;
}
});
// 리스너를 붙인 후 HTML을 렌더링합니다.
webviewView.webview.html = this._getHtml();
}
// --------------------------------------------------------
// Fetch installed Ollama models
// --------------------------------------------------------
private async _sendModels() {
if (!this._view) { return; }
const { ollamaBase, defaultModel } = getConfig();
try {
const res = await axios.get(`${ollamaBase}/api/tags`, { timeout: 3000 });
let models: string[] = res.data.models.map((m: any) => m.name);
if (models.length === 0) {
models = [defaultModel];
} else if (!models.includes(defaultModel)) {
models.unshift(defaultModel);
}
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
});
// 저장용: 유저 메시지 기록 (프롬프트만)
this._displayMessages.push({ text: prompt, role: 'user' });
// 4. Call Ollama
const { ollamaBase, defaultModel, timeout } = getConfig();
// 이번 요청에만 사용할 임시 메시지 배열 생성
const reqMessages = [...this._chatHistory];
// 시스템 프롬프트(0번 인덱스)에 현재 작업 환경 정보를 주입
if (reqMessages.length > 0 && reqMessages[0].role === 'system') {
reqMessages[0] = {
role: 'system',
content: `${SYSTEM_PROMPT}\n\n[BACKGROUND CONTEXT - DO NOT EXPLAIN THIS TO THE USER UNLESS ASKED]\n${contextBlock}\n${workspaceCtx}`
};
}
const response = await axios.post(`${ollamaBase}/api/chat`, {
model: modelName || defaultModel,
messages: reqMessages,
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 = /<create_file|<edit_file|<run_command/.test(aiMessage);
if (hasActions) {
report.push('❌ 폴더가 열려있지 않습니다. File → Open Folder로 폴더를 먼저 열어주세요.');
}
return report;
}
// ACTION 1: Create files
const createRegex = /<create_file\s+path="([^"]+)">([\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 = /<edit_file\s+path="([^"]+)">([\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 = /<find>([\s\S]*?)<\/find>\s*<replace>([\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 = /<run_command>([\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
// ============================================================
// ============================================================
// Webview HTML — Premium UI v2 (Zero External Dependencies)
// ============================================================
private _getHtml(): string {
return `<!DOCTYPE html>
<html lang="ko"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Connect AI LAB</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
:root{
--bg:#111113;--bg2:#18181b;--surface:#1e1e22;--surface2:#27272b;
--border:rgba(255,255,255,.08);--border2:rgba(255,255,255,.12);
--text:#a1a1aa;--text-bright:#fafafa;--text-dim:#52525b;
--accent:#818cf8;--accent2:#c084fc;--accent-glow:rgba(129,140,248,.15);
--input-bg:#1a1a1e;--code-bg:#0c0c0e;
--green:#34d399;--yellow:#fbbf24;--cyan:#22d3ee;--red:#fb7185;
}
html,body{height:100%;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif;font-size:13px;background:var(--bg);color:var(--text);display:flex;flex-direction:column;overflow:hidden}
.header{display:flex;align-items:center;justify-content:space-between;padding:12px 16px;background:rgba(17,17,19,.85);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border-bottom:1px solid var(--border);flex-shrink:0;position:relative;z-index:10}
.header::after{content:'';position:absolute;bottom:-1px;left:0;right:0;height:1px;background:linear-gradient(90deg,transparent,var(--accent),var(--accent2),transparent);opacity:.3}
.header-left{display:flex;align-items:center;gap:10px}
.logo{width:24px;height:24px;border-radius:6px;background:linear-gradient(135deg,var(--accent),var(--accent2));display:flex;align-items:center;justify-content:center;font-size:14px;color:#fff;box-shadow:0 0 12px rgba(129,140,248,.3)}
.brand{font-weight:700;font-size:13px;color:var(--text-bright);letter-spacing:-.3px}
.header-right{display:flex;align-items:center;gap:6px}
select{background:var(--surface);color:var(--text-bright);border:1px solid var(--border2);padding:5px 10px;border-radius:6px;font-size:11px;font-family:inherit;cursor:pointer;outline:none;max-width:140px;transition:border-color .2s}
select:hover,select:focus{border-color:var(--accent)}
.btn-icon{background:var(--surface);border:1px solid var(--border2);color:var(--text-dim);width:28px;height:28px;border-radius:6px;font-size:14px;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:all .2s}
.btn-icon:hover{background:var(--surface2);color:var(--text-bright);border-color:var(--accent);box-shadow:0 0 8px var(--accent-glow)}
.chat{flex:1;overflow-y:auto;padding:20px 16px;display:flex;flex-direction:column;gap:20px}
.chat::-webkit-scrollbar{width:3px}.chat::-webkit-scrollbar-track{background:transparent}.chat::-webkit-scrollbar-thumb{background:var(--border2);border-radius:3px}
.msg{display:flex;flex-direction:column;gap:6px;animation:msgIn .3s ease-out}
.msg-head{display:flex;align-items:center;gap:8px;font-weight:600;font-size:11.5px;color:var(--text)}
.msg-time{font-weight:400;font-size:10px;color:var(--text-dim);margin-left:auto}
.av{width:22px;height:22px;border-radius:6px;display:flex;align-items:center;justify-content:center;font-size:12px;flex-shrink:0}
.av-user{background:var(--surface2);color:var(--text)}.av-ai{background:linear-gradient(135deg,var(--accent),var(--accent2));color:#fff;box-shadow:0 0 8px rgba(129,140,248,.2)}
.msg-body{padding-left:30px;line-height:1.7;color:var(--text);white-space:pre-wrap;word-break:break-word;font-size:13px}
.msg-user .msg-body{background:var(--surface);border:1px solid var(--border2);border-radius:12px;padding:10px 14px;margin-left:30px;color:var(--text-bright)}
.msg-body pre{background:var(--code-bg);border:1px solid var(--border2);border-radius:8px;padding:14px 16px;overflow-x:auto;margin:8px 0;font-size:12px;line-height:1.55;color:#adbac7}
.msg-body code{font-family:'SF Mono','Fira Code','Cascadia Code','Menlo',monospace;font-size:12px}
.msg-body :not(pre)>code{background:rgba(129,140,248,.1);color:var(--accent);padding:1px 6px;border-radius:4px;border:1px solid rgba(129,140,248,.15)}
.code-wrap{position:relative}
.code-lang{position:absolute;top:0;left:14px;background:var(--surface2);color:var(--text-dim);padding:1px 8px;border-radius:0 0 4px 4px;font-size:9px;font-family:'SF Mono',monospace;text-transform:uppercase;letter-spacing:.5px}
.copy-btn{position:absolute;top:6px;right:8px;background:var(--surface2);border:1px solid var(--border2);color:var(--text-dim);padding:3px 10px;border-radius:5px;font-size:10px;cursor:pointer;opacity:0;transition:all .2s;font-family:inherit;z-index:1}
.code-wrap:hover .copy-btn{opacity:1}.copy-btn:hover{background:var(--accent);color:#fff;border-color:var(--accent)}
.copy-btn.copied{background:var(--green);color:#fff;border-color:var(--green);opacity:1}
.file-badge{background:rgba(251,191,36,.06);border:1px solid rgba(251,191,36,.2);border-radius:8px 8px 0 0;border-bottom:none;padding:8px 14px;font-size:11px;font-weight:600;color:var(--yellow);display:flex;align-items:center;gap:6px}
.edit-badge{background:rgba(34,211,238,.06);border:1px solid rgba(34,211,238,.2);border-radius:8px 8px 0 0;border-bottom:none;padding:8px 14px;font-size:11px;font-weight:600;color:var(--cyan);display:flex;align-items:center;gap:6px}
.cmd-badge{background:rgba(129,140,248,.06);border:1px solid rgba(129,140,248,.2);border-radius:8px;padding:10px 14px;margin:8px 0;font-size:12px;color:var(--accent);font-family:'SF Mono','Menlo',monospace;display:flex;align-items:center;gap:8px}
.agent-report{background:rgba(52,211,153,.06);border:1px solid rgba(52,211,153,.2);border-radius:8px;padding:12px 14px;margin-top:8px;font-size:12px;line-height:1.7}
.msg-error .msg-body{color:var(--red)}
.welcome{text-align:center;padding:30px 20px 10px}
.welcome-logo{width:48px;height:48px;border-radius:14px;margin:0 auto 14px;background:linear-gradient(135deg,var(--accent),var(--accent2));display:flex;align-items:center;justify-content:center;font-size:26px;color:#fff;box-shadow:0 0 30px rgba(129,140,248,.25)}
.welcome-title{font-size:18px;font-weight:800;letter-spacing:-.5px;background:linear-gradient(135deg,var(--accent),var(--accent2));-webkit-background-clip:text;-webkit-text-fill-color:transparent;margin-bottom:8px}
.welcome-sub{color:var(--text-dim);font-size:12px;line-height:1.6;margin-bottom:16px}
.welcome-features{display:flex;justify-content:center;gap:16px;flex-wrap:wrap;margin-bottom:18px}
.wf{display:flex;align-items:center;gap:4px;font-size:11px;color:var(--text)}.wf-icon{font-size:14px}
.quick-actions{display:flex;flex-wrap:wrap;gap:6px;justify-content:center}
.qa-btn{background:var(--surface);border:1px solid var(--border2);color:var(--text);padding:8px 14px;border-radius:8px;font-size:11px;cursor:pointer;transition:all .2s;font-family:inherit}
.qa-btn:hover{border-color:var(--accent);color:var(--text-bright);background:var(--surface2);box-shadow:0 0 12px var(--accent-glow)}
.loading-wrap{padding-left:30px;padding-top:6px;display:flex;align-items:center;gap:8px}
.loading-bar{width:120px;height:3px;background:var(--surface2);border-radius:3px;overflow:hidden;position:relative}
.loading-bar::after{content:'';position:absolute;top:0;left:-40px;width:40px;height:100%;background:linear-gradient(90deg,transparent,var(--accent),var(--accent2),transparent);animation:shimmer 1.2s ease-in-out infinite}
.loading-text{font-size:11px;color:var(--text-dim);animation:pulse 2s ease-in-out infinite}
.input-wrap{padding:10px 16px 16px;flex-shrink:0;position:relative}
.input-box{background:var(--input-bg);border:1px solid var(--border2);border-radius:12px;padding:12px 14px;display:flex;flex-direction:column;gap:8px;transition:all .2s;position:relative}
.input-box::before{content:'';position:absolute;inset:-1px;border-radius:13px;background:linear-gradient(135deg,var(--accent),var(--accent2));opacity:0;transition:opacity .3s;z-index:-1}
.input-box:focus-within{border-color:transparent}.input-box:focus-within::before{opacity:.4}
textarea{width:100%;background:transparent;border:none;color:var(--text-bright);font-family:inherit;font-size:13px;line-height:1.5;resize:none;outline:none;min-height:22px;max-height:150px}
textarea::placeholder{color:var(--text-dim)}
.input-footer{display:flex;align-items:center;justify-content:space-between}
.input-hint{font-size:10px;color:var(--text-dim)}
.input-btns{display:flex;gap:5px}
.send-btn{background:linear-gradient(135deg,var(--accent),var(--accent2));border:none;color:#fff;width:30px;height:30px;border-radius:8px;cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:14px;transition:all .15s;box-shadow:0 2px 8px rgba(129,140,248,.25)}
.send-btn:hover{transform:translateY(-1px);box-shadow:0 4px 16px rgba(129,140,248,.35)}.send-btn:active{transform:scale(.94)}.send-btn:disabled{opacity:.25;cursor:not-allowed;transform:none;box-shadow:none}
.stop-btn{background:var(--red);border:none;color:#fff;width:30px;height:30px;border-radius:8px;cursor:pointer;display:none;align-items:center;justify-content:center;font-size:11px}
.stop-btn.visible{display:flex}
@keyframes msgIn{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:translateY(0)}}
@keyframes shimmer{0%{left:-40px}100%{left:120px}}
@keyframes pulse{0%,100%{opacity:.5}50%{opacity:1}}
</style></head><body>
<div class="header"><div class="header-left"><div class="logo">\u2726</div><span class="brand">Connect AI LAB</span></div><div class="header-right"><select id="modelSel"></select><button class="btn-icon" id="newChatBtn" title="New Chat">+</button></div></div>
<div class="chat" id="chat">
<div class="welcome">
<div class="welcome-logo">\u2726</div>
<div class="welcome-title">Connect AI LAB</div>
<div class="welcome-sub">100% \ub85c\uceec \u00b7 100% \uc624\ud504\ub77c\uc778 \u00b7 100% \ubb34\ub8cc<br>\ud504\ub85c\uc81d\ud2b8\ub97c \uc774\ud574\ud558\uace0, \ucf54\ub4dc\ub97c \uc791\uc131\ud558\uace0, \uc2e4\ud589\ud569\ub2c8\ub2e4.</div>
</div></div>
<div class="input-wrap"><div class="input-box">
<textarea id="input" rows="1" placeholder="\ubb34\uc5c7\uc744 \ub9cc\ub4e4\uc5b4 \ub4dc\ub9b4\uae4c\uc694?"></textarea>
<div class="input-footer"><span class="input-hint">Enter \uc804\uc1a1 \u00b7 Shift+Enter \uc904\ubc14\uafc8</span>
<div class="input-btns"><button class="stop-btn" id="stopBtn">\u25a0</button><button class="send-btn" id="sendBtn">\u2191</button></div></div></div></div>
<script>
try {
const vscode=acquireVsCodeApi(),chat=document.getElementById('chat'),input=document.getElementById('input'),
sendBtn=document.getElementById('sendBtn'),stopBtn=document.getElementById('stopBtn'),
modelSel=document.getElementById('modelSel'),newChatBtn=document.getElementById('newChatBtn');
let loader=null,sending=false;
vscode.postMessage({type:'getModels'});
setTimeout(()=>vscode.postMessage({type:'ready'}),300);
input.addEventListener('input',()=>{input.style.height='auto';input.style.height=Math.min(input.scrollHeight,150)+'px'});
function getTime(){return new Date().toLocaleTimeString('ko-KR',{hour:'2-digit',minute:'2-digit'})}
function esc(s){const d=document.createElement('div');d.innerText=s;return d.innerHTML}
function fmt(t){
t=t.replace(new RegExp('<create_file\\\\s+path="([^"]+)">([\\\\s\\\\S]*?)<\\\\/create_file>', 'g'),(_,p,c)=>'<div class="file-badge">\uD83D\uDCC1 '+esc(p)+' \u2014 \uC790\uB3D9 \uC0DD\uC131\uB428</div><div class="code-wrap"><pre><code>'+esc(c)+'</code></pre><button class="copy-btn" onclick="copyCode(this)">Copy</button></div>');
t=t.replace(new RegExp('<edit_file\\\\s+path="([^"]+)">([\\\\s\\\\S]*?)<\\\\/edit_file>', 'g'),(_,p,c)=>'<div class="edit-badge">\u270F\uFE0F '+esc(p)+' \u2014 \uD3B8\uC9D1\uB428</div><div class="code-wrap"><pre><code>'+esc(c)+'</code></pre><button class="copy-btn" onclick="copyCode(this)">Copy</button></div>');
t=t.replace(new RegExp('<run_command>([\\\\s\\\\S]*?)<\\\\/run_command>', 'g'),(_,c)=>'<div class="cmd-badge">\u25B6 '+esc(c)+'</div>');
t=t.replace(new RegExp('\\\\x60\\\\x60\\\\x60(\\\\w*)\\\\n([\\\\s\\\\S]*?)\\\\x60\\\\x60\\\\x60', 'g'),(_,lang,c)=>{const l=lang||'code';return '<div class="code-wrap"><span class="code-lang">'+l+'</span><pre><code>'+esc(c)+'</code></pre><button class="copy-btn" onclick="copyCode(this)">Copy</button></div>'});
t=t.replace(new RegExp('\\\\x60([^\\\\x60]+)\\\\x60', 'g'),(_,c)=>'<code>'+esc(c)+'</code>');
t=t.replace(new RegExp('\\\\*\\\\*([^*]+)\\\\*\\\\*', 'g'),'<strong>$1</strong>');
return t;
}
function copyCode(btn){const code=btn.parentElement.querySelector('code');if(!code)return;navigator.clipboard.writeText(code.innerText).then(()=>{btn.textContent='\u2713 Copied';btn.classList.add('copied');setTimeout(()=>{btn.textContent='Copy';btn.classList.remove('copied')},1500)})}
function addMsg(text,role){
const isUser=role==='user',isErr=role==='error';
const el=document.createElement('div');el.className='msg'+(isUser?' msg-user':'')+(isErr?' msg-error':'');
const head=document.createElement('div');head.className='msg-head';
head.innerHTML=(isUser?'<div class="av av-user">\ud83d\udc64</div><span>You</span>':'<div class="av av-ai">\u2726</div><span>Connect AI LAB</span>')+'<span class="msg-time">'+getTime()+'</span>';
const body=document.createElement('div');body.className='msg-body';
if(isUser){body.innerText=text}else{body.innerHTML=fmt(text)}
el.appendChild(head);el.appendChild(body);chat.appendChild(el);chat.scrollTop=chat.scrollHeight;
}
function showLoader(){loader=document.createElement('div');loader.className='msg';loader.innerHTML='<div class="msg-head"><div class="av av-ai">\u2726</div><span>Connect AI LAB</span><span class="msg-time">'+getTime()+'</span></div><div class="loading-wrap"><div class="loading-bar"></div><span class="loading-text">\uc0dd\uac01\ud558\ub294 \uc911...</span></div>';chat.appendChild(loader);chat.scrollTop=chat.scrollHeight}
function hideLoader(){if(loader&&loader.parentNode)loader.parentNode.removeChild(loader);loader=null}
function setSending(v){sending=v;sendBtn.disabled=v;stopBtn.classList.toggle('visible',v);input.disabled=v;if(!v)input.focus()}
function send(){const text=input.value.trim();if(!text||sending)return;const w=document.querySelector('.welcome');if(w)w.remove();document.querySelectorAll('.quick-actions').forEach(e=>e.remove());addMsg(text,'user');input.value='';input.style.height='auto';setSending(true);showLoader();vscode.postMessage({type:'prompt',value:text,model:modelSel.value})}
document.addEventListener('click',e=>{if(e.target.classList.contains('qa-btn')){const p=e.target.getAttribute('data-prompt');if(p){input.value=p;send()}}});
sendBtn.addEventListener('click',send);
input.addEventListener('keydown',e=>{if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();send()}});
newChatBtn.addEventListener('click',()=>vscode.postMessage({type:'newChat'}));
window.addEventListener('message',e=>{const msg=e.data;switch(msg.type){
case 'response':hideLoader();setSending(false);addMsg(msg.value,'ai');break;
case 'error':hideLoader();setSending(false);addMsg(msg.value,'error');break;
case 'modelsList':modelSel.innerHTML='';msg.value.forEach(m=>{const o=document.createElement('option');o.value=m;o.textContent=m;modelSel.appendChild(o)});break;
case 'clearChat':chat.innerHTML='';addMsg('\uc0c8 \ub300\ud654\uac00 \uc2dc\uc791\ub418\uc5c8\uc2b5\ub2c8\ub2e4.','ai');break;
case 'restoreMessages':chat.innerHTML='';if(msg.value&&msg.value.length>0){msg.value.forEach(m=>addMsg(m.text,m.role))}break;
case 'focusInput':input.focus();break;
case 'injectPrompt':input.value=msg.value;input.style.height='auto';input.style.height=Math.min(input.scrollHeight,150)+'px';send();break;
} });
} catch(err) {
document.body.innerHTML = '<div style="color:#ff4444;padding:20px;background:#111;height:100%;font-size:14px;overflow:auto;"><h2>⚠️ WEBVIEW JS CRASH</h2><pre>' + err.name + ': ' + err.message + '\\n' + err.stack + '</pre></div>';
}
</script></body></html>`;
}
}