- 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)
1390 lines
42 KiB
Markdown
1390 lines
42 KiB
Markdown
# 로컬 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 변경**: 설정 변경 시 재시작 필요 (런타임 변경 불가) — 프론트엔드에서 알림
|