# 로컬 LLM 채팅 페이지 — 상세 계획 및 코드 ## 개요 ExperionCrawler SPA에 **탭 #13: 로컬 LLM 채팅**을 통합한다. Ollama 로컬 LLM과 연결하여 폐쇄 네트워크 환경에서 채팅 UI를 제공. - **기술**: 순수 HTML/CSS/JS (빌드 없음) + C# ASP.NET Core 백엔드 프록시 - **LLM 백엔드**: Ollama (`http://localhost:11434`) - **스트리밍**: SSE (Server-Sent Events) - **저장**: localStorage (서버 DB 없음) --- ## 변경 파일 목록 | 파일 | 작업 | |------|------| | `src/Web/Controllers/OllamaController.cs` | 신규 생성 | | `src/Web/Program.cs` | HttpClient 등록 추가 | | `src/Web/wwwroot/index.html` | 탭 #13 + `
` 추가 | | `src/Web/wwwroot/css/style.css` | 채팅 UI 스타일 추가 | | `src/Web/wwwroot/js/app.js` | 채팅 JS 로직 추가 | --- ## 1. 백엔드 — OllamaController.cs (신규) **경로**: `src/Web/Controllers/OllamaController.cs` ```csharp using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.Configuration; using System.Text; using System.Text.Json; namespace ExperionCrawler.Web.Controllers; [ApiController] [Route("api/ollama")] public class OllamaController : ControllerBase { private readonly HttpClient _httpClient; private readonly IConfiguration _config; private readonly ILogger _logger; public OllamaController( HttpClient httpClient, IConfiguration config, ILogger logger) { _httpClient = httpClient; _config = config; _logger = logger; } string OllamaConfigPath { get { var mcpDir = _config["McpServer:WorkingDirectory"] ?? "../../mcp-server"; if (!Path.IsPathRooted(mcpDir)) mcpDir = Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), mcpDir)); return Path.Combine(mcpDir, "ollama-config.json"); } } OllamaConfig LoadConfig() { try { var path = OllamaConfigPath; if (File.Exists(path)) { var json = File.ReadAllText(path); return JsonSerializer.Deserialize(json) ?? new OllamaConfig(); } } catch (Exception ex) { _logger.LogWarning(ex, "[OllamaController] 설정 로드 실패, 기본값 사용"); } return new OllamaConfig(); } // ── 모델 목록 ────────────────────────────────────────── [HttpGet("models")] public async Task GetModels() { try { var cfg = LoadConfig(); var res = await _httpClient.GetAsync($"{cfg.BaseUrl}/api/tags"); if (!res.IsSuccessStatusCode) return Ok(new { success = false, error = $"Ollama HTTP {(int)res.StatusCode}", models = Array.Empty() }); var body = await res.Content.ReadAsStringAsync(); var doc = JsonDocument.Parse(body); var models = new List(); if (doc.RootElement.TryGetProperty("models", out var arr)) { foreach (var m in arr.EnumerateArray()) { if (m.TryGetProperty("name", out var name)) models.Add(name.GetString() ?? ""); } } return Ok(new { success = true, models = [.. models.OrderBy(x => x)] }); } catch (Exception ex) { _logger.LogError(ex, "[OllamaController] 모델 조회 실패"); return Ok(new { success = false, error = ex.Message, models = Array.Empty() }); } } // ── 일반 채팅 (비스트리밍) ───────────────────────────── [HttpPost("chat")] public async Task Chat([FromBody] OllamaChatRequest req) { try { var cfg = LoadConfig(); var payload = new { model = req.Model, messages = req.Messages, system = req.SystemPrompt, stream = false }; var content = new StringContent( JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"); var res = await _httpClient.PostAsync($"{cfg.BaseUrl}/api/chat", content); if (!res.IsSuccessStatusCode) { var err = await res.Content.ReadAsStringAsync(); return Ok(new { success = false, error = err }); } var body = await res.Content.ReadAsStringAsync(); var doc = JsonDocument.Parse(body); string? reply = null; if (doc.RootElement.TryGetProperty("message", out var msg) && msg.TryGetProperty("content", out var cnt)) { reply = cnt.GetString(); } return Ok(new { success = true, reply }); } catch (Exception ex) { _logger.LogError(ex, "[OllamaController] 채팅 실패"); return Ok(new { success = false, error = ex.Message, reply = (string?)null }); } } // ── 스트리밍 채팅 (SSE) ─────────────────────────────── [HttpPost("chat/stream")] public async Task OllamaChatStream([FromBody] OllamaChatRequest req) { Response.Headers.Append("Content-Type", "text/event-stream"); Response.Headers.Append("Cache-Control", "no-cache"); Response.Headers.Append("Connection", "keep-alive"); Response.Headers.Append("X-Accel-Buffering", "no"); try { var cfg = LoadConfig(); var payload = new { model = req.Model, messages = req.Messages, system = req.SystemPrompt, stream = true }; var content = new StringContent( JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"); var httpRequest = new HttpRequestMessage(HttpMethod.Post, $"{cfg.BaseUrl}/api/chat") { Content = content }; var res = await _httpClient.SendAsync(httpRequest, HttpCompletionOption.ResponseHeadersRead, HttpContext.RequestAborted); if (!res.IsSuccessStatusCode) { await Response.WriteAsync($"event: error\ndata: {res.StatusCode}\n\n"); await Response.Body.FlushAsync(); return; } var stream = await res.Content.ReadAsStreamAsync(); var buffer = new byte[1024]; var sb = new StringBuilder(); int read; while ((read = await stream.ReadAsync(buffer, 0, buffer.Length, HttpContext.RequestAborted)) > 0) { if (HttpContext.RequestAborted.IsCancellationRequested) break; sb.Append(Encoding.UTF8.GetString(buffer, 0, read)); var lines = sb.ToString().Split('\n'); sb.Clear(); for (int i = 0; i < lines.Length; i++) { var line = lines[i]; // NDJSON: 각 줄이独立的 JSON 객체 if (string.IsNullOrWhiteSpace(line)) { // 빈 줄 = NDJSON 레코드 구분자 await Response.Body.FlushAsync(); continue; } // 마지막 줄이 불완전하면 sb에 재저장 if (i == lines.Length - 1 && sb.Length == 0 && lines.Length > 1) { // OK } else if (i == lines.Length - 1) { // 마지막 라인일 수 있으므로 체크 sb.Append(line); continue; } try { var doc = JsonDocument.Parse(line); var json = line; // 원본 JSON 그대로 전달 await Response.WriteAsync($"event: message\ndata: {json}\n\n"); await Response.Body.FlushAsync(); } catch { sb.Append(line); } } } // 남은 데이터 처리 if (sb.Length > 0) { try { await Response.WriteAsync($"event: message\ndata: {sb}\n\n"); } catch { } } await Response.WriteAsync("event: done\ndata: {}\n\n"); await Response.Body.FlushAsync(); } catch (OperationCanceledException) { // 클라이언트 연결 끊김 — 정상 종료 } catch (Exception ex) { _logger.LogError(ex, "[OllamaController] 스트리밍 오류"); try { await Response.WriteAsync($"event: error\ndata: {JsonSerializer.Serialize(new { message = ex.Message })}\n\n"); await Response.Body.FlushAsync(); } catch { } } } // ── 설정 조회/저장 ───────────────────────────────────── [HttpGet("config")] public IActionResult GetConfig() { var cfg = LoadConfig(); return Ok(new { success = true, host = cfg.Host, port = cfg.Port, baseUrl = cfg.BaseUrl }); } [HttpPost("config")] public IActionResult SetConfig([FromBody] OllamaConfig cfg) { try { var path = OllamaConfigPath; var dir = Path.GetDirectoryName(path); if (!Directory.Exists(dir)) Directory.CreateDirectory(dir); var json = JsonSerializer.Serialize(new { host = cfg.Host, port = cfg.Port }, new JsonSerializerOptions { WriteIndented = true }); File.WriteAllText(path, json); _logger.LogInformation("[OllamaController] 설정 저장: {Host}:{Port}", cfg.Host, cfg.Port); return Ok(new { success = true, host = cfg.Host, port = cfg.Port, baseUrl = cfg.BaseUrl }); } catch (Exception ex) { _logger.LogError(ex, "[OllamaController] 설정 저장 실패"); return Ok(new { success = false, error = ex.Message }); } } // ── 연결 테스트 ──────────────────────────────────────── [HttpGet("ping")] public async Task Ping() { try { var cfg = LoadConfig(); var res = await _httpClient.GetAsync($"{cfg.BaseUrl}/api/tags"); return Ok(new { success = res.IsSuccessStatusCode, host = cfg.Host, port = cfg.Port }); } catch (Exception ex) { return Ok(new { success = false, error = ex.Message }); } } } // ── DTOs ───────────────────────────────────────────────────────────────────── public class OllamaConfig { public string Host { get; set; } = "localhost"; public int Port { get; set; } = 11434; public string BaseUrl => $"http://{Host}:{Port}"; } public class OllamaChatRequest { public string Model { get; set; } = ""; public OllamaMessage[] Messages { get; set; } = Array.Empty(); public string? SystemPrompt { get; set; } } public class OllamaMessage { public string Role { get; set; } = ""; public string Content { get; set; } = ""; } ``` --- ## 2. Program.cs 변경 **추가 위치**: `McpClient` 등록 직후 (91줄 근처) ```csharp // ── Ollama HttpClient ─────────────────────────────────────────────────────── builder.Services.AddHttpClient(_ => new Uri("http://localhost:11434")) .SetHandlerLifetime(Timeout.InfiniteTimeSpan); builder.Services.AddSingleton(sp => sp.GetRequiredService().CreateClient("Ollama")); ``` `OllamaController`에 HttpClient를 주입받게 하려면 인터페이스가 필요: **`src/Core/Application/Interfaces/IOllamaHttpClient.cs` (신규, 빈 인터페이스)**: ```csharp namespace ExperionCrawler.Core.Application.Interfaces; // HttpClient 용 마커 인터페이스 (DI 용) public interface IOllamaHttpClient { } ``` 실제로는 `OllamaController` 생성자에 `HttpClient`를 직접 받고, Program.cs에서: ```csharp builder.Services.AddHttpClient("Ollama", c => { c.BaseAddress = new Uri("http://localhost:11434"); c.Timeout = TimeSpan.FromSeconds(1800); }) .SetHandlerLifetime(Timeout.InfiniteTimeSpan); ``` 그리고 `OllamaController` 생성자에서 `IHttpClientFactory` 사용: ```csharp public OllamaController( IHttpClientFactory httpClientFactory, IConfiguration config, ILogger logger) { _httpClient = httpClientFactory.CreateClient("Ollama"); _config = config; _logger = logger; } ``` --- ## 3. index.html 변경 ### 3.1 Sidebar — 탭 추가 **위치**: `
  • ` 직후 (82줄 근처) ```html
  • ``` ### 3.2 Main — `
    ` 추가 **위치**: `
    ` (pane-evt 종료) 직후, `` 직전 (1216줄 근처) ```html

    로컬 LLM 채팅

    로컬 Ollama 서버에 연결하여 LLM과 대화합니다.

    LLM / CHAT
    대화 목록
    대화가 없습니다. + 버튼을 눌러 새 대화를 시작하세요.
    💬
    새 대화를 시작하세요
    모델을 선택하고 메시지를 입력하세요
    ``` --- ## 4. CSS — style.css 추가 **파일**: `src/Web/wwwroot/css/style.css` — 파일 끝에 추가 ```css /* ═══════════════════════════════════════════════════════ 로컬 LLM 채팅 ══════════════════════════════════════════════════════ */ .llm-layout { display: flex; gap: 0; height: calc(100vh - var(--sw) - 140px); min-height: 400px; border: 1px solid var(--bd); border-radius: var(--r); overflow: hidden; background: var(--s1); } /* ── 왼쪽 사이드바 ──────────────────────────────────── */ .llm-sidebar { width: 200px; flex-shrink: 0; background: var(--s2); border-right: 1px solid var(--bd); display: flex; flex-direction: column; } .llm-sidebar-header { display: flex; justify-content: space-between; align-items: center; padding: 10px 12px; border-bottom: 1px solid var(--bd); } .llm-sidebar-title { font-size: 12px; font-weight: 700; color: var(--t1); } .llm-session-list { flex: 1; overflow-y: auto; padding: 6px; } .llm-session-item { padding: 8px 10px; border-radius: 6px; cursor: pointer; font-size: 12px; color: var(--t2); transition: all var(--tr); margin-bottom: 2px; display: flex; justify-content: space-between; align-items: center; gap: 6px; } .llm-session-item:hover { background: var(--s3); color: var(--t1); } .llm-session-item.active { background: var(--ag); color: var(--a); } .llm-session-item .llm-sess-title { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .llm-session-item .llm-sess-del { opacity: 0; color: var(--red); font-size: 14px; transition: opacity var(--tr); flex-shrink: 0; } .llm-session-item:hover .llm-sess-del { opacity: 1; } .llm-sidebar-footer { padding: 8px; border-top: 1px solid var(--bd); } .llm-empty { padding: 20px 12px; font-size: 11px; color: var(--t2); text-align: center; line-height: 1.6; } /* ── 메인 영역 ──────────────────────────────────────── */ .llm-main { flex: 1; display: flex; flex-direction: column; min-width: 0; } /* 상단 바 */ .llm-header { display: flex; justify-content: space-between; align-items: flex-end; padding: 10px 14px; border-bottom: 1px solid var(--bd); background: var(--s2); gap: 10px; } .llm-header-left, .llm-header-right { display: flex; align-items: flex-end; gap: 8px; } .llm-conn-dot { font-size: 12px; color: var(--t2); padding-bottom: 6px; } .llm-conn-dot.connected { color: var(--grn); } .llm-conn-dot.error { color: var(--red); } /* 설정 패널 */ .llm-settings { background: var(--s3); border-bottom: 1px solid var(--bd); padding: 12px 14px; } /* 메시지 영역 */ .llm-messages { flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 12px; } .llm-welcome { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; color: var(--t2); gap: 8px; } .llm-welcome-icon { font-size: 40px; opacity: 0.3; } .llm-welcome-text { font-size: 14px; font-weight: 600; } .llm-welcome-hint { font-size: 12px; } /* 메시지 버블 */ .llm-msg { display: flex; gap: 10px; max-width: 85%; animation: llm-fade-in 0.2s ease; } @keyframes llm-fade-in { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } } .llm-msg.user { align-self: flex-end; flex-direction: row-reverse; } .llm-msg.assistant { align-self: flex-start; } .llm-msg-avatar { width: 28px; height: 28px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 700; flex-shrink: 0; margin-top: 2px; } .llm-msg.user .llm-msg-avatar { background: var(--a); color: #fff; } .llm-msg.assistant .llm-msg-avatar { background: var(--s4); color: var(--t1); } .llm-msg-bubble { padding: 10px 14px; border-radius: var(--r); font-size: 13px; line-height: 1.6; word-break: break-word; white-space: pre-wrap; } .llm-msg.user .llm-msg-bubble { background: var(--a); color: #fff; border-bottom-right-radius: 2px; } .llm-msg.assistant .llm-msg-bubble { background: var(--s3); color: var(--t0); border: 1px solid var(--bd); border-bottom-left-radius: 2px; } .llm-msg-bubble code { background: rgba(0,0,0,0.3); padding: 1px 5px; border-radius: 3px; font-family: var(--fm); font-size: 12px; } .llm-msg-bubble pre { background: rgba(0,0,0,0.4); padding: 10px 12px; border-radius: 6px; overflow-x: auto; margin: 8px 0; font-family: var(--fm); font-size: 12px; line-height: 1.5; } .llm-msg-bubble pre code { background: none; padding: 0; } /* 타이핑 인디케이터 */ .llm-typing { display: flex; gap: 4px; padding: 4px 0; } .llm-typing span { width: 6px; height: 6px; border-radius: 50%; background: var(--t2); animation: llm-bounce 1.2s infinite; } .llm-typing span:nth-child(2) { animation-delay: 0.2s; } .llm-typing span:nth-child(3) { animation-delay: 0.4s; } @keyframes llm-bounce { 0%, 60%, 100% { transform: translateY(0); opacity: 0.4; } 30% { transform: translateY(-6px); opacity: 1; } } /* ── 입력 영역 ──────────────────────────────────────── */ .llm-input-area { padding: 12px 14px; border-top: 1px solid var(--bd); background: var(--s2); } .llm-input-box { display: flex; align-items: flex-end; gap: 8px; } .llm-textarea { flex: 1; background: var(--s3); border: 1px solid var(--bd); border-radius: var(--r); padding: 10px 14px; color: var(--t0); font-family: var(--ff); font-size: 13px; resize: none; outline: none; line-height: 1.5; max-height: 150px; transition: border-color var(--tr); } .llm-textarea:focus { border-color: var(--a); } .llm-textarea::placeholder { color: var(--t2); } .llm-input-btns { display: flex; gap: 4px; flex-shrink: 0; } /* ── 반응형 ─────────────────────────────────────────── */ @media (max-width: 768px) { .llm-sidebar { display: none; } .llm-layout { height: calc(100vh - var(--sw) - 120px); } } ``` --- ## 5. JavaScript — app.js 추가 **파일**: `src/Web/wwwroot/js/app.js` — 파일 끝에 추가 ```javascript /* ═══════════════════════════════════════════════════════ 13 로컬 LLM 채팅 ══════════════════════════════════════════════════════ */ // ── 상태 ─────────────────────────────────────────────── let llmSessions = JSON.parse(localStorage.getItem('llmSessions') || '[]'); let llmActiveSessionId = localStorage.getItem('llmActiveSessionId') || ''; let llmAbortController = null; let llmIsStreaming = false; // ── 초기화 ───────────────────────────────────────────── document.querySelectorAll('[data-tab="llmchat"]').forEach(item => { item.addEventListener('click', () => { llmLoadModels(); llmRenderSessionList(); llmLoadActiveSession(); llmLoadConfigToUI(); }); }); // ── 세션 관리 ────────────────────────────────────────── function llmCreateSession() { const id = 'llm_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8); const session = { id, title: '새 대화', model: '', systemPrompt: '', messages: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }; llmSessions.unshift(session); llmActiveSessionId = id; llmSaveSessions(); llmRenderSessionList(); llmRenderMessages(); return session; } function llmNewSession() { llmCreateSession(); document.getElementById('llm-input').focus(); } function llmSwitchSession(id) { llmActiveSessionId = id; localStorage.setItem('llmActiveSessionId', id); llmRenderSessionList(); llmRenderMessages(); } function llmDeleteSession(id, e) { e.stopPropagation(); if (!confirm('이 대화를 삭제하시겠습니까?')) return; llmSessions = llmSessions.filter(s => s.id !== id); llmSaveSessions(); if (llmActiveSessionId === id) { llmActiveSessionId = llmSessions.length > 0 ? llmSessions[0].id : ''; localStorage.setItem('llmActiveSessionId', llmActiveSessionId); } llmRenderSessionList(); llmRenderMessages(); } function llmGetActiveSession() { return llmSessions.find(s => s.id === llmActiveSessionId) || null; } function llmSaveSessions() { localStorage.setItem('llmSessions', JSON.stringify(llmSessions)); localStorage.setItem('llmActiveSessionId', llmActiveSessionId); } function llmSaveSessionMeta() { const sess = llmGetActiveSession(); if (!sess) return; sess.model = document.getElementById('llm-model-select').value; sess.systemPrompt = document.getElementById('llm-system-prompt').value.trim(); sess.updatedAt = new Date().toISOString(); llmSaveSessions(); llmRenderSessionList(); } // ── 세션 목록 렌더링 ─────────────────────────────────── function llmRenderSessionList() { const el = document.getElementById('llm-session-list'); if (!el) return; if (llmSessions.length === 0) { el.innerHTML = '
    대화가 없습니다.
    + 버튼을 눌러 새 대화를 시작하세요.
    '; return; } el.innerHTML = llmSessions.map(s => { const isActive = s.id === llmActiveSessionId; const title = esc(s.title || '제목 없음'); const time = s.updatedAt ? new Date(s.updatedAt).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' }) : ''; return `
    ${title} ×
    `; }).join(''); } // ── 메시지 렌더링 ────────────────────────────────────── function llmRenderMessages() { const el = document.getElementById('llm-messages'); if (!el) return; const sess = llmGetActiveSession(); if (!sess || sess.messages.length === 0) { el.innerHTML = `
    💬
    새 대화를 시작하세요
    모델을 선택하고 메시지를 입력하세요
    `; // 모델/시스템 프롬프트 불러오기 if (sess) { const sel = document.getElementById('llm-model-select'); if (sel && sess.model) sel.value = sess.model; const ta = document.getElementById('llm-system-prompt'); if (ta && sess.systemPrompt) ta.value = sess.systemPrompt; } return; } // 모델/시스템 프롬프트 불러오기 const sel = document.getElementById('llm-model-select'); if (sel && sess.model) sel.value = sess.model; const ta = document.getElementById('llm-system-prompt'); if (ta && sess.systemPrompt) ta.value = sess.systemPrompt; el.innerHTML = sess.messages.map(m => { const role = m.role === 'user' ? 'user' : 'assistant'; const avatar = role === 'user' ? 'U' : 'AI'; const content = llmFormatMessage(m.content); return `
    ${avatar}
    ${content}
    `; }).join(''); el.scrollTop = el.scrollHeight; } function llmFormatMessage(text) { // 코드 블록 처리 text = text.replace(/```(\w*)\n?([\s\S]*?)```/g, (m, lang, code) => { return `
    ${esc(code.trim())}
    `; }); // 인라인 코드 text = text.replace(/`([^`]+)`/g, '$1'); // 줄바꿈 text = esc(text).replace(/\n/g, '
    '); return text; } // ── 모델 목록 로드 ───────────────────────────────────── async function llmLoadModels() { try { const d = await api('GET', '/api/ollama/models'); const sel = document.getElementById('llm-model-select'); if (!sel) return; const currentVal = sel.value; sel.innerHTML = ''; if (d.success && d.models) { d.models.forEach(m => { const opt = document.createElement('option'); opt.value = m; opt.textContent = m; sel.appendChild(opt); }); // 이전에 선택된 모델 유지 if (currentVal && [...sel.options].some(o => o.value === currentVal)) { sel.value = currentVal; } } // 연결 상태 표시 const dot = document.getElementById('llm-conn-status'); if (dot) { dot.className = d.success ? 'llm-conn-dot connected' : 'llm-conn-dot error'; dot.title = d.success ? 'Ollama 연결됨' : 'Ollama 연결 실패'; } } catch (e) { const dot = document.getElementById('llm-conn-status'); if (dot) { dot.className = 'llm-conn-dot error'; dot.title = '연결 실패: ' + e.message; } } } // ── 메시지 전송 (스트리밍) ───────────────────────────── async function llmSend() { const input = document.getElementById('llm-input'); const text = input.value.trim(); if (!text || llmIsStreaming) return; // 세션이 없으면 생성 let sess = llmGetActiveSession(); if (!sess) { sess = llmCreateSession(); } const model = document.getElementById('llm-model-select').value; if (!model) { alert('모델을 선택하세요.'); return; } const systemPrompt = document.getElementById('llm-system-prompt').value.trim(); // 사용자 메시지 추가 sess.messages.push({ role: 'user', content: text }); input.value = ''; input.style.height = 'auto'; llmSaveSessions(); llmRenderMessages(); llmRenderSessionList(); // 모델/시스템 프롬프트 저장 sess.model = model; sess.systemPrompt = systemPrompt; // assistant 메시지 자리 표시 const assistantMsg = { role: 'assistant', content: '' }; sess.messages.push(assistantMsg); // 스트리밍 시작 llmIsStreaming = true; llmUpdateButtons(); llmRenderMessages(); // assistant 버블에 타이핑 인디케이터 추가 const messagesEl = document.getElementById('llm-messages'); const typingDiv = document.createElement('div'); typingDiv.className = 'llm-msg assistant'; typingDiv.id = 'llm-streaming-msg'; typingDiv.innerHTML = `
    AI
    `; messagesEl.appendChild(typingDiv); messagesEl.scrollTop = messagesEl.scrollHeight; llmAbortController = new AbortController(); try { const res = await fetch('/api/ollama/chat/stream', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model, messages: sess.messages.slice(0, -1), // assistant 자리 표시 제외 systemPrompt: systemPrompt || undefined }), signal: llmAbortController.signal }); if (!res.ok) { throw new Error(`HTTP ${res.status}`); } const reader = res.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); // SSE 파싱: event: ...\ndata: ...\n\n const parts = buffer.split('\n\n'); buffer = parts.pop() || ''; for (const part of parts) { const lines = part.split('\n'); let eventData = ''; for (const line of lines) { if (line.startsWith('data: ')) { eventData = line.slice(6); } else if (line.startsWith('event: error')) { throw new Error(eventData || '스트리밍 오류'); } else if (line.startsWith('event: done')) { break; } } if (eventData && eventData !== '{}') { try { const json = JSON.parse(eventData); if (json.message && json.message.content) { assistantMsg.content += json.message.content; llmUpdateStreamingMessage(assistantMsg.content); } else if (json.response) { // /api/generate 형식 호환 assistantMsg.content += json.response; llmUpdateStreamingMessage(assistantMsg.content); } } catch { // 파싱 실패 시 무시 (불완전 데이터) } } } } // 스트리밍 완료 sess.updatedAt = new Date().toISOString(); llmSaveSessions(); llmRenderMessages(); llmRenderSessionList(); } catch (e) { if (e.name === 'AbortError') { // 사용자가 중단 — 현재까지의 내용 유지 if (assistantMsg.content) { sess.updatedAt = new Date().toISOString(); llmSaveSessions(); llmRenderMessages(); } else { // 중단 시 내용 없으면 메시지 제거 sess.messages.pop(); llmSaveSessions(); llmRenderMessages(); } } else { // 에러 메시지 표시 assistantMsg.content = `❌ 오류: ${e.message}`; sess.messages.pop(); sess.messages.push(assistantMsg); sess.updatedAt = new Date().toISOString(); llmSaveSessions(); llmRenderMessages(); } } finally { llmIsStreaming = false; llmAbortController = null; llmUpdateButtons(); } } function llmUpdateStreamingMessage(content) { let msgEl = document.getElementById('llm-streaming-msg'); if (!msgEl) { const messagesEl = document.getElementById('llm-messages'); msgEl = document.createElement('div'); msgEl.className = 'llm-msg assistant'; msgEl.id = 'llm-streaming-msg'; msgEl.innerHTML = `
    AI
    `; messagesEl.appendChild(msgEl); } const bubble = msgEl.querySelector('.llm-msg-bubble'); if (bubble) { bubble.innerHTML = llmFormatMessage(content); } const messagesEl = document.getElementById('llm-messages'); if (messagesEl) { messagesEl.scrollTop = messagesEl.scrollHeight; } } function llmStop() { if (llmAbortController) { llmAbortController.abort(); } } function llmUpdateButtons() { const sendBtn = document.getElementById('llm-send-btn'); const stopBtn = document.getElementById('llm-stop-btn'); if (sendBtn) sendBtn.disabled = llmIsStreaming; if (stopBtn) stopBtn.style.display = llmIsStreaming ? 'inline-flex' : 'none'; } // ── 입력 키 처리 ─────────────────────────────────────── function llmInputKeydown(e) { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); llmSend(); } // 자동 높이 조절 const ta = e.target; setTimeout(() => { ta.style.height = 'auto'; ta.style.height = Math.min(ta.scrollHeight, 150) + 'px'; }, 0); } // ── 세션 초기화 ──────────────────────────────────────── function llmClearSession() { const sess = llmGetActiveSession(); if (!sess) return; if (!confirm('현재 대화의 메시지를 모두 지우시겠습니까?')) return; sess.messages = []; sess.updatedAt = new Date().toISOString(); llmSaveSessions(); llmRenderMessages(); llmRenderSessionList(); } // ── 설정 ─────────────────────────────────────────────── function llmToggleSettings() { const panel = document.getElementById('llm-settings-panel'); if (panel) panel.classList.toggle('hidden'); } async function llmSaveConfig() { const host = document.getElementById('llm-host').value.trim() || 'localhost'; const port = parseInt(document.getElementById('llm-port').value) || 11434; try { const d = await api('POST', '/api/ollama/config', { host, port }); if (d.success) { // Ollama HttpClient는 BaseAddress가 고정되므로 재시작 필요 alert('설정 저장 완료. 변경 사항 적용을 위해 페이지를 새로고침하세요.'); } else { alert('설정 저장 실패: ' + (d.error || '알 수 없는 오류')); } } catch (e) { alert('설정 저장 실패: ' + e.message); } } async function llmTestConnection() { try { const d = await api('GET', '/api/ollama/ping'); const dot = document.getElementById('llm-conn-status'); if (dot) { dot.className = d.success ? 'llm-conn-dot connected' : 'llm-conn-dot error'; dot.title = d.success ? 'Ollama 연결됨' : `Ollama 연결 실패: ${d.error || ''}`; } alert(d.success ? 'Ollama 연결 성공!' : `Ollama 연결 실패: ${d.error || ''}`); } catch (e) { alert('연결 테스트 실패: ' + e.message); } } function llmLoadConfigToUI() { api('GET', '/api/ollama/config').then(d => { if (d.success) { const hostEl = document.getElementById('llm-host'); const portEl = document.getElementById('llm-port'); if (hostEl && d.host) hostEl.value = d.host; if (portEl && d.port) portEl.value = d.port; } }).catch(() => {}); } function llmLoadActiveSession() { if (llmActiveSessionId && !llmSessions.find(s => s.id === llmActiveSessionId)) { llmActiveSessionId = ''; localStorage.setItem('llmActiveSessionId', ''); } } // ── 전체 내보내기 ────────────────────────────────────── function llmExportAll() { if (llmSessions.length === 0) { alert('내보낼 대화가 없습니다.'); return; } const text = llmSessions.map(s => { const header = `=== ${s.title} (${new Date(s.updatedAt).toLocaleString('ko-KR')}) ===`; const msgs = s.messages.map(m => `[${m.role}] ${m.content}`).join('\n\n'); return `${header}\n\n${msgs}`; }).join('\n\n' + '='.repeat(50) + '\n\n'); const blob = new Blob([text], { type: 'text/plain;charset=utf-8' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `llm-chat-${new Date().toISOString().slice(0, 10)}.txt`; a.click(); URL.revokeObjectURL(url); } ``` --- ## 6. 구현 순서 1. **OllamaController.cs** 생성 2. **Program.cs** 수정 (HttpClient 등록) 3. **index.html** 수정 (탭 + pane 추가) 4. **style.css** 수정 (채팅 스타일 추가) 5. **app.js** 수정 (JS 로직 추가) 6. **dotnet build** 검증 7. Ollama 실행 후 테스트 --- ## 7. 주의사항 - **Ollama 실행 필수**: `ollama serve`가 `localhost:11434`에서 실행 중이어야 함 - **모델 사전 다운로드**: `ollama pull qwen3` 등 사용하려는 모델 미리 다운로드 - **CORS**: Ollama가 로컬 실행이므로 CORS 문제 없음 (C# 백엔드 프록시 경유) - **타임아웃**: 스트리밍 응답은 긴 시간 소요 가능 — `HttpClient.Timeout` 1800s 설정 - **localStorage 용량**: 대화 기록이 많으면 localStorage 한도(5MB) 초과 가능 — 필요시 세션 정리 기능 추가 - **HttpClient BaseAddress 변경**: 설정 변경 시 재시작 필요 (런타임 변경 불가) — 프론트엔드에서 알림