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

1390 lines
42 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 로컬 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`
```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<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줄 근처)
```csharp
// ── 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` (신규, 빈 인터페이스)**:
```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<OllamaController> logger)
{
_httpClient = httpClientFactory.CreateClient("Ollama");
_config = config;
_logger = logger;
}
```
---
## 3. index.html 변경
### 3.1 Sidebar — 탭 추가
**위치**: `<li data-tab="evt">` 직후 (82줄 근처)
```html
<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줄 근처)
```html
<!-- ══════════════════════════════════════════════════════
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` — 파일 끝에 추가
```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 = '<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 serve``localhost:11434`에서 실행 중이어야 함
- **모델 사전 다운로드**: `ollama pull qwen3` 등 사용하려는 모델 미리 다운로드
- **CORS**: Ollama가 로컬 실행이므로 CORS 문제 없음 (C# 백엔드 프록시 경유)
- **타임아웃**: 스트리밍 응답은 긴 시간 소요 가능 — `HttpClient.Timeout` 1800s 설정
- **localStorage 용량**: 대화 기록이 많으면 localStorage 한도(5MB) 초과 가능 — 필요시 세션 정리 기능 추가
- **HttpClient BaseAddress 변경**: 설정 변경 시 재시작 필요 (런타임 변경 불가) — 프론트엔드에서 알림