Files
ExperionCrawler/plans/채팅-페이지-플랜.md
windpacer 35136ba91e feat: 로컬 LLM 채팅 기능 추가 (Ollama + vLLM, 스트리밍, MCP 도구 호출)
- OllamaController: Ollama/vLLM 프록시 API (채팅, 스트리밍, 모델 목록, 설정)
- UI: 새 대화 탭, 세션 관리, Markdown 렌더링, 스트리밍 응답
- vLLM: OpenAI-compatible API 지원, MCP function calling 통합
- Fix: McpClient DI 팩토리 등록 (HttpClient BaseAddress 문제 해결)
- Fix: llm-model.json 직렬화 JsonSerializer 사용
- Fix: nl2sql_worker KST 시간대 표시 (AT TIME ZONE Asia/Seoul)
- Program.cs: Ollama/vLLM HttpClient 등록 (1800s timeout)
2026-05-12 19:59:31 +09:00

42 KiB
Raw Blame History

로컬 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 + <section id="pane-llmchat"> 추가
src/Web/wwwroot/css/style.css 채팅 UI 스타일 추가
src/Web/wwwroot/js/app.js 채팅 JS 로직 추가

1. 백엔드 — OllamaController.cs (신규)

경로: src/Web/Controllers/OllamaController.cs

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<OllamaController> _logger;

    public OllamaController(
        HttpClient httpClient,
        IConfiguration config,
        ILogger<OllamaController> 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<OllamaConfig>(json) ?? new OllamaConfig();
            }
        }
        catch (Exception ex)
        {
            _logger.LogWarning(ex, "[OllamaController] 설정 로드 실패, 기본값 사용");
        }
        return new OllamaConfig();
    }

    // ── 모델 목록 ──────────────────────────────────────────
    [HttpGet("models")]
    public async Task<IActionResult> 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<string>() });

            var body = await res.Content.ReadAsStringAsync();
            var doc = JsonDocument.Parse(body);
            var models = new List<string>();
            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<string>() });
        }
    }

    // ── 일반 채팅 (비스트리밍) ─────────────────────────────
    [HttpPost("chat")]
    public async Task<IActionResult> 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<IActionResult> 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<OllamaMessage>();
    public string? SystemPrompt { get; set; }
}

public class OllamaMessage
{
    public string Role { get; set; } = "";
    public string Content { get; set; } = "";
}

2. Program.cs 변경

추가 위치: McpClient 등록 직후 (91줄 근처)

// ── Ollama HttpClient ───────────────────────────────────────────────────────
builder.Services.AddHttpClient<IOllamaHttpClient>(_ => new Uri("http://localhost:11434"))
    .SetHandlerLifetime(Timeout.InfiniteTimeSpan);
builder.Services.AddSingleton<IOllamaHttpClient>(sp =>
    sp.GetRequiredService<IHttpClientFactory>().CreateClient("Ollama"));

OllamaController에 HttpClient를 주입받게 하려면 인터페이스가 필요:

src/Core/Application/Interfaces/IOllamaHttpClient.cs (신규, 빈 인터페이스):

namespace ExperionCrawler.Core.Application.Interfaces;

// HttpClient 용 마커 인터페이스 (DI 용)
public interface IOllamaHttpClient
{
}

실제로는 OllamaController 생성자에 HttpClient를 직접 받고, Program.cs에서:

builder.Services.AddHttpClient("Ollama", c =>
{
    c.BaseAddress = new Uri("http://localhost:11434");
    c.Timeout = TimeSpan.FromSeconds(1800);
})
.SetHandlerLifetime(Timeout.InfiniteTimeSpan);

그리고 OllamaController 생성자에서 IHttpClientFactory 사용:

public OllamaController(
    IHttpClientFactory httpClientFactory,
    IConfiguration config,
    ILogger<OllamaController> logger)
{
    _httpClient = httpClientFactory.CreateClient("Ollama");
    _config = config;
    _logger = logger;
}

3. index.html 변경

3.1 Sidebar — 탭 추가

위치: <li data-tab="evt"> 직후 (82줄 근처)

<li class="nav-item" data-tab="llmchat">
  <span class="ni">13</span>
  <span class="nl">로컬 LLM 채팅</span>
</li>

3.2 Main — <section id="pane-llmchat"> 추가

위치: </section> (pane-evt 종료) 직후, </main> 직전 (1216줄 근처)

<!-- ══════════════════════════════════════════════════════
     13  로컬 LLM 채팅
══════════════════════════════════════════════════════ -->
<section class="pane" id="pane-llmchat">
  <header class="pane-hdr">
    <div>
      <h1>로컬 LLM 채팅</h1>
      <p>로컬 Ollama 서버에 연결하여 LLM과 대화합니다.</p>
    </div>
    <div class="pane-tag">LLM / CHAT</div>
  </header>

  <div class="llm-layout">
    <!-- ── 왼쪽: 세션 목록 ──────────────────────────────── -->
    <div class="llm-sidebar">
      <div class="llm-sidebar-header">
        <span class="llm-sidebar-title">대화 목록</span>
        <button class="btn-a btn-sm" onclick="llmNewSession()" title="새 대화"></button>
      </div>
      <div id="llm-session-list" class="llm-session-list">
        <div class="llm-empty">대화가 없습니다. + 버튼을 눌러 새 대화를 시작하세요.</div>
      </div>
      <div class="llm-sidebar-footer">
        <button class="btn-b btn-sm" onclick="llmExportAll()" style="width:100%">📋 전체 내보내기</button>
      </div>
    </div>

    <!-- ── 중앙: 채팅 영역 ──────────────────────────────── -->
    <div class="llm-main">
      <!-- 상단 바 -->
      <div class="llm-header">
        <div class="llm-header-left">
          <div class="fg" style="margin:0;width:180px">
            <label>모델</label>
            <select id="llm-model-select" class="inp" onchange="llmSaveSessionMeta()">
              <option value="">-- 모델을 선택하세요 --</option>
            </select>
          </div>
          <button class="btn-b btn-sm" onclick="llmLoadModels()">🔄 갱신</button>
          <span id="llm-conn-status" class="llm-conn-dot" title="연결 상태"></span>
        </div>
        <div class="llm-header-right">
          <button class="btn-b btn-sm" onclick="llmClearSession()">🗑 초기화</button>
          <button class="btn-b btn-sm" onclick="llmToggleSettings()">⚙ 설정</button>
        </div>
      </div>

      <!-- 설정 패널 (접이식) -->
      <div id="llm-settings-panel" class="llm-settings hidden">
        <div class="cols-2">
          <div class="fg"><label>Ollama Host</label>
            <input id="llm-host" class="inp" value="localhost"/></div>
          <div class="fg"><label>Port</label>
            <input id="llm-port" class="inp" type="number" value="11434"/></div>
        </div>
        <div class="fg"><label>시스템 프롬프트</label>
          <textarea id="llm-system-prompt" class="ta" rows="3"
            placeholder="예: 너는 산업 자동화 분야의 전문가입니다."></textarea>
        </div>
        <div class="btn-row">
          <button class="btn-a btn-sm" onclick="llmSaveConfig()">💾 설정 저장</button>
          <button class="btn-b btn-sm" onclick="llmTestConnection()">🔌 연결 테스트</button>
        </div>
      </div>

      <!-- 메시지 영역 -->
      <div id="llm-messages" class="llm-messages">
        <div class="llm-welcome">
          <div class="llm-welcome-icon">💬</div>
          <div class="llm-welcome-text">새 대화를 시작하세요</div>
          <div class="llm-welcome-hint">모델을 선택하고 메시지를 입력하세요</div>
        </div>
      </div>

      <!-- 입력 영역 -->
      <div class="llm-input-area">
        <div class="llm-input-box">
          <textarea id="llm-input" class="llm-textarea" rows="1"
            placeholder="메시지를 입력하세요... (Shift+Enter: 줄바꿈, Enter: 전송)"
            onkeydown="llmInputKeydown(event)"></textarea>
          <div class="llm-input-btns">
            <button id="llm-send-btn" class="btn-a btn-sm" onclick="llmSend()">전송</button>
            <button id="llm-stop-btn" class="btn-b btn-sm" onclick="llmStop()" style="display:none">중단</button>
          </div>
        </div>
      </div>
    </div>
  </div>
</section>

4. CSS — style.css 추가

파일: src/Web/wwwroot/css/style.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 — 파일 끝에 추가

/* ═══════════════════════════════════════════════════════
   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 = '<div class="llm-empty">대화가 없습니다.<br>+ 버튼을 눌러 새 대화를 시작하세요.</div>';
    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 `
      <div class="llm-session-item ${isActive ? 'active' : ''}" onclick="llmSwitchSession('${s.id}')">
        <span class="llm-sess-title" title="${title}">${title}</span>
        <span class="llm-sess-del" onclick="llmDeleteSession('${s.id}', event)" title="삭제">×</span>
      </div>
    `;
  }).join('');
}

// ── 메시지 렌더링 ──────────────────────────────────────
function llmRenderMessages() {
  const el = document.getElementById('llm-messages');
  if (!el) return;

  const sess = llmGetActiveSession();
  if (!sess || sess.messages.length === 0) {
    el.innerHTML = `
      <div class="llm-welcome">
        <div class="llm-welcome-icon">💬</div>
        <div class="llm-welcome-text">새 대화를 시작하세요</div>
        <div class="llm-welcome-hint">모델을 선택하고 메시지를 입력하세요</div>
      </div>
    `;
    // 모델/시스템 프롬프트 불러오기
    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 `
      <div class="llm-msg ${role}">
        <div class="llm-msg-avatar">${avatar}</div>
        <div class="llm-msg-bubble">${content}</div>
      </div>
    `;
  }).join('');

  el.scrollTop = el.scrollHeight;
}

function llmFormatMessage(text) {
  // 코드 블록 처리
  text = text.replace(/```(\w*)\n?([\s\S]*?)```/g, (m, lang, code) => {
    return `<pre><code>${esc(code.trim())}</code></pre>`;
  });
  // 인라인 코드
  text = text.replace(/`([^`]+)`/g, '<code>$1</code>');
  // 줄바꿈
  text = esc(text).replace(/\n/g, '<br>');
  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 = '<option value="">-- 모델을 선택하세요 --</option>';

    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 = `
    <div class="llm-msg-avatar">AI</div>
    <div class="llm-msg-bubble">
      <div class="llm-typing"><span></span><span></span><span></span></div>
    </div>
  `;
  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 = `
      <div class="llm-msg-avatar">AI</div>
      <div class="llm-msg-bubble"></div>
    `;
    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 servelocalhost:11434에서 실행 중이어야 함
  • 모델 사전 다운로드: ollama pull qwen3 등 사용하려는 모델 미리 다운로드
  • CORS: Ollama가 로컬 실행이므로 CORS 문제 없음 (C# 백엔드 프록시 경유)
  • 타임아웃: 스트리밍 응답은 긴 시간 소요 가능 — HttpClient.Timeout 1800s 설정
  • localStorage 용량: 대화 기록이 많으면 localStorage 한도(5MB) 초과 가능 — 필요시 세션 정리 기능 추가
  • HttpClient BaseAddress 변경: 설정 변경 시 재시작 필요 (런타임 변경 불가) — 프론트엔드에서 알림