From 35136ba91e55624b78e91aff2df9f15c4cf6d973 Mon Sep 17 00:00:00 2001 From: windpacer Date: Tue, 12 May 2026 19:59:31 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=A1=9C=EC=BB=AC=20LLM=20=EC=B1=84?= =?UTF-8?q?=ED=8C=85=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80=20(Ollama?= =?UTF-8?q?=20+=20vLLM,=20=EC=8A=A4=ED=8A=B8=EB=A6=AC=EB=B0=8D,=20MCP=20?= =?UTF-8?q?=EB=8F=84=EA=B5=AC=20=ED=98=B8=EC=B6=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- gemma4-dflash-run.sh | 28 + mcp-server/llm-model.json | 4 +- mcp-server/ollama-config.json | 4 + mcp-server/worker/nl2sql_worker.py | 14 +- opencode.json | 21 + plans/LLM채팅+지식증강플랜.md | 770 +++++++++++ plans/채팅-페이지-플랜.md | 1389 ++++++++++++++++++++ src/Web/Controllers/ExperionControllers.cs | 2 +- src/Web/Controllers/OllamaController.cs | 835 ++++++++++++ src/Web/Program.cs | 18 +- src/Web/wwwroot/css/style.css | 359 +++++ src/Web/wwwroot/index.html | 101 +- src/Web/wwwroot/js/app.js | 557 +++++++- 13 files changed, 4091 insertions(+), 11 deletions(-) create mode 100644 gemma4-dflash-run.sh create mode 100644 mcp-server/ollama-config.json create mode 100644 opencode.json create mode 100644 plans/LLM채팅+지식증강플랜.md create mode 100644 plans/채팅-페이지-플랜.md create mode 100644 src/Web/Controllers/OllamaController.cs diff --git a/gemma4-dflash-run.sh b/gemma4-dflash-run.sh new file mode 100644 index 0000000..4bb94c6 --- /dev/null +++ b/gemma4-dflash-run.sh @@ -0,0 +1,28 @@ +docker run -d --name gemma4-dflash \ + --gpus all --network host --ipc host \ + --ulimit memlock=-1 --ulimit stack=67108864 \ + -v "$HOME/.cache/huggingface:/root/.cache/huggingface" \ + -e PORT=8000 \ + -e SERVED_MODEL_NAME=google/gemma-4-31B-it-vllm-fp8-dflash-16k \ + -e HF_TOKEN=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 \ + -e VLLM_DISABLE_COMPILE_CACHE=1 \ + --entrypoint "" \ + gemma4-dflash:arm64 \ + bash -c " +python3 /opt/gemma4-dflash-spark-vllm/scripts/patch_vllm_gb10_gemma4_dflash_runtime.py +exec vllm serve google/gemma-4-31B-it \ + --host 0.0.0.0 \ + --port 8000 \ + --served-model-name google/gemma-4-31B-it-vllm-fp8-dflash-16k \ + --trust-remote-code \ + --max-model-len 16384 \ + --gpu-memory-utilization 0.80 \ + --quantization fp8 \ + --tensor-parallel-size 1 \ + --max-num-batched-tokens 16384 \ + --enforce-eager \ + --speculative-config '{"model":"RedHatAI/gemma-4-31B-it-speculator.dflash","num_speculative_tokens":8,"method":"dflash"}' \ + --limit-mm-per-prompt '{"image":0,"video":0}' \ + --enable-auto-tool-choice \ + --tool-call-parser hermes +" diff --git a/mcp-server/llm-model.json b/mcp-server/llm-model.json index 233365f..8d8f9bb 100644 --- a/mcp-server/llm-model.json +++ b/mcp-server/llm-model.json @@ -1,3 +1 @@ -{ - "vllm_model": "Qwen3.6-27B-FP8" -} +{"vllm_model":"Qwen3.6-27B-FP8"} \ No newline at end of file diff --git a/mcp-server/ollama-config.json b/mcp-server/ollama-config.json new file mode 100644 index 0000000..6879689 --- /dev/null +++ b/mcp-server/ollama-config.json @@ -0,0 +1,4 @@ +{ + "host": "localhost", + "port": 11434 +} \ No newline at end of file diff --git a/mcp-server/worker/nl2sql_worker.py b/mcp-server/worker/nl2sql_worker.py index abbe427..632b72f 100644 --- a/mcp-server/worker/nl2sql_worker.py +++ b/mcp-server/worker/nl2sql_worker.py @@ -110,13 +110,14 @@ N분 간격 집계 공식 (time_bucket 금지, date_trunc 사용): 10분 버킷: to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/600)*600) AS bucket N분 버킷: to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/(N*60))*(N*60)) AS bucket -예시 (2분 간격, 여러 태그): - SELECT to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/120)*120) AS bucket, +예시 (2분 간격, 여러 태그, KST 표시): + SELECT to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/120)*120) AT TIME ZONE 'Asia/Seoul' AS bucket, tagname, AVG(value::double precision) AS avg_val FROM history_table WHERE tagname IN ('tag1', 'tag2') AND recorded_at >= NOW() - INTERVAL '3 hours' - GROUP BY bucket, tagname ORDER BY bucket, tagname + GROUP BY to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/120)*120), tagname + ORDER BY to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/120)*120), tagname 규칙: - SELECT만 허용 (INSERT/UPDATE/DELETE/DROP 등 불가) @@ -142,7 +143,12 @@ async def _generate_sql(natural_language: str) -> str: " with GROUP BY bucket, tagname and AVG(value::double precision) AS avg_val\n" " * If NO interval is specified: SELECT recorded_at, tagname, value — NO GROUP BY.\n" "- Current year is 2026. '4월 27일' means 2026-04-27.\n" - "- All times in DB are UTC. Korean input is KST (UTC+9). Convert: KST 12:00 = UTC 03:00.\n" + "- All times in DB are UTC. Korean input is KST (UTC+9). Convert KST→UTC for WHERE: KST 12:00 = UTC 03:00.\n" + "- Display times in KST: always apply AT TIME ZONE 'Asia/Seoul' on time columns in SELECT.\n" + " * Non-aggregated: SELECT recorded_at AT TIME ZONE 'Asia/Seoul' AS recorded_at, ...\n" + " * Aggregated bucket: GROUP BY the raw UTC expression, then convert only in SELECT:\n" + " SELECT to_timestamp(...) AT TIME ZONE 'Asia/Seoul' AS bucket, AVG(...) AS avg_val\n" + " FROM ... GROUP BY to_timestamp(...), tagname ORDER BY to_timestamp(...), tagname\n" "- value column is TEXT; cast with ::double precision only when aggregating.\n" "- All tagnames are lowercase (e.g. 'ficq-6113.pv'). Match exactly.\n" "- PostgreSQL LIKE: dot has no special meaning, no escaping needed.\n" diff --git a/opencode.json b/opencode.json new file mode 100644 index 0000000..524d31b --- /dev/null +++ b/opencode.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "vllm": { + "npm": "@ai-sdk/openai-compatible", + "name": "vLLM (local)", + "options": { + "baseURL": "http://localhost:8000/v1" + }, + "models": { + "google/gemma-4-31B-it-vllm-fp8-dflash-16k": { + "name": "Gemma 4 31B dflash", + "limit": { + "context": 16384, + "output": 8192 + } + } + } + } + } +} diff --git a/plans/LLM채팅+지식증강플랜.md b/plans/LLM채팅+지식증강플랜.md new file mode 100644 index 0000000..9f0518f --- /dev/null +++ b/plans/LLM채팅+지식증강플랜.md @@ -0,0 +1,770 @@ +# 로컬 LLM 채팅 + 지식 증강 (RAG) 플랜 + +작성 시점: 2026-05-12 +대상: 탭 #13 로컬 LLM 채팅 + 신규 탭 #14 RAG 관리 + +--- + +## 0. 배경 및 목적 + +운전원이 채팅 UI에서 자연어로 "공장 상황 보고 / 계기 상태 / 현장 재고 / 고장 이력" 등을 +질문하고, 시스템이 다음 두 가지 소스를 합성해 답한다. + +1. **실시간/이력 데이터** — PostgreSQL(history_table, realtime_table, event_history_table, + tag_metadata, node_map_master) +2. **사용자 지식 베이스(RAG)** — 관리자가 첨부한 문서(엑셀, 워드, PDF, MD, TXT 등)에서 + 추출한 청크를 Qdrant에 인덱스, 의미 검색으로 활용 + +본 문서는 두 갈래를 통합한 **컨셉 합의 결과**와 **구현 계획**을 기록한다. +코드는 골격/예시 위주이며, 실제 구현은 본 플랜을 기준으로 단계별 진행한다. + +--- + +## 1. 현 상태 검토 요약 + +### 1.1 잘 짜여 있는 부분 +- `OllamaController` — Ollama / vLLM 이중 백엔드, SSE 스트리밍, MCP 툴콜 루프(최대 10라운드), + `tool_calls` API 미지원 모델용 JSON 텍스트 폴백 추출(`ExtractFirstJsonObject`). +- `app.js` 채팅 UI — localStorage 세션 관리, 모델/툴 토글, 중단 버튼, 코드블록 포맷팅. +- MCP 툴셋(`mcp-server/server.py`) — `run_sql`, `query_pv_history`, `get_tag_metadata`, + `list_drawings`, `query_with_nl`, RAG 3종(`search_codebase`, `search_r530_docs`, `rag_query`, + `ask_iiot_llm`). +- 임베딩·OCR·PDF/DXF 추출 인프라가 이미 존재(`_embed`, `_ocr`, `_extract_text_from_pdf`, + `_extract_text_from_pdf_ocr`, `_extract_text_from_dxf`). + +### 1.2 "공장 상황 보고" 사용 대비 격차 + +| # | 격차 | 비고 | +|---|---|---| +| G1 | 이벤트/알람 질의 도구 부재 | event_history_table은 적재만 됨, MCP 노출 X | +| G2 | "보고서" 합성 도구 부재 | 모델이 멀티 툴을 알아서 호출해야 함 | +| G3 | 현장 재고 데이터 자체 없음 | 별도 자료 수급 또는 RAG로 흡수 | +| G4 | 태그 의미 → 태그명 시맨틱 검색 부재 | `get_tag_metadata`는 패턴 매칭만 | +| G5 | 시스템 프롬프트 빈약(영어 1줄 하드코딩) | 플랜트 용어집·계기 prefix·예시 미주입 | +| G6 | 툴 결과가 raw JSON으로 버블에 박힘 | 표/차트 렌더링 없음, 실행된 SQL 미노출 | +| G7 | 툴 실행 중 UX 공백 | tool_calls 1라운드 비스트리밍 → 침묵 | +| G8 | SQL 안전장치 약함 | run_sql LIMIT/타임아웃 없음 | +| G9 | 장기 대화 컨텍스트 관리 없음 | 매 턴 전체 messages 전송 | +| G10 | 추천 질문/빠른 액션 없음 | 빈 화면 진입 장벽 | +| G11 | 사용자 지식 베이스(KB) 부재 | 본 플랜의 핵심 추가 | + +### 1.3 발견된 자잘한 결함 +- `mcp-server/worker/nl2sql_worker.py:244` — `time_bucket('1 min', ts)` 사용. `realtime_table`에는 + `ts` 컬럼이 없고 시계열은 `history_table.recorded_at`. 호출 시 깨짐. 본체 `server.py`는 정상. +- `mcp-server/llm-model.json` — `"Qwen3.6-27B-FP8"`. 실제 vLLM 서빙 모델명과 동기화 확인 필요 + (메모리에는 Qwen3-Coder-Next-FP8 운영 기록). +- `OllamaController.cs:608` — 시스템 프롬프트 하드코딩 영어 문자열. 한글 + plant_context 합성으로 + 교체 권장. + +--- + +## 2. 합의된 결정 사항 (체크리스트) + +채팅 전 합의 과정에서 확정된 항목. + +### 2.1 채팅 페이지 개선 방향 (별도 사용자 결정 대기 항목 있음) + +| # | 항목 | 합의 | +|---|---|---| +| C1 | 추천 질문 칩(welcome 화면) | 추천 — 구현 예정 | +| C2 | plant_context.md 시스템 프롬프트 주입 | 추천 — 구현 예정 | +| C3 | event MCP 툴 3종 (`query_events`/`summarize_events`/`active_alarms`) | 추천 — 구현 예정 | +| C4 | SSE `tool_start`/`tool_result` 이벤트 + UI 가시화 | 추천 — 구현 예정 | +| C5 | 테이블/시계열 자동 렌더링 | 추천 — 구현 예정 | +| C6 | `generate_status_report` 매크로 툴 | 추천 — 구현 예정 | +| C7 | 태그 시맨틱 검색(`find_tags`) | 추천 — 구현 예정 | +| C8 | SQL 안전장치 (LIMIT 자동/statement_timeout) | 추천 — 구현 예정 | +| C9 | NL2SQL 의도 라우터 | 검토 후 결정 | +| C10 | 대화 요약/압축 | 후순위 | +| C11 | 에이전트 모드(자율 멀티스텝) | 후순위 | +| C12 | 위 1.3 결함 픽스 | 즉시 진행 | + +### 2.2 지식 증강(RAG ingest) 결정 + +| # | 항목 | 결정 | +|---|---|---| +| Q1 | ingest 권한 | **관리자만** | +| Q2 | 컬렉션 구조 | **doc_type별 분리 컬렉션** (마스터 사전 정의 + 자유 태그 세분화) | +| Q3 | 관리자 권한 | **비번 인증** (해시 + 솔트, 세션 토큰 60분) | +| Q4 | 출처 인용 | **위치(시트/페이지/헤딩) + 다운로드 링크까지** | +| Q5 | 엑셀 청킹 | **행 단위 + 시트 단위 둘 다 저장** | +| Q6 | ingest 트리거 | **첨부 버튼 (관리 탭 안)** — 채팅창에는 첨부 없음 | +| Q7 | 처리 방식 | **전 시스템 비동기 큐** | +| R | 재업로드 정책 | **누적** (버전 관리 없음, 시간순 누적) | +| N1 | 카테고리 운용 | **마스터 사전 정의(5종) + 자유 태그 보조** | +| N2 | PDF 청킹 | **섹션 + 표 별도 추출**, OCR PDF는 페이지 단위 fallback | +| N3 | 누적 잡음 완화 | **검색 시 최신 가중치 + 관리탭 일괄 비활성화 버튼** | +| N4 | 다운로드 인증 | **누구나 다운로드 가능** | +| M1 | 저장소 cleanup | **수동만** — 관리탭 "오래된 비활성화 일괄 영구삭제" 버튼만 제공 | +| M2 | 임베딩 모델 | **추천 채택** — BGE-M3 또는 multilingual-e5-large (한국어 품질 우선) | +| M3 | 비번 저장 | **해시(PBKDF2/Argon2) + 솔트**, 초기 비번은 환경변수 또는 콘솔 출력, 관리탭에서 변경 | + +--- + +## 3. 통합 시스템 설계 + +### 3.1 전체 아키텍처 + +``` + ┌─────────────────────────┐ + │ 채팅 탭 #13 (운전원) │ + │ - 추천 질문 칩 │ + │ - 스트리밍 + 툴 가시화 │ + │ - 테이블/차트 자동 렌더 │ + └──────────┬──────────────┘ + │ /api/ollama/vllm/chat/stream + ▼ + ┌──────────────────────────┐ + │ OllamaController │ + │ - tool_calls 루프 │ + │ - SSE 이벤트 발행 │ + └──────────┬───────────────┘ + │ MCP tool call + ▼ + ┌──────────────────────────────────────────────────────┐ + │ MCP Server (server.py) │ + │ ┌────────────┬─────────────┬──────────────────────┐ │ + │ │ DB 도구 │ KB 도구 │ R530 docs / Code │ │ + │ │ run_sql │ search_kb │ search_r530_docs │ │ + │ │ q_pv_hist │ parse_doc │ search_codebase │ │ + │ │ events* │ ... │ ... │ │ + │ └────────────┴─────────────┴──────────────────────┘ │ + └──────┬─────────────────┬──────────────────────┬──────┘ + │ │ │ + ▼ ▼ ▼ + PostgreSQL Qdrant collections Filesystem + (시계열 + KB 메타) (kb_* 5개 + 기존 2개) (storage/kb/) + + + ┌─────────────────────────┐ + │ RAG 관리 탭 #14 (관리자) │ + │ - 비번 인증 │ + │ - 업로드 (드래그앤드롭) │ + │ - 문서 목록/상태/삭제 │ + │ - 진행률 폴링 │ + └──────────┬──────────────┘ + │ /api/kb/* + ▼ + ┌──────────────────────────┐ + │ KbController │ + │ + KbIngestWorker │ + │ (BackgroundService) │ + └──────────────────────────┘ +``` + +### 3.2 시드 카테고리 (kb_collections 초기 데이터) + +| `collection_key` | 표시명 | 청킹 정책 (chunking_policy JSONB) | 대표 자료 | +|---|---|---|---| +| `system_instrument` | 시스템 & 계기 정보 | `{"pdf":"section+table","xlsx":"row+sheet","docx":"heading"}` | 계기 datasheet, P&ID, 사양서, 노드맵 | +| `plant_operation` | 공장 운전 정보 | `{"xlsx":"row","docx":"heading","md":"heading"}` | 재고, 생산현황, 고장이력, 교대일지 | +| `procedure` | 절차서/SOP | `{"docx":"heading","md":"heading","pdf":"section"}` | SOP, 정비 절차, 알람 대응 매뉴얼 | +| `report` | 보고서 | `{"pdf":"section+table","docx":"heading"}` | 일/주/월 보고, 사고보고, 분석보고 | +| `vendor_doc` | 벤더 자료 | `{"pdf":"section+table","docx":"heading"}` | 카탈로그, 매뉴얼, 인증서 | + +**공통 보조 태그** (kb_documents.tags, 자유 입력): +- `area` — Unit A, Unit B 등 +- `equipment` — P-6201, FIC-6113 등 +- `date` — 자료 기준일 (YYYY-MM-DD) +- `language` — ko / en + +--- + +### 3.3 데이터 모델 (PostgreSQL) + +```sql +-- 컬렉션 레지스트리 +CREATE TABLE kb_collections ( + collection_key TEXT PRIMARY KEY, + display_name TEXT NOT NULL, + qdrant_name TEXT NOT NULL UNIQUE, + chunking_policy JSONB NOT NULL, + description TEXT, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +-- 문서 메타 +CREATE TABLE kb_documents ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + collection_key TEXT REFERENCES kb_collections, + title TEXT NOT NULL, + original_path TEXT NOT NULL, -- storage/kb/2026-05/{uuid}.{ext} + file_sha256 TEXT NOT NULL, + file_size BIGINT, + mime_type TEXT, + tags TEXT[], + status TEXT NOT NULL DEFAULT 'pending', + -- pending / parsing / embedding / indexed / failed / disabled + chunk_count INT DEFAULT 0, + error_message TEXT, + uploaded_by TEXT, -- 관리자 세션 식별자 + uploaded_at TIMESTAMPTZ DEFAULT NOW(), + indexed_at TIMESTAMPTZ, + disabled_at TIMESTAMPTZ +); +CREATE INDEX idx_kb_docs_coll_status ON kb_documents(collection_key, status, uploaded_at DESC); +CREATE INDEX idx_kb_docs_title ON kb_documents(title); + +-- 비동기 작업 큐 +CREATE TABLE kb_ingest_jobs ( + id BIGSERIAL PRIMARY KEY, + doc_id UUID REFERENCES kb_documents, + stage TEXT NOT NULL, -- parse / embed / index + attempts INT DEFAULT 0, + last_error TEXT, + enqueued_at TIMESTAMPTZ DEFAULT NOW(), + started_at TIMESTAMPTZ, + finished_at TIMESTAMPTZ +); +CREATE INDEX idx_kb_jobs_pending ON kb_ingest_jobs(stage, finished_at) + WHERE finished_at IS NULL; + +-- 관리자 인증 (단일 행) +CREATE TABLE kb_admin_credential ( + id INT PRIMARY KEY DEFAULT 1 CHECK (id = 1), + password_hash TEXT NOT NULL, -- Argon2 또는 PBKDF2 + salt TEXT NOT NULL, + algorithm TEXT NOT NULL DEFAULT 'argon2id', + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +-- 관리자 세션 +CREATE TABLE kb_admin_sessions ( + token TEXT PRIMARY KEY, + issued_at TIMESTAMPTZ DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL, + client_ip TEXT +); +``` + +### 3.4 Qdrant payload 표준 + +모든 KB 컬렉션 공통: + +```json +{ + "doc_id": "uuid", + "collection_key": "plant_operation", + "title": "정비이력_2026Q1.xlsx", + "chunk_kind": "row | sheet | section | table | page | paragraph", + "locator": "sheet=Pump-A; row=12-15", + "uploaded_at": "2026-05-12T10:00:00Z", + "tags": ["unit-a", "P-6201"] +} +``` + +- `locator` — UI에 그대로 표시될 사람용 위치 문자열 +- `uploaded_at` — 검색 시 최신 가중치(decay) 계산용 +- `chunk_kind` — 같은 문서에서 행 청크 vs 시트 청크 중복 발생 시 dedup/우선순위 결정용 + +--- + +### 3.5 API 엔드포인트 (.NET) + +``` +[관리자 인증] +POST /api/kb/auth/login {password} → {token, expiresAt} +POST /api/kb/auth/logout header: X-Kb-Token +GET /api/kb/auth/status header: X-Kb-Token → {valid, expiresAt} +POST /api/kb/auth/change-password {oldPassword, newPassword} + +[컬렉션 — 누구나 조회 가능] +GET /api/kb/collections → [{key, name, chunkCount, ...}] + +[업로드 / 관리 — admin only] +POST /api/kb/upload multipart → {docId, status:"pending"} + fields: file, collectionKey, title?, tags[]? +GET /api/kb/documents?collection=&status=&q=&page= → 페이지네이션 목록 +GET /api/kb/documents/{id} → 상세 + chunks 미리보기(상위 N) +DELETE /api/kb/documents/{id} → Qdrant + storage 동시 정리 +POST /api/kb/documents/{id}/reindex → status 초기화 + 재큐 +POST /api/kb/documents/{id}/disable → status='disabled' +POST /api/kb/documents/bulk-disable {title} → 동일 제목 일괄 비활성화 +POST /api/kb/documents/purge-disabled {olderThanDays?} → 비활성화 영구삭제(수동) +GET /api/kb/jobs?status=&docId= → 진행 중/실패 작업 + +[다운로드 — 누구나] +GET /api/kb/download/{docId} → 원본 스트림 (Content-Disposition) +``` + +### 3.6 MCP 측 신규/변경 도구 (server.py) + +```python +# 신규 — KB 인덱싱 (워커 전용, 채팅 노출 X) +parse_document(doc_id: str) -> dict + """형식별 청킹. xlsx는 행+시트 둘 다, pdf는 섹션+표 별도, OCR PDF는 페이지 fallback.""" + +# 신규 — KB 검색 (채팅 노출 O) +search_kb(query: str, + collection_keys: list[str] = None, # None이면 전체 + top_k: int = 8, + tags: list[str] = None, + since: str = None, # ISO date — 이후 업로드된 것만 + boost_recent: bool = True) -> str + """다중 컬렉션 의미 검색. uploaded_at 기반 최신 가중치 적용.""" + +# 신규 — 이벤트/알람 +query_events(time_from: str, time_to: str, + severity: str = None, area: str = None) -> str +summarize_events(time_window: str = "24h") -> str +active_alarms() -> str + +# 신규 — 태그 시맨틱 검색 +find_tags(query: str, top_k: int = 10) -> str + """tag_metadata.value(desc, area) 벡터 인덱스 사용. '냉각수 펌프 토출' 같은 표현 지원.""" + +# 신규 — 보고서 합성 +generate_status_report(scope: str = "shift", # shift / daily / event + area: str = None) -> str + +# 통합 — 기존 rag_query 확장 +rag_query(question: str, + search_code: bool = False, + search_docs: bool = True, + search_kb: bool = True, # 신규 — KB 통합 + kb_collections: list[str] = None) +``` + +### 3.7 비동기 파이프라인 + +``` +[관리자 PC] ── multipart 업로드 ──▶ [.NET KbController] + │ + (응답 즉시 반환) ├─ storage/kb/{yyyy-mm}/{uuid}.{ext} 저장 + ├─ SHA256 계산 + ├─ kb_documents INSERT (status='pending') + └─ kb_ingest_jobs INSERT (stage='parse') + + ▼ + [KbIngestWorker — BackgroundService] + ├─ FOR UPDATE SKIP LOCKED 큐 폴링(2초) + ├─ stage='parse' + │ └─ MCP parse_document → chunks 목록 반환 + ├─ stage='embed' + │ └─ MCP _embed 배치 호출 + ├─ stage='index' + │ └─ Qdrant upsert (collection_key별) + ├─ 성공: kb_documents.status='indexed', chunk_count=N + └─ 실패: attempts++, ≥3이면 status='failed' + + ▼ + [관리 탭] 1초 폴링으로 진행률 표시 + "정비이력_2026Q1.xlsx — 임베딩 중 (43/120)" + 완료 시 ✓ 마크 +``` + +#### 형식별 청킹 디테일 + +**xlsx (행 + 시트 둘 다)** +- 시트 청크: 시트 전체를 Markdown 표로 직렬화 → 1개 chunk per 시트 +- 행 청크: 각 행을 `{시트명}: 컬럼1=값1, 컬럼2=값2, …` 한 줄로 직렬화 → N개 chunk +- locator: `sheet=Pump-A`, `sheet=Pump-A; row=12` + +**pdf (섹션 + 표 별도)** +- pdfplumber로 헤딩 구조 추출 시도 +- 헤딩 추출 성공: 섹션별 chunk + 페이지 내 표만 별도 chunk +- 헤딩 추출 실패(OCR 등): 페이지 단위 fallback +- locator: `section=Specifications`, `page=5; table=Performance` + +**docx** +- python-docx로 헤딩 트리 추출 → 헤딩 path별 chunk +- locator: `heading=5.2 펌프 기동` + +**md / txt** +- `#` 헤딩 기반 splitter (md), 빈 줄 두 개 기반 (txt) +- locator: `heading=…` 또는 `paragraph=N` + +#### 청크 크기 가이드 +- 평균 400~800 토큰, 헤딩 단위가 너무 크면 추가 분할 +- overlap 100 토큰 + +--- + +### 3.8 채팅 통합 + +채팅 탭은 **읽기 전용** — 첨부 버튼 없음 (Q6 결정). + +``` +사용자 질문 + │ + ▼ +[vLLM tool calling] + │ + ├─ 의도: 시스템/제품 사양 질문 + │ → search_r530_docs + search_kb(['system_instrument','vendor_doc']) + │ + ├─ 의도: 운전 데이터 질문 + │ → query_with_nl (SQL) + search_kb(['plant_operation','procedure']) + │ + 필요시 query_events / active_alarms + │ + └─ 의도: 보고서 작성 + → generate_status_report + search_kb(['report']) + │ + ▼ +[SSE 이벤트 발행] + event: tool_start data: {name, args} + event: tool_result data: {name, ok, summary, rows} + event: message data: 모델 출력 토큰 + │ + ▼ +[프론트엔드] + ├─ 툴 실행 카드 (접이식) 렌더 + ├─ 모델 인용("정비이력_2026Q1.xlsx > 시트:Pump-A > 행 12-15")을 정규식으로 잡아 + │ → [정비이력_2026Q1.xlsx 다운로드 ↓] 링크로 치환 + └─ 시계열 결과 → mini sparkline, 일반 표 → HTML table +``` + +--- + +### 3.9 관리 탭 #14 UI 스케치 + +``` +┌─ 14 RAG 관리 ────────────────────────────────────┐ +│ 🔒 관리자 비번 [______________] [로그인] │ +│ 세션 만료: 60분 (재로그인 필요) │ +├─────────────────────────────────────────────────│ +│ 필터 컬렉션 [▼ 전체] 상태 [▼ indexed] │ +│ 태그 [#unit-a #P-6201] │ +│ 검색 [제목/태그 검색.................] │ +├─────────────────────────────────────────────────│ +│ [📁 파일 업로드] [🔄 새로고침] [비번 변경] │ +│ │ +│ ┌────────────────────────────────────────────┐ │ +│ │ ☐ 제목 컬렉션 chunk 업로드 상태│ │ +│ │ ──────────────────────────────────────────│ │ +│ │ ☐ 정비이력… 운전 127 05-12 ✓ │ │ +│ │ ☐ FIC-6113… 계기 48 05-12 ⏳│ │ +│ │ ☐ SOP-펌프… 절차서 23 05-11 ✓ │ │ +│ │ ☐ 사양서… 계기 0 05-10 ✗ │ │ +│ │ … │ │ +│ └────────────────────────────────────────────┘ │ +│ [선택삭제] [재인덱스] [일괄 비활성화(동일제목)] │ +│ [비활성화 영구삭제(수동)] │ +└─────────────────────────────────────────────────┘ +``` + +**업로드 모달**: +- 드래그앤드롭 + 파일 선택 버튼 +- `collection_key` 드롭다운 **강제 선택** +- 제목(기본: 파일명, 편집 가능) +- 태그(자유 입력, 콤마 분리) +- 업로드 → 즉시 doc_id 반환 → 모달은 닫히고 목록에 status=pending으로 새 행 등장 + +--- + +### 3.10 보안 / 저장소 관리 / 임베딩 모델 + +**관리자 비번** +- 첫 실행 시 `kb_admin_credential` 비어있으면 환경변수 `KB_ADMIN_INITIAL_PASSWORD`로 초기화, + 없으면 콘솔에 랜덤 비번 1회 출력 후 강제 변경 요구. +- 알고리즘: Argon2id (또는 PBKDF2-SHA256 100k iter) +- 세션 토큰: `Guid.NewGuid().ToString("N")` 또는 RandomNumberGenerator 32바이트, `X-Kb-Token` 헤더로 전달. +- 세션 만료 60분, 슬라이딩 갱신 옵션은 추후. + +**저장소 cleanup (M1 = c 수동만)** +- 자동 삭제 잡 없음. +- 관리탭에 `[비활성화 영구삭제]` 버튼: `disabled_at < NOW() - INTERVAL '90 days'` 등 옵션 입력 + 후 일괄 영구삭제(Qdrant + storage). + +**임베딩 모델 (M2 추천 채택)** +- 후보: `BAAI/bge-m3` (멀티링구얼 + 1024차원, dense+sparse 지원) 또는 + `intfloat/multilingual-e5-large` (1024차원, 단순 사용 용이). +- 점검 액션: 현재 `_embed`가 사용하는 모델/차원 확인 → 한국어 sample 검색 품질 A/B 테스트 후 결정. +- 컬렉션을 doc_type별로 새로 만드므로 모델 교체에 따른 마이그레이션 부담은 적음. + +--- + +## 4. 구현 순서 (Todo) + +### Phase 0 — 사전 정비 (반나절) +0.1 `mcp-server/worker/nl2sql_worker.py:244` `time_bucket('1 min', ts)` 버그 수정 +0.2 `mcp-server/llm-model.json` 모델명을 실제 vLLM 서빙명과 일치 +0.3 `OllamaController.cs:608` 시스템 프롬프트 한글화 + plant_context.md 외부 파일화 + +### Phase 1 — 데이터 모델 & 인증 (1일) +1.1 PostgreSQL 마이그레이션: `kb_collections`, `kb_documents`, `kb_ingest_jobs`, + `kb_admin_credential`, `kb_admin_sessions` 테이블 생성 +1.2 시드 데이터 INSERT: kb_collections 5건 (system_instrument, plant_operation, procedure, + report, vendor_doc) +1.3 Qdrant 컬렉션 5개 생성 (kb_system_instrument, kb_plant_operation, kb_procedure, + kb_report, kb_vendor_doc) — 임베딩 차원에 맞춰 +1.4 `KbAuthController` (login/logout/status/change-password) + Argon2 해시 유틸 +1.5 첫 실행 시 초기 비번 시드 로직 + +### Phase 2 — 업로드 & 비동기 워커 (2일) +2.1 `KbController.Upload` — multipart 수신 → storage 저장 → kb_documents/kb_ingest_jobs INSERT +2.2 `KbIngestWorker (BackgroundService)` — 큐 폴링 + 단계별 처리 +2.3 MCP `parse_document` — xlsx (행+시트), pdf (섹션+표), docx, md/txt +2.4 MCP `_embed` 배치 호출 + Qdrant upsert (collection_key 기반 라우팅) +2.5 `KbController.Documents/Jobs` — 목록·상세·진행률 폴링 + +### Phase 3 — 관리 탭 #14 (1일) +3.1 사이드바 14번 탭 추가, `
` 신설 +3.2 비번 입력 → 토큰 받아 sessionStorage 저장 +3.3 컬렉션 필터, 상태/태그 필터, 검색 +3.4 업로드 모달(드래그앤드롭 + collection_key 드롭다운 + 태그) +3.5 목록, 상세 보기, 삭제, 재인덱스, 일괄 비활성화, 비활성화 영구삭제 +3.6 1초 폴링으로 ingesting 진행률 표시 + +### Phase 4 — 다운로드 & 검색 (반나절) +4.1 `/api/kb/download/{docId}` — 원본 스트림, Content-Disposition +4.2 MCP `search_kb` — 다중 컬렉션 + uploaded_at 최신 가중치 + 태그 필터 +4.3 기존 `rag_query` 확장: `search_kb` 통합 옵션 + +### Phase 5 — 채팅 통합 (1~2일) +5.1 SSE 이벤트 추가: `tool_start`, `tool_result` (백엔드 `VllmChatStreamWithTools` 안) +5.2 프론트 채팅 메시지에 툴 실행 카드 렌더 (접이식) +5.3 모델 인용 자동 → 다운로드 링크 치환 +5.4 테이블/시계열 자동 렌더 (`{success, columns, data}` JSON 감지) +5.5 추천 질문 칩(welcome 화면) +5.6 system prompt 합성 로직 (plant_context.md + 도구 가이드 + 사용자 입력) + +### Phase 6 — 보강 도구 (1일) +6.1 MCP `query_events`, `summarize_events`, `active_alarms` (event_history_table 기반) +6.2 MCP `find_tags` — tag_metadata 시맨틱 검색 (별도 Qdrant 컬렉션 또는 KB와 통합) +6.3 MCP `generate_status_report` — 매크로 툴 +6.4 `run_sql` LIMIT 자동 + `SET LOCAL statement_timeout = 10s` + +### Phase 7 — 운영 보강 (옵션) +7.1 NL2SQL 의도 라우터 +7.2 대화 요약/압축 (장기 세션) +7.3 에이전트 모드 (자율 멀티스텝 계획) +7.4 KB 청크 미리보기/편집 UI + +--- + +## 5. 영향 받는 파일 목록 (예상) + +### 신규 +- `plans/LLM채팅+지식증강플랜.md` (본 문서) +- `prompts/plant_context.md` (도메인 용어집/계기 prefix/예시) +- `src/Web/Controllers/KbController.cs` +- `src/Web/Controllers/KbAuthController.cs` +- `src/Infrastructure/Kb/KbIngestWorker.cs` (BackgroundService) +- `src/Infrastructure/Kb/PasswordHasher.cs` +- `src/Core/Domain/Entities/KbEntities.cs` (KbCollection, KbDocument, KbIngestJob, KbAdminCredential, KbAdminSession) +- `src/Core/Application/Interfaces/IKbServices.cs` +- `mcp-server/parsers/xlsx_parser.py` +- `mcp-server/parsers/pdf_parser.py` +- `mcp-server/parsers/docx_parser.py` +- `mcp-server/parsers/text_parser.py` + +### 수정 +- `src/Web/Program.cs` — `KbIngestWorker` HostedService 등록, HttpClient 추가 (필요 시) +- `src/Web/Controllers/OllamaController.cs` — SSE tool_start/tool_result 이벤트, system prompt 합성 +- `src/Web/wwwroot/index.html` — 14번 탭 + pane-kbadmin 섹션, 채팅 추천 질문 칩 +- `src/Web/wwwroot/js/app.js` — kbAuth/kbUpload/kbList/kbDelete/kbDownload, 채팅 툴카드 렌더 + 인용 변환 +- `src/Web/wwwroot/css/style.css` — KB 관리 스타일, 툴카드 스타일 +- `src/Infrastructure/Database/ExperionDbContext.cs` — kb_* DbSet + 마이그레이션 DDL +- `mcp-server/server.py` — `parse_document`, `search_kb`, `query_events`, `summarize_events`, + `active_alarms`, `find_tags`, `generate_status_report` 추가; `rag_query` 확장 +- `mcp-server/worker/nl2sql_worker.py` — time_bucket 버그 픽스 +- `mcp-server/llm-model.json` — 모델명 정정 + +### 미수정 (영향 없음 명시) +- 인증서 관련 (`ExperionCertificateService.cs` 등) — 손대지 않음 +- OPC UA 서버/클라이언트 — 손대지 않음 +- 기존 nodemap/포인트빌더/이력조회 탭 — 손대지 않음 + +--- + +## 6. 부록 + +### 6.1 골격 코드 예시 (참고용, 실제 구현 시 상세 추가) + +#### Argon2 해시 유틸 (PasswordHasher.cs) + +```csharp +using Konscious.Security.Cryptography; // NuGet: Konscious.Security.Cryptography.Argon2 +using System.Security.Cryptography; +using System.Text; + +public static class PasswordHasher +{ + public static (string Hash, string Salt) Hash(string password) + { + var saltBytes = RandomNumberGenerator.GetBytes(16); + var argon = new Argon2id(Encoding.UTF8.GetBytes(password)) + { + Salt = saltBytes, + DegreeOfParallelism = 4, + MemorySize = 65536, + Iterations = 3 + }; + var hashBytes = argon.GetBytes(32); + return (Convert.ToBase64String(hashBytes), Convert.ToBase64String(saltBytes)); + } + + public static bool Verify(string password, string hashB64, string saltB64) + { + var argon = new Argon2id(Encoding.UTF8.GetBytes(password)) + { + Salt = Convert.FromBase64String(saltB64), + DegreeOfParallelism = 4, + MemorySize = 65536, + Iterations = 3 + }; + var computed = argon.GetBytes(32); + return CryptographicOperations.FixedTimeEquals(computed, Convert.FromBase64String(hashB64)); + } +} +``` + +#### xlsx parser 골격 (parsers/xlsx_parser.py) + +```python +from openpyxl import load_workbook + +def parse_xlsx(path: str, doc_id: str, title: str) -> list[dict]: + """행 단위 + 시트 단위 청크 둘 다 생성.""" + wb = load_workbook(path, read_only=True, data_only=True) + chunks = [] + + for sheet in wb.worksheets: + rows = list(sheet.iter_rows(values_only=True)) + if not rows: + continue + header = [str(c) if c is not None else "" for c in rows[0]] + + # (1) 시트 단위 chunk — markdown 표 + md_lines = ["| " + " | ".join(header) + " |", + "| " + " | ".join(["---"] * len(header)) + " |"] + for r in rows[1:]: + md_lines.append("| " + " | ".join(str(c) if c is not None else "" for c in r) + " |") + chunks.append({ + "text": "\n".join(md_lines), + "chunk_kind": "sheet", + "locator": f"sheet={sheet.title}" + }) + + # (2) 행 단위 chunk + for i, r in enumerate(rows[1:], start=2): + parts = [f"{header[j]}={r[j]}" for j in range(len(header)) + if j < len(r) and r[j] is not None] + if not parts: + continue + chunks.append({ + "text": f"{sheet.title}: " + ", ".join(parts), + "chunk_kind": "row", + "locator": f"sheet={sheet.title}; row={i}" + }) + + return chunks +``` + +#### SSE tool_start/tool_result 이벤트 (OllamaController.cs 발췌) + +```csharp +// VllmChatStreamWithTools 안, MCP 호출 직전/직후 +await Response.WriteAsync( + $"event: tool_start\ndata: {JsonSerializer.Serialize(new {name=funcName, args=funcArgs})}\n\n"); +await Response.Body.FlushAsync(); + +var toolResult = await _mcpClient.CallToolAsync(funcName, args, HttpContext.RequestAborted); + +// 결과 요약(처음 200자) — 큰 JSON은 전체 전송 대신 요약 +var preview = toolResult.Length > 200 ? toolResult.Substring(0, 200) + "..." : toolResult; +await Response.WriteAsync( + $"event: tool_result\ndata: {JsonSerializer.Serialize(new {name=funcName, ok=true, preview, length=toolResult.Length})}\n\n"); +await Response.Body.FlushAsync(); +``` + +#### 프론트 SSE 처리(app.js 발췌) + +```javascript +// llmSend 안의 SSE 파싱 루프에서 +for (const line of lines) { + if (line.startsWith('event: tool_start')) { + eventType = 'tool_start'; + } else if (line.startsWith('event: tool_result')) { + eventType = 'tool_result'; + } else if (line.startsWith('data: ')) { + eventData = line.slice(6); + } +} + +if (eventType === 'tool_start') { + const t = JSON.parse(eventData); + llmAppendToolCard(t.name, t.args, 'running'); +} else if (eventType === 'tool_result') { + const t = JSON.parse(eventData); + llmUpdateToolCard(t.name, t.preview, t.length, t.ok); +} +``` + +### 6.2 추천 질문 칩 (welcome 화면 시드) + +``` +- "지금 활성 알람을 보여줘" +- "Unit A의 24시간 운전 상황을 요약해줘" +- "FIC-6113.PV 최근 1시간 추이" +- "오늘 발생한 디지털 이벤트 정리" +- "P-6201 펌프의 정비 이력" ← KB 연동 +- "이번 주 보고서를 작성해줘" +- "냉각수 펌프 토출 압력 태그를 찾아줘" ← find_tags +``` + +### 6.3 plant_context.md 시드 (개요) + +```markdown +# 플랜트 운전 컨텍스트 + +## 단위(Area / Unit) +- Unit A: ... +- Unit B: ... +- ... + +## 계기 명명 약어 +- FIC: Flow Indicator Controller (유량 지시 제어) +- FT: Flow Transmitter (유량 발신기) +- PT: Pressure Transmitter +- TI: Temperature Indicator +- LIC: Level Indicator Controller +- XV: Digital On/Off Valve +- ... + +## 태그 명명 규칙 +- 모두 소문자 (예: ficq-6113.pv) +- 접미사: .pv (Process Value), .sp (Setpoint), .op (Output), + .instate0..7 (Boolean 상태비트) + +## 시간대 +- DB 저장: UTC +- 사용자 입력: KST (UTC+9), 자동 변환 +- 응답 표시: KST + +## 사용 가능 도구 +- run_sql: PostgreSQL SELECT 실행 +- query_pv_history: 태그 이력 조회 +- query_events: 이벤트/알람 조회 +- active_alarms: 현재 진행 중 알람 +- search_kb: 사용자 지식 베이스 검색 +- find_tags: 자연어 → 태그 시맨틱 매칭 +- generate_status_report: 상황 보고 합성 + +## 예시 질문 +- "지금 활성 알람 보여줘" → active_alarms +- "Unit A 24시간 요약" → generate_status_report(scope='daily', area='unit-a') +- "정비이력 알려줘" → search_kb(['plant_operation','procedure']) +``` + +### 6.4 빌드/테스트 시 확인 사항 +- `dotnet build` — 경고는 기존 대비 신규 발생 없을 것 +- Qdrant 컬렉션 5개 생성 확인 (`curl http://localhost:6333/collections`) +- 비번 첫 설정 후 로그인/만료/재로그인 동작 +- 작은 텍스트 업로드 → 30초 내 status='indexed' +- 큰 PDF(OCR) 업로드 → 워커가 stage 단계 진행, 실패 시 attempts 카운트 +- 채팅에서 KB 인용 → 다운로드 링크 클릭 시 원본 다운로드 + +--- + +## 7. 결정 보류/추후 합의 항목 + +다음은 본 플랜에서는 결정하지 않고 구현 진행 중 별도 합의 예정: + +- **C9** NL2SQL 의도 라우터 도입 시점 — Phase 5 후 모델 호출 패턴을 측정 후 결정 +- **C10/C11** 대화 요약 / 에이전트 모드 — Phase 5 완료 후 사용성 평가에 따라 +- **현장 재고** 데이터 소스 — RAG로 흡수(MES 엑셀 정기 업로드) vs 별도 inventory_table — 데이터 출처 확정 후 결정 +- **임베딩 모델 최종 선택** — BGE-M3 vs multilingual-e5-large 한국어 sample 검증 후 결정 +- **세션 토큰 슬라이딩 갱신 / 다중 관리자 / IP 제한** — 운영 형태 확정 후 diff --git a/plans/채팅-페이지-플랜.md b/plans/채팅-페이지-플랜.md new file mode 100644 index 0000000..4e6468e --- /dev/null +++ b/plans/채팅-페이지-플랜.md @@ -0,0 +1,1389 @@ +# 로컬 LLM 채팅 페이지 — 상세 계획 및 코드 + +## 개요 + +ExperionCrawler SPA에 **탭 #13: 로컬 LLM 채팅**을 통합한다. +Ollama 로컬 LLM과 연결하여 폐쇄 네트워크 환경에서 채팅 UI를 제공. + +- **기술**: 순수 HTML/CSS/JS (빌드 없음) + C# ASP.NET Core 백엔드 프록시 +- **LLM 백엔드**: Ollama (`http://localhost:11434`) +- **스트리밍**: SSE (Server-Sent Events) +- **저장**: localStorage (서버 DB 없음) + +--- + +## 변경 파일 목록 + +| 파일 | 작업 | +|------|------| +| `src/Web/Controllers/OllamaController.cs` | 신규 생성 | +| `src/Web/Program.cs` | HttpClient 등록 추가 | +| `src/Web/wwwroot/index.html` | 탭 #13 + `
` 추가 | +| `src/Web/wwwroot/css/style.css` | 채팅 UI 스타일 추가 | +| `src/Web/wwwroot/js/app.js` | 채팅 JS 로직 추가 | + +--- + +## 1. 백엔드 — OllamaController.cs (신규) + +**경로**: `src/Web/Controllers/OllamaController.cs` + +```csharp +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Configuration; +using System.Text; +using System.Text.Json; + +namespace ExperionCrawler.Web.Controllers; + +[ApiController] +[Route("api/ollama")] +public class OllamaController : ControllerBase +{ + private readonly HttpClient _httpClient; + private readonly IConfiguration _config; + private readonly ILogger _logger; + + public OllamaController( + HttpClient httpClient, + IConfiguration config, + ILogger logger) + { + _httpClient = httpClient; + _config = config; + _logger = logger; + } + + string OllamaConfigPath + { + get + { + var mcpDir = _config["McpServer:WorkingDirectory"] ?? "../../mcp-server"; + if (!Path.IsPathRooted(mcpDir)) + mcpDir = Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), mcpDir)); + return Path.Combine(mcpDir, "ollama-config.json"); + } + } + + OllamaConfig LoadConfig() + { + try + { + var path = OllamaConfigPath; + if (File.Exists(path)) + { + var json = File.ReadAllText(path); + return JsonSerializer.Deserialize(json) ?? new OllamaConfig(); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "[OllamaController] 설정 로드 실패, 기본값 사용"); + } + return new OllamaConfig(); + } + + // ── 모델 목록 ────────────────────────────────────────── + [HttpGet("models")] + public async Task GetModels() + { + try + { + var cfg = LoadConfig(); + var res = await _httpClient.GetAsync($"{cfg.BaseUrl}/api/tags"); + if (!res.IsSuccessStatusCode) + return Ok(new { success = false, error = $"Ollama HTTP {(int)res.StatusCode}", models = Array.Empty() }); + + var body = await res.Content.ReadAsStringAsync(); + var doc = JsonDocument.Parse(body); + var models = new List(); + if (doc.RootElement.TryGetProperty("models", out var arr)) + { + foreach (var m in arr.EnumerateArray()) + { + if (m.TryGetProperty("name", out var name)) + models.Add(name.GetString() ?? ""); + } + } + return Ok(new { success = true, models = [.. models.OrderBy(x => x)] }); + } + catch (Exception ex) + { + _logger.LogError(ex, "[OllamaController] 모델 조회 실패"); + return Ok(new { success = false, error = ex.Message, models = Array.Empty() }); + } + } + + // ── 일반 채팅 (비스트리밍) ───────────────────────────── + [HttpPost("chat")] + public async Task Chat([FromBody] OllamaChatRequest req) + { + try + { + var cfg = LoadConfig(); + var payload = new + { + model = req.Model, + messages = req.Messages, + system = req.SystemPrompt, + stream = false + }; + var content = new StringContent( + JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"); + + var res = await _httpClient.PostAsync($"{cfg.BaseUrl}/api/chat", content); + if (!res.IsSuccessStatusCode) + { + var err = await res.Content.ReadAsStringAsync(); + return Ok(new { success = false, error = err }); + } + + var body = await res.Content.ReadAsStringAsync(); + var doc = JsonDocument.Parse(body); + string? reply = null; + if (doc.RootElement.TryGetProperty("message", out var msg) + && msg.TryGetProperty("content", out var cnt)) + { + reply = cnt.GetString(); + } + return Ok(new { success = true, reply }); + } + catch (Exception ex) + { + _logger.LogError(ex, "[OllamaController] 채팅 실패"); + return Ok(new { success = false, error = ex.Message, reply = (string?)null }); + } + } + + // ── 스트리밍 채팅 (SSE) ─────────────────────────────── + [HttpPost("chat/stream")] + public async Task OllamaChatStream([FromBody] OllamaChatRequest req) + { + Response.Headers.Append("Content-Type", "text/event-stream"); + Response.Headers.Append("Cache-Control", "no-cache"); + Response.Headers.Append("Connection", "keep-alive"); + Response.Headers.Append("X-Accel-Buffering", "no"); + + try + { + var cfg = LoadConfig(); + var payload = new + { + model = req.Model, + messages = req.Messages, + system = req.SystemPrompt, + stream = true + }; + var content = new StringContent( + JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"); + + var httpRequest = new HttpRequestMessage(HttpMethod.Post, $"{cfg.BaseUrl}/api/chat") + { + Content = content + }; + + var res = await _httpClient.SendAsync(httpRequest, HttpCompletionOption.ResponseHeadersRead, HttpContext.RequestAborted); + + if (!res.IsSuccessStatusCode) + { + await Response.WriteAsync($"event: error\ndata: {res.StatusCode}\n\n"); + await Response.Body.FlushAsync(); + return; + } + + var stream = await res.Content.ReadAsStreamAsync(); + var buffer = new byte[1024]; + var sb = new StringBuilder(); + int read; + + while ((read = await stream.ReadAsync(buffer, 0, buffer.Length, HttpContext.RequestAborted)) > 0) + { + if (HttpContext.RequestAborted.IsCancellationRequested) + break; + + sb.Append(Encoding.UTF8.GetString(buffer, 0, read)); + var lines = sb.ToString().Split('\n'); + sb.Clear(); + + for (int i = 0; i < lines.Length; i++) + { + var line = lines[i]; + // NDJSON: 각 줄이独立的 JSON 객체 + if (string.IsNullOrWhiteSpace(line)) + { + // 빈 줄 = NDJSON 레코드 구분자 + await Response.Body.FlushAsync(); + continue; + } + + // 마지막 줄이 불완전하면 sb에 재저장 + if (i == lines.Length - 1 && sb.Length == 0 && lines.Length > 1) + { + // OK + } + else if (i == lines.Length - 1) + { + // 마지막 라인일 수 있으므로 체크 + sb.Append(line); + continue; + } + + try + { + var doc = JsonDocument.Parse(line); + var json = line; // 원본 JSON 그대로 전달 + + await Response.WriteAsync($"event: message\ndata: {json}\n\n"); + await Response.Body.FlushAsync(); + } + catch + { + sb.Append(line); + } + } + } + + // 남은 데이터 처리 + if (sb.Length > 0) + { + try + { + await Response.WriteAsync($"event: message\ndata: {sb}\n\n"); + } + catch { } + } + + await Response.WriteAsync("event: done\ndata: {}\n\n"); + await Response.Body.FlushAsync(); + } + catch (OperationCanceledException) + { + // 클라이언트 연결 끊김 — 정상 종료 + } + catch (Exception ex) + { + _logger.LogError(ex, "[OllamaController] 스트리밍 오류"); + try + { + await Response.WriteAsync($"event: error\ndata: {JsonSerializer.Serialize(new { message = ex.Message })}\n\n"); + await Response.Body.FlushAsync(); + } + catch { } + } + } + + // ── 설정 조회/저장 ───────────────────────────────────── + [HttpGet("config")] + public IActionResult GetConfig() + { + var cfg = LoadConfig(); + return Ok(new { success = true, host = cfg.Host, port = cfg.Port, baseUrl = cfg.BaseUrl }); + } + + [HttpPost("config")] + public IActionResult SetConfig([FromBody] OllamaConfig cfg) + { + try + { + var path = OllamaConfigPath; + var dir = Path.GetDirectoryName(path); + if (!Directory.Exists(dir)) + Directory.CreateDirectory(dir); + + var json = JsonSerializer.Serialize(new + { + host = cfg.Host, + port = cfg.Port + }, new JsonSerializerOptions { WriteIndented = true }); + File.WriteAllText(path, json); + _logger.LogInformation("[OllamaController] 설정 저장: {Host}:{Port}", cfg.Host, cfg.Port); + return Ok(new { success = true, host = cfg.Host, port = cfg.Port, baseUrl = cfg.BaseUrl }); + } + catch (Exception ex) + { + _logger.LogError(ex, "[OllamaController] 설정 저장 실패"); + return Ok(new { success = false, error = ex.Message }); + } + } + + // ── 연결 테스트 ──────────────────────────────────────── + [HttpGet("ping")] + public async Task Ping() + { + try + { + var cfg = LoadConfig(); + var res = await _httpClient.GetAsync($"{cfg.BaseUrl}/api/tags"); + return Ok(new { success = res.IsSuccessStatusCode, host = cfg.Host, port = cfg.Port }); + } + catch (Exception ex) + { + return Ok(new { success = false, error = ex.Message }); + } + } +} + +// ── DTOs ───────────────────────────────────────────────────────────────────── + +public class OllamaConfig +{ + public string Host { get; set; } = "localhost"; + public int Port { get; set; } = 11434; + + public string BaseUrl => $"http://{Host}:{Port}"; +} + +public class OllamaChatRequest +{ + public string Model { get; set; } = ""; + public OllamaMessage[] Messages { get; set; } = Array.Empty(); + public string? SystemPrompt { get; set; } +} + +public class OllamaMessage +{ + public string Role { get; set; } = ""; + public string Content { get; set; } = ""; +} +``` + +--- + +## 2. Program.cs 변경 + +**추가 위치**: `McpClient` 등록 직후 (91줄 근처) + +```csharp +// ── Ollama HttpClient ─────────────────────────────────────────────────────── +builder.Services.AddHttpClient(_ => new Uri("http://localhost:11434")) + .SetHandlerLifetime(Timeout.InfiniteTimeSpan); +builder.Services.AddSingleton(sp => + sp.GetRequiredService().CreateClient("Ollama")); +``` + +`OllamaController`에 HttpClient를 주입받게 하려면 인터페이스가 필요: + +**`src/Core/Application/Interfaces/IOllamaHttpClient.cs` (신규, 빈 인터페이스)**: +```csharp +namespace ExperionCrawler.Core.Application.Interfaces; + +// HttpClient 용 마커 인터페이스 (DI 용) +public interface IOllamaHttpClient +{ +} +``` + +실제로는 `OllamaController` 생성자에 `HttpClient`를 직접 받고, Program.cs에서: + +```csharp +builder.Services.AddHttpClient("Ollama", c => +{ + c.BaseAddress = new Uri("http://localhost:11434"); + c.Timeout = TimeSpan.FromSeconds(1800); +}) +.SetHandlerLifetime(Timeout.InfiniteTimeSpan); +``` + +그리고 `OllamaController` 생성자에서 `IHttpClientFactory` 사용: + +```csharp +public OllamaController( + IHttpClientFactory httpClientFactory, + IConfiguration config, + ILogger logger) +{ + _httpClient = httpClientFactory.CreateClient("Ollama"); + _config = config; + _logger = logger; +} +``` + +--- + +## 3. index.html 변경 + +### 3.1 Sidebar — 탭 추가 + +**위치**: `
  • ` 직후 (82줄 근처) + +```html +
  • +``` + +### 3.2 Main — `
    ` 추가 + +**위치**: `
    ` (pane-evt 종료) 직후, `` 직전 (1216줄 근처) + +```html + +
    +
    +
    +

    로컬 LLM 채팅

    +

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

    +
    +
    LLM / CHAT
    +
    + +
    + +
    +
    + 대화 목록 + +
    +
    +
    대화가 없습니다. + 버튼을 눌러 새 대화를 시작하세요.
    +
    + +
    + + +
    + +
    +
    +
    + + +
    + + +
    +
    + + +
    +
    + + + + + +
    +
    +
    💬
    +
    새 대화를 시작하세요
    +
    모델을 선택하고 메시지를 입력하세요
    +
    +
    + + +
    +
    + +
    + + +
    +
    +
    +
    +
    +
    +``` + +--- + +## 4. CSS — style.css 추가 + +**파일**: `src/Web/wwwroot/css/style.css` — 파일 끝에 추가 + +```css +/* ═══════════════════════════════════════════════════════ + 로컬 LLM 채팅 +══════════════════════════════════════════════════════ */ + +.llm-layout { + display: flex; + gap: 0; + height: calc(100vh - var(--sw) - 140px); + min-height: 400px; + border: 1px solid var(--bd); + border-radius: var(--r); + overflow: hidden; + background: var(--s1); +} + +/* ── 왼쪽 사이드바 ──────────────────────────────────── */ +.llm-sidebar { + width: 200px; + flex-shrink: 0; + background: var(--s2); + border-right: 1px solid var(--bd); + display: flex; + flex-direction: column; +} + +.llm-sidebar-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 12px; + border-bottom: 1px solid var(--bd); +} + +.llm-sidebar-title { + font-size: 12px; + font-weight: 700; + color: var(--t1); +} + +.llm-session-list { + flex: 1; + overflow-y: auto; + padding: 6px; +} + +.llm-session-item { + padding: 8px 10px; + border-radius: 6px; + cursor: pointer; + font-size: 12px; + color: var(--t2); + transition: all var(--tr); + margin-bottom: 2px; + display: flex; + justify-content: space-between; + align-items: center; + gap: 6px; +} + +.llm-session-item:hover { + background: var(--s3); + color: var(--t1); +} + +.llm-session-item.active { + background: var(--ag); + color: var(--a); +} + +.llm-session-item .llm-sess-title { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.llm-session-item .llm-sess-del { + opacity: 0; + color: var(--red); + font-size: 14px; + transition: opacity var(--tr); + flex-shrink: 0; +} + +.llm-session-item:hover .llm-sess-del { + opacity: 1; +} + +.llm-sidebar-footer { + padding: 8px; + border-top: 1px solid var(--bd); +} + +.llm-empty { + padding: 20px 12px; + font-size: 11px; + color: var(--t2); + text-align: center; + line-height: 1.6; +} + +/* ── 메인 영역 ──────────────────────────────────────── */ +.llm-main { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; +} + +/* 상단 바 */ +.llm-header { + display: flex; + justify-content: space-between; + align-items: flex-end; + padding: 10px 14px; + border-bottom: 1px solid var(--bd); + background: var(--s2); + gap: 10px; +} + +.llm-header-left, +.llm-header-right { + display: flex; + align-items: flex-end; + gap: 8px; +} + +.llm-conn-dot { + font-size: 12px; + color: var(--t2); + padding-bottom: 6px; +} + +.llm-conn-dot.connected { + color: var(--grn); +} + +.llm-conn-dot.error { + color: var(--red); +} + +/* 설정 패널 */ +.llm-settings { + background: var(--s3); + border-bottom: 1px solid var(--bd); + padding: 12px 14px; +} + +/* 메시지 영역 */ +.llm-messages { + flex: 1; + overflow-y: auto; + padding: 16px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.llm-welcome { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + color: var(--t2); + gap: 8px; +} + +.llm-welcome-icon { + font-size: 40px; + opacity: 0.3; +} + +.llm-welcome-text { + font-size: 14px; + font-weight: 600; +} + +.llm-welcome-hint { + font-size: 12px; +} + +/* 메시지 버블 */ +.llm-msg { + display: flex; + gap: 10px; + max-width: 85%; + animation: llm-fade-in 0.2s ease; +} + +@keyframes llm-fade-in { + from { opacity: 0; transform: translateY(6px); } + to { opacity: 1; transform: translateY(0); } +} + +.llm-msg.user { + align-self: flex-end; + flex-direction: row-reverse; +} + +.llm-msg.assistant { + align-self: flex-start; +} + +.llm-msg-avatar { + width: 28px; + height: 28px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + font-weight: 700; + flex-shrink: 0; + margin-top: 2px; +} + +.llm-msg.user .llm-msg-avatar { + background: var(--a); + color: #fff; +} + +.llm-msg.assistant .llm-msg-avatar { + background: var(--s4); + color: var(--t1); +} + +.llm-msg-bubble { + padding: 10px 14px; + border-radius: var(--r); + font-size: 13px; + line-height: 1.6; + word-break: break-word; + white-space: pre-wrap; +} + +.llm-msg.user .llm-msg-bubble { + background: var(--a); + color: #fff; + border-bottom-right-radius: 2px; +} + +.llm-msg.assistant .llm-msg-bubble { + background: var(--s3); + color: var(--t0); + border: 1px solid var(--bd); + border-bottom-left-radius: 2px; +} + +.llm-msg-bubble code { + background: rgba(0,0,0,0.3); + padding: 1px 5px; + border-radius: 3px; + font-family: var(--fm); + font-size: 12px; +} + +.llm-msg-bubble pre { + background: rgba(0,0,0,0.4); + padding: 10px 12px; + border-radius: 6px; + overflow-x: auto; + margin: 8px 0; + font-family: var(--fm); + font-size: 12px; + line-height: 1.5; +} + +.llm-msg-bubble pre code { + background: none; + padding: 0; +} + +/* 타이핑 인디케이터 */ +.llm-typing { + display: flex; + gap: 4px; + padding: 4px 0; +} + +.llm-typing span { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--t2); + animation: llm-bounce 1.2s infinite; +} + +.llm-typing span:nth-child(2) { animation-delay: 0.2s; } +.llm-typing span:nth-child(3) { animation-delay: 0.4s; } + +@keyframes llm-bounce { + 0%, 60%, 100% { transform: translateY(0); opacity: 0.4; } + 30% { transform: translateY(-6px); opacity: 1; } +} + +/* ── 입력 영역 ──────────────────────────────────────── */ +.llm-input-area { + padding: 12px 14px; + border-top: 1px solid var(--bd); + background: var(--s2); +} + +.llm-input-box { + display: flex; + align-items: flex-end; + gap: 8px; +} + +.llm-textarea { + flex: 1; + background: var(--s3); + border: 1px solid var(--bd); + border-radius: var(--r); + padding: 10px 14px; + color: var(--t0); + font-family: var(--ff); + font-size: 13px; + resize: none; + outline: none; + line-height: 1.5; + max-height: 150px; + transition: border-color var(--tr); +} + +.llm-textarea:focus { + border-color: var(--a); +} + +.llm-textarea::placeholder { + color: var(--t2); +} + +.llm-input-btns { + display: flex; + gap: 4px; + flex-shrink: 0; +} + +/* ── 반응형 ─────────────────────────────────────────── */ +@media (max-width: 768px) { + .llm-sidebar { display: none; } + .llm-layout { height: calc(100vh - var(--sw) - 120px); } +} +``` + +--- + +## 5. JavaScript — app.js 추가 + +**파일**: `src/Web/wwwroot/js/app.js` — 파일 끝에 추가 + +```javascript +/* ═══════════════════════════════════════════════════════ + 13 로컬 LLM 채팅 +══════════════════════════════════════════════════════ */ + +// ── 상태 ─────────────────────────────────────────────── +let llmSessions = JSON.parse(localStorage.getItem('llmSessions') || '[]'); +let llmActiveSessionId = localStorage.getItem('llmActiveSessionId') || ''; +let llmAbortController = null; +let llmIsStreaming = false; + +// ── 초기화 ───────────────────────────────────────────── +document.querySelectorAll('[data-tab="llmchat"]').forEach(item => { + item.addEventListener('click', () => { + llmLoadModels(); + llmRenderSessionList(); + llmLoadActiveSession(); + llmLoadConfigToUI(); + }); +}); + +// ── 세션 관리 ────────────────────────────────────────── +function llmCreateSession() { + const id = 'llm_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8); + const session = { + id, + title: '새 대화', + model: '', + systemPrompt: '', + messages: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString() + }; + llmSessions.unshift(session); + llmActiveSessionId = id; + llmSaveSessions(); + llmRenderSessionList(); + llmRenderMessages(); + return session; +} + +function llmNewSession() { + llmCreateSession(); + document.getElementById('llm-input').focus(); +} + +function llmSwitchSession(id) { + llmActiveSessionId = id; + localStorage.setItem('llmActiveSessionId', id); + llmRenderSessionList(); + llmRenderMessages(); +} + +function llmDeleteSession(id, e) { + e.stopPropagation(); + if (!confirm('이 대화를 삭제하시겠습니까?')) return; + llmSessions = llmSessions.filter(s => s.id !== id); + llmSaveSessions(); + if (llmActiveSessionId === id) { + llmActiveSessionId = llmSessions.length > 0 ? llmSessions[0].id : ''; + localStorage.setItem('llmActiveSessionId', llmActiveSessionId); + } + llmRenderSessionList(); + llmRenderMessages(); +} + +function llmGetActiveSession() { + return llmSessions.find(s => s.id === llmActiveSessionId) || null; +} + +function llmSaveSessions() { + localStorage.setItem('llmSessions', JSON.stringify(llmSessions)); + localStorage.setItem('llmActiveSessionId', llmActiveSessionId); +} + +function llmSaveSessionMeta() { + const sess = llmGetActiveSession(); + if (!sess) return; + sess.model = document.getElementById('llm-model-select').value; + sess.systemPrompt = document.getElementById('llm-system-prompt').value.trim(); + sess.updatedAt = new Date().toISOString(); + llmSaveSessions(); + llmRenderSessionList(); +} + +// ── 세션 목록 렌더링 ─────────────────────────────────── +function llmRenderSessionList() { + const el = document.getElementById('llm-session-list'); + if (!el) return; + + if (llmSessions.length === 0) { + el.innerHTML = '
    대화가 없습니다.
    + 버튼을 눌러 새 대화를 시작하세요.
    '; + return; + } + + el.innerHTML = llmSessions.map(s => { + const isActive = s.id === llmActiveSessionId; + const title = esc(s.title || '제목 없음'); + const time = s.updatedAt ? new Date(s.updatedAt).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' }) : ''; + return ` +
    + ${title} + × +
    + `; + }).join(''); +} + +// ── 메시지 렌더링 ────────────────────────────────────── +function llmRenderMessages() { + const el = document.getElementById('llm-messages'); + if (!el) return; + + const sess = llmGetActiveSession(); + if (!sess || sess.messages.length === 0) { + el.innerHTML = ` +
    +
    💬
    +
    새 대화를 시작하세요
    +
    모델을 선택하고 메시지를 입력하세요
    +
    + `; + // 모델/시스템 프롬프트 불러오기 + if (sess) { + const sel = document.getElementById('llm-model-select'); + if (sel && sess.model) sel.value = sess.model; + const ta = document.getElementById('llm-system-prompt'); + if (ta && sess.systemPrompt) ta.value = sess.systemPrompt; + } + return; + } + + // 모델/시스템 프롬프트 불러오기 + const sel = document.getElementById('llm-model-select'); + if (sel && sess.model) sel.value = sess.model; + const ta = document.getElementById('llm-system-prompt'); + if (ta && sess.systemPrompt) ta.value = sess.systemPrompt; + + el.innerHTML = sess.messages.map(m => { + const role = m.role === 'user' ? 'user' : 'assistant'; + const avatar = role === 'user' ? 'U' : 'AI'; + const content = llmFormatMessage(m.content); + return ` +
    +
    ${avatar}
    +
    ${content}
    +
    + `; + }).join(''); + + el.scrollTop = el.scrollHeight; +} + +function llmFormatMessage(text) { + // 코드 블록 처리 + text = text.replace(/```(\w*)\n?([\s\S]*?)```/g, (m, lang, code) => { + return `
    ${esc(code.trim())}
    `; + }); + // 인라인 코드 + text = text.replace(/`([^`]+)`/g, '$1'); + // 줄바꿈 + text = esc(text).replace(/\n/g, '
    '); + return text; +} + +// ── 모델 목록 로드 ───────────────────────────────────── +async function llmLoadModels() { + try { + const d = await api('GET', '/api/ollama/models'); + const sel = document.getElementById('llm-model-select'); + if (!sel) return; + + const currentVal = sel.value; + sel.innerHTML = ''; + + if (d.success && d.models) { + d.models.forEach(m => { + const opt = document.createElement('option'); + opt.value = m; + opt.textContent = m; + sel.appendChild(opt); + }); + // 이전에 선택된 모델 유지 + if (currentVal && [...sel.options].some(o => o.value === currentVal)) { + sel.value = currentVal; + } + } + + // 연결 상태 표시 + const dot = document.getElementById('llm-conn-status'); + if (dot) { + dot.className = d.success ? 'llm-conn-dot connected' : 'llm-conn-dot error'; + dot.title = d.success ? 'Ollama 연결됨' : 'Ollama 연결 실패'; + } + } catch (e) { + const dot = document.getElementById('llm-conn-status'); + if (dot) { + dot.className = 'llm-conn-dot error'; + dot.title = '연결 실패: ' + e.message; + } + } +} + +// ── 메시지 전송 (스트리밍) ───────────────────────────── +async function llmSend() { + const input = document.getElementById('llm-input'); + const text = input.value.trim(); + if (!text || llmIsStreaming) return; + + // 세션이 없으면 생성 + let sess = llmGetActiveSession(); + if (!sess) { + sess = llmCreateSession(); + } + + const model = document.getElementById('llm-model-select').value; + if (!model) { + alert('모델을 선택하세요.'); + return; + } + + const systemPrompt = document.getElementById('llm-system-prompt').value.trim(); + + // 사용자 메시지 추가 + sess.messages.push({ role: 'user', content: text }); + input.value = ''; + input.style.height = 'auto'; + llmSaveSessions(); + llmRenderMessages(); + llmRenderSessionList(); + + // 모델/시스템 프롬프트 저장 + sess.model = model; + sess.systemPrompt = systemPrompt; + + // assistant 메시지 자리 표시 + const assistantMsg = { role: 'assistant', content: '' }; + sess.messages.push(assistantMsg); + + // 스트리밍 시작 + llmIsStreaming = true; + llmUpdateButtons(); + llmRenderMessages(); + + // assistant 버블에 타이핑 인디케이터 추가 + const messagesEl = document.getElementById('llm-messages'); + const typingDiv = document.createElement('div'); + typingDiv.className = 'llm-msg assistant'; + typingDiv.id = 'llm-streaming-msg'; + typingDiv.innerHTML = ` +
    AI
    +
    +
    +
    + `; + messagesEl.appendChild(typingDiv); + messagesEl.scrollTop = messagesEl.scrollHeight; + + llmAbortController = new AbortController(); + + try { + const res = await fetch('/api/ollama/chat/stream', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model, + messages: sess.messages.slice(0, -1), // assistant 자리 표시 제외 + systemPrompt: systemPrompt || undefined + }), + signal: llmAbortController.signal + }); + + if (!res.ok) { + throw new Error(`HTTP ${res.status}`); + } + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + + // SSE 파싱: event: ...\ndata: ...\n\n + const parts = buffer.split('\n\n'); + buffer = parts.pop() || ''; + + for (const part of parts) { + const lines = part.split('\n'); + let eventData = ''; + for (const line of lines) { + if (line.startsWith('data: ')) { + eventData = line.slice(6); + } else if (line.startsWith('event: error')) { + throw new Error(eventData || '스트리밍 오류'); + } else if (line.startsWith('event: done')) { + break; + } + } + + if (eventData && eventData !== '{}') { + try { + const json = JSON.parse(eventData); + if (json.message && json.message.content) { + assistantMsg.content += json.message.content; + llmUpdateStreamingMessage(assistantMsg.content); + } else if (json.response) { + // /api/generate 형식 호환 + assistantMsg.content += json.response; + llmUpdateStreamingMessage(assistantMsg.content); + } + } catch { + // 파싱 실패 시 무시 (불완전 데이터) + } + } + } + } + + // 스트리밍 완료 + sess.updatedAt = new Date().toISOString(); + llmSaveSessions(); + llmRenderMessages(); + llmRenderSessionList(); + + } catch (e) { + if (e.name === 'AbortError') { + // 사용자가 중단 — 현재까지의 내용 유지 + if (assistantMsg.content) { + sess.updatedAt = new Date().toISOString(); + llmSaveSessions(); + llmRenderMessages(); + } else { + // 중단 시 내용 없으면 메시지 제거 + sess.messages.pop(); + llmSaveSessions(); + llmRenderMessages(); + } + } else { + // 에러 메시지 표시 + assistantMsg.content = `❌ 오류: ${e.message}`; + sess.messages.pop(); + sess.messages.push(assistantMsg); + sess.updatedAt = new Date().toISOString(); + llmSaveSessions(); + llmRenderMessages(); + } + } finally { + llmIsStreaming = false; + llmAbortController = null; + llmUpdateButtons(); + } +} + +function llmUpdateStreamingMessage(content) { + let msgEl = document.getElementById('llm-streaming-msg'); + if (!msgEl) { + const messagesEl = document.getElementById('llm-messages'); + msgEl = document.createElement('div'); + msgEl.className = 'llm-msg assistant'; + msgEl.id = 'llm-streaming-msg'; + msgEl.innerHTML = ` +
    AI
    +
    + `; + messagesEl.appendChild(msgEl); + } + + const bubble = msgEl.querySelector('.llm-msg-bubble'); + if (bubble) { + bubble.innerHTML = llmFormatMessage(content); + } + + const messagesEl = document.getElementById('llm-messages'); + if (messagesEl) { + messagesEl.scrollTop = messagesEl.scrollHeight; + } +} + +function llmStop() { + if (llmAbortController) { + llmAbortController.abort(); + } +} + +function llmUpdateButtons() { + const sendBtn = document.getElementById('llm-send-btn'); + const stopBtn = document.getElementById('llm-stop-btn'); + if (sendBtn) sendBtn.disabled = llmIsStreaming; + if (stopBtn) stopBtn.style.display = llmIsStreaming ? 'inline-flex' : 'none'; +} + +// ── 입력 키 처리 ─────────────────────────────────────── +function llmInputKeydown(e) { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + llmSend(); + } + // 자동 높이 조절 + const ta = e.target; + setTimeout(() => { + ta.style.height = 'auto'; + ta.style.height = Math.min(ta.scrollHeight, 150) + 'px'; + }, 0); +} + +// ── 세션 초기화 ──────────────────────────────────────── +function llmClearSession() { + const sess = llmGetActiveSession(); + if (!sess) return; + if (!confirm('현재 대화의 메시지를 모두 지우시겠습니까?')) return; + sess.messages = []; + sess.updatedAt = new Date().toISOString(); + llmSaveSessions(); + llmRenderMessages(); + llmRenderSessionList(); +} + +// ── 설정 ─────────────────────────────────────────────── +function llmToggleSettings() { + const panel = document.getElementById('llm-settings-panel'); + if (panel) panel.classList.toggle('hidden'); +} + +async function llmSaveConfig() { + const host = document.getElementById('llm-host').value.trim() || 'localhost'; + const port = parseInt(document.getElementById('llm-port').value) || 11434; + try { + const d = await api('POST', '/api/ollama/config', { host, port }); + if (d.success) { + // Ollama HttpClient는 BaseAddress가 고정되므로 재시작 필요 + alert('설정 저장 완료. 변경 사항 적용을 위해 페이지를 새로고침하세요.'); + } else { + alert('설정 저장 실패: ' + (d.error || '알 수 없는 오류')); + } + } catch (e) { + alert('설정 저장 실패: ' + e.message); + } +} + +async function llmTestConnection() { + try { + const d = await api('GET', '/api/ollama/ping'); + const dot = document.getElementById('llm-conn-status'); + if (dot) { + dot.className = d.success ? 'llm-conn-dot connected' : 'llm-conn-dot error'; + dot.title = d.success ? 'Ollama 연결됨' : `Ollama 연결 실패: ${d.error || ''}`; + } + alert(d.success ? 'Ollama 연결 성공!' : `Ollama 연결 실패: ${d.error || ''}`); + } catch (e) { + alert('연결 테스트 실패: ' + e.message); + } +} + +function llmLoadConfigToUI() { + api('GET', '/api/ollama/config').then(d => { + if (d.success) { + const hostEl = document.getElementById('llm-host'); + const portEl = document.getElementById('llm-port'); + if (hostEl && d.host) hostEl.value = d.host; + if (portEl && d.port) portEl.value = d.port; + } + }).catch(() => {}); +} + +function llmLoadActiveSession() { + if (llmActiveSessionId && !llmSessions.find(s => s.id === llmActiveSessionId)) { + llmActiveSessionId = ''; + localStorage.setItem('llmActiveSessionId', ''); + } +} + +// ── 전체 내보내기 ────────────────────────────────────── +function llmExportAll() { + if (llmSessions.length === 0) { + alert('내보낼 대화가 없습니다.'); + return; + } + const text = llmSessions.map(s => { + const header = `=== ${s.title} (${new Date(s.updatedAt).toLocaleString('ko-KR')}) ===`; + const msgs = s.messages.map(m => `[${m.role}] ${m.content}`).join('\n\n'); + return `${header}\n\n${msgs}`; + }).join('\n\n' + '='.repeat(50) + '\n\n'); + + const blob = new Blob([text], { type: 'text/plain;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `llm-chat-${new Date().toISOString().slice(0, 10)}.txt`; + a.click(); + URL.revokeObjectURL(url); +} +``` + +--- + +## 6. 구현 순서 + +1. **OllamaController.cs** 생성 +2. **Program.cs** 수정 (HttpClient 등록) +3. **index.html** 수정 (탭 + pane 추가) +4. **style.css** 수정 (채팅 스타일 추가) +5. **app.js** 수정 (JS 로직 추가) +6. **dotnet build** 검증 +7. Ollama 실행 후 테스트 + +--- + +## 7. 주의사항 + +- **Ollama 실행 필수**: `ollama serve`가 `localhost:11434`에서 실행 중이어야 함 +- **모델 사전 다운로드**: `ollama pull qwen3` 등 사용하려는 모델 미리 다운로드 +- **CORS**: Ollama가 로컬 실행이므로 CORS 문제 없음 (C# 백엔드 프록시 경유) +- **타임아웃**: 스트리밍 응답은 긴 시간 소요 가능 — `HttpClient.Timeout` 1800s 설정 +- **localStorage 용량**: 대화 기록이 많으면 localStorage 한도(5MB) 초과 가능 — 필요시 세션 정리 기능 추가 +- **HttpClient BaseAddress 변경**: 설정 변경 시 재시작 필요 (런타임 변경 불가) — 프론트엔드에서 알림 diff --git a/src/Web/Controllers/ExperionControllers.cs b/src/Web/Controllers/ExperionControllers.cs index f03ddb2..a0a7d9a 100644 --- a/src/Web/Controllers/ExperionControllers.cs +++ b/src/Web/Controllers/ExperionControllers.cs @@ -1350,7 +1350,7 @@ public class LlmConfigController : ControllerBase return Ok(new { success = false, error = "vllm_model is required" }); } - var json = $"{{\"vllm_model\": \"{model}\"}}"; + var json = System.Text.Json.JsonSerializer.Serialize(new { vllm_model = model }); System.IO.File.WriteAllText(path, json); _logger.LogInformation("[LlmConfigController] 모델 변경: {Model}", model); return Ok(new { success = true, vllmModel = model }); diff --git a/src/Web/Controllers/OllamaController.cs b/src/Web/Controllers/OllamaController.cs new file mode 100644 index 0000000..314ff83 --- /dev/null +++ b/src/Web/Controllers/OllamaController.cs @@ -0,0 +1,835 @@ +using ExperionCrawler.Infrastructure.Mcp; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Configuration; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace ExperionCrawler.Web.Controllers; + +[ApiController] +[Route("api/ollama")] +public class OllamaController : ControllerBase +{ + private readonly HttpClient _httpClient; + private readonly HttpClient _vllmClient; + private readonly IConfiguration _config; + private readonly ILogger _logger; + private readonly McpClient _mcpClient; + + public OllamaController( + IHttpClientFactory httpClientFactory, + IConfiguration config, + ILogger logger, + McpClient mcpClient) + { + _httpClient = httpClientFactory.CreateClient("Ollama"); + _vllmClient = httpClientFactory.CreateClient("Vllm"); + _config = config; + _logger = logger; + _mcpClient = mcpClient; + } + + 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 (System.IO.File.Exists(path)) + { + var json = System.IO.File.ReadAllText(path); + return JsonSerializer.Deserialize(json) ?? new OllamaConfig(); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "[OllamaController] 설정 로드 실패, 기본값 사용"); + } + return new OllamaConfig(); + } + + [HttpGet("models")] + public async Task GetModels() + { + try + { + var cfg = LoadConfig(); + var res = await _httpClient.GetAsync($"{cfg.BaseUrl}/api/tags"); + if (!res.IsSuccessStatusCode) + return Ok(new { success = false, error = $"Ollama HTTP {(int)res.StatusCode}", models = Array.Empty() }); + + var body = await res.Content.ReadAsStringAsync(); + var doc = JsonDocument.Parse(body); + var models = new List(); + if (doc.RootElement.TryGetProperty("models", out var arr)) + { + foreach (var m in arr.EnumerateArray()) + { + if (m.TryGetProperty("name", out var name)) + models.Add(name.GetString() ?? ""); + } + } + return Ok(new { success = true, models = models.OrderBy(x => x).ToArray() }); + } + catch (Exception ex) + { + _logger.LogError(ex, "[OllamaController] 모델 조회 실패"); + return Ok(new { success = false, error = ex.Message, models = Array.Empty() }); + } + } + + [HttpPost("chat")] + public async Task Chat([FromBody] OllamaChatRequest req) + { + try + { + var cfg = LoadConfig(); + var payload = new + { + model = req.Model, + messages = req.Messages, + system = req.SystemPrompt, + stream = false + }; + var content = new StringContent( + JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"); + + var res = await _httpClient.PostAsync($"{cfg.BaseUrl}/api/chat", content); + if (!res.IsSuccessStatusCode) + { + var err = await res.Content.ReadAsStringAsync(); + return Ok(new { success = false, error = err }); + } + + var body = await res.Content.ReadAsStringAsync(); + var doc = JsonDocument.Parse(body); + string? reply = null; + if (doc.RootElement.TryGetProperty("message", out var msg) + && msg.TryGetProperty("content", out var cnt)) + { + reply = cnt.GetString(); + } + return Ok(new { success = true, reply }); + } + catch (Exception ex) + { + _logger.LogError(ex, "[OllamaController] 채팅 실패"); + return Ok(new { success = false, error = ex.Message, reply = (string?)null }); + } + } + + [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(); + using var reader = new StreamReader(stream, Encoding.UTF8); + while (true) + { + if (HttpContext.RequestAborted.IsCancellationRequested) break; + var line = await reader.ReadLineAsync(); + if (line == null) break; + if (string.IsNullOrWhiteSpace(line)) continue; + + try + { + var doc = JsonDocument.Parse(line); + await Response.WriteAsync($"event: message\ndata: {line}\n\n"); + await Response.Body.FlushAsync(); + + if (doc.RootElement.TryGetProperty("done", out var done) && done.GetBoolean()) + break; + } + 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 (dir != null && !System.IO.Directory.Exists(dir)) + System.IO.Directory.CreateDirectory(dir); + + var json = JsonSerializer.Serialize(new + { + host = cfg.Host, + port = cfg.Port + }, new JsonSerializerOptions { WriteIndented = true }); + System.IO.File.WriteAllText(path, json); + _logger.LogInformation("[OllamaController] 설정 저장: {Host}:{Port}", cfg.Host, cfg.Port); + return Ok(new { success = true, host = cfg.Host, port = cfg.Port, baseUrl = cfg.BaseUrl }); + } + catch (Exception ex) + { + _logger.LogError(ex, "[OllamaController] 설정 저장 실패"); + return Ok(new { success = false, error = ex.Message }); + } + } + + [HttpGet("ping")] + public async Task Ping() + { + try + { + var cfg = LoadConfig(); + var res = await _httpClient.GetAsync($"{cfg.BaseUrl}/api/tags"); + return Ok(new { success = res.IsSuccessStatusCode, host = cfg.Host, port = cfg.Port }); + } + catch (Exception ex) + { + return Ok(new { success = false, error = ex.Message }); + } + } + + // ── vLLM (OpenAI-compatible API) ───────────────────────────────────── + + string VllmModelPath + { + get + { + var mcpDir = _config["McpServer:WorkingDirectory"] ?? "../../mcp-server"; + if (!Path.IsPathRooted(mcpDir)) + mcpDir = Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), mcpDir)); + return Path.Combine(mcpDir, "llm-model.json"); + } + } + + string LoadVllmModel() + { + try + { + var path = VllmModelPath; + if (System.IO.File.Exists(path)) + { + var json = System.IO.File.ReadAllText(path); + var doc = JsonDocument.Parse(json); + return doc.RootElement.TryGetProperty("vllm_model", out var v) ? v.GetString() ?? "Qwen3.6-27B-FP8" : "Qwen3.6-27B-FP8"; + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "[OllamaController] vLLM 모델 로드 실패"); + } + return "Qwen3.6-27B-FP8"; + } + + [HttpGet("vllm/models")] + public async Task GetVllmModels() + { + try + { + var res = await _vllmClient.GetAsync("/v1/models"); + if (!res.IsSuccessStatusCode) + return Ok(new { success = false, error = $"vLLM HTTP {(int)res.StatusCode}", models = Array.Empty() }); + + var body = await res.Content.ReadAsStringAsync(); + var doc = JsonDocument.Parse(body); + var models = new List(); + if (doc.RootElement.TryGetProperty("data", out var arr)) + { + foreach (var m in arr.EnumerateArray()) + { + if (m.TryGetProperty("id", out var name)) + models.Add(name.GetString() ?? ""); + } + } + return Ok(new { success = true, models = models.OrderBy(x => x).ToArray() }); + } + catch (Exception ex) + { + _logger.LogError(ex, "[OllamaController] vLLM 모델 조회 실패"); + return Ok(new { success = false, error = ex.Message, models = Array.Empty() }); + } + } + + [HttpPost("vllm/chat")] + public async Task VllmChat([FromBody] OllamaChatRequest req) + { + try + { + var model = req.Model; + var msgList = new List(); + if (req.SystemPrompt != null) + msgList.Add(new { role = "system", content = req.SystemPrompt }); + foreach (var m in req.Messages) + msgList.Add(m); + var payload = new + { + model, + messages = msgList, + stream = false + }; + var content = new StringContent( + JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"); + + var res = await _vllmClient.PostAsync("/v1/chat/completions", 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("choices", out var choices) && choices.GetArrayLength() > 0) + { + var first = choices[0]; + if (first.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] vLLM 채팅 실패"); + return Ok(new { success = false, error = ex.Message, reply = (string?)null }); + } + } + + [HttpPost("vllm/chat/stream")] + public async Task VllmChatStream([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 hasTools = req.Tools != null && req.Tools.Length > 0; + if (hasTools) + { + await VllmChatStreamWithTools(req); + } + else + { + await VllmChatStreamSimple(req); + } + } + catch (OperationCanceledException) + { + } + catch (Exception ex) + { + _logger.LogError(ex, "[OllamaController] vLLM 스트리밍 오류"); + try + { + await Response.WriteAsync($"event: error\ndata: {JsonSerializer.Serialize(new { message = ex.Message })}\n\n"); + await Response.Body.FlushAsync(); + } + catch { } + } + } + + private async Task VllmChatStreamSimple(OllamaChatRequest req) + { + var msgList = new List(); + if (req.SystemPrompt != null) + msgList.Add(new { role = "system", content = req.SystemPrompt }); + foreach (var m in req.Messages) + msgList.Add(m); + var payload = new + { + model = req.Model, + messages = msgList, + stream = true + }; + var content = new StringContent( + JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"); + + var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/v1/chat/completions") + { + Content = content + }; + + var res = await _vllmClient.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(); + using var reader = new StreamReader(stream, Encoding.UTF8); + while (true) + { + if (HttpContext.RequestAborted.IsCancellationRequested) break; + var line = await reader.ReadLineAsync(); + if (line == null) break; + + if (line.StartsWith("data: ") == false) continue; + var data = line.Substring(6); + if (string.IsNullOrWhiteSpace(data) || data == "[DONE]") continue; + + try + { + var doc = JsonDocument.Parse(data); + if (doc.RootElement.TryGetProperty("choices", out var choices) && choices.GetArrayLength() > 0) + { + var delta = choices[0]; + if (delta.TryGetProperty("delta", out var d) && d.TryGetProperty("content", out var c)) + { + var text = c.GetString() ?? ""; + if (!string.IsNullOrEmpty(text)) + { + var msgJson = JsonSerializer.Serialize(new { message = new { content = text } }); + await Response.WriteAsync($"event: message\ndata: {msgJson}\n\n"); + await Response.Body.FlushAsync(); + } + } + } + } + catch + { + } + } + + await Response.WriteAsync("event: done\ndata: {}\n\n"); + await Response.Body.FlushAsync(); + } + + private async Task VllmChatStreamWithTools(OllamaChatRequest req) + { + var messages = new List(); + if (req.SystemPrompt != null) + messages.Add(new { role = "system", content = req.SystemPrompt }); + foreach (var m in req.Messages) + messages.Add(m); + + int maxToolRounds = 10; + int toolRound = 0; + + while (toolRound < maxToolRounds) + { + toolRound++; + + var nonStreamPayload = new + { + model = req.Model, + messages, + tools = req.Tools, + tool_choice = "auto", + stream = false + }; + + var reqContent = new StringContent( + JsonSerializer.Serialize(nonStreamPayload), Encoding.UTF8, "application/json"); + + var httpReq = new HttpRequestMessage(HttpMethod.Post, "/v1/chat/completions") + { + Content = reqContent + }; + + var res = await _vllmClient.SendAsync(httpReq, HttpContext.RequestAborted); + if (!res.IsSuccessStatusCode) + { + var err = await res.Content.ReadAsStringAsync(); + await Response.WriteAsync($"event: error\ndata: {err}\n\n"); + await Response.Body.FlushAsync(); + return; + } + + var body = await res.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(body); + + JsonElement choice; + if (!doc.RootElement.TryGetProperty("choices", out var choices) || choices.GetArrayLength() == 0) + { + await Response.WriteAsync("event: error\ndata: No choices in response\n\n"); + await Response.Body.FlushAsync(); + return; + } + choice = choices[0]; + + var finishReason = choice.TryGetProperty("finish_reason", out var fr) ? fr.GetString() : "stop"; + + if (finishReason == "tool_calls") + { + var msg = choice.GetProperty("message"); + + var tcList = new List(); + if (msg.TryGetProperty("tool_calls", out var toolCalls)) + { + foreach (var tc in toolCalls.EnumerateArray()) + { + var tcId = tc.GetProperty("id").GetString() ?? $"tc_{toolRound}_{Guid.NewGuid():N}"; + var func = tc.GetProperty("function"); + var funcName = func.GetProperty("name").GetString() ?? ""; + var funcArgs = func.GetProperty("arguments").GetString() ?? "{}"; + + tcList.Add(new + { + id = tcId, + type = "function", + function = new + { + name = funcName, + arguments = funcArgs + } + }); + } + } + + messages.Add(new + { + role = "assistant", + content = (string?)null, + tool_calls = tcList + }); + + foreach (var tc in tcList) + { + var tcId = tc.GetType().GetProperty("id")?.GetValue(tc) as string ?? ""; + var func = tc.GetType().GetProperty("function")?.GetValue(tc); + var funcName = func?.GetType().GetProperty("name")?.GetValue(func) as string ?? ""; + var funcArgs = func?.GetType().GetProperty("arguments")?.GetValue(func) as string ?? "{}"; + + try + { + var args = JsonSerializer.Deserialize>(funcArgs) + ?? new Dictionary(); + + var toolResult = await _mcpClient.CallToolAsync(funcName, args, HttpContext.RequestAborted); + + messages.Add(new + { + role = "tool", + tool_call_id = tcId, + content = toolResult + }); + } + catch (Exception ex) + { + messages.Add(new + { + role = "tool", + tool_call_id = tcId, + content = $"도구 실행 오류: {ex.Message}" + }); + } + + if (HttpContext.RequestAborted.IsCancellationRequested) return; + } + + continue; + } + + // content에서 JSON 형식 텍스트 도구 호출 감지 (tool_calls API 대신 JSON 텍스트를 출력하는 모델 처리) + var stopContent = ""; + if (choice.TryGetProperty("message", out var stopMsgEl) && stopMsgEl.TryGetProperty("content", out var stopCntEl)) + stopContent = stopCntEl.GetString() ?? ""; + + // 모델 무관: content 안에서 첫 번째 완전한 JSON 객체를 추출 (앞에 thinking 토큰, 설명 등이 붙어도 동작) + var jsonCandidate = ExtractFirstJsonObject(stopContent); + + if (!string.IsNullOrEmpty(jsonCandidate)) + { + try + { + using var jsonDoc = JsonDocument.Parse(jsonCandidate); + if (jsonDoc.RootElement.ValueKind == JsonValueKind.Object) + { + var propNames = jsonDoc.RootElement.EnumerateObject().Select(p => p.Name).ToHashSet(); + string? detectedTool = null; + var args = new Dictionary(); + + // 포맷 1: {"tool": "toolName", "parameters": {...}} 또는 {"tool": "toolName", "arguments": {...}} + if (propNames.Contains("tool") && (propNames.Contains("parameters") || propNames.Contains("arguments"))) + { + detectedTool = jsonDoc.RootElement.GetProperty("tool").GetString(); + var paramsKey = propNames.Contains("parameters") ? "parameters" : "arguments"; + if (jsonDoc.RootElement.TryGetProperty(paramsKey, out var paramsEl) && paramsEl.ValueKind == JsonValueKind.Object) + { + foreach (var prop in paramsEl.EnumerateObject()) + { + var key = (detectedTool == "query_with_nl" && prop.Name == "query") ? "question" : prop.Name; + args[key] = prop.Value.ValueKind == JsonValueKind.String + ? (object)(prop.Value.GetString() ?? "") + : JsonSerializer.Deserialize(prop.Value.GetRawText()) ?? ""; + } + } + } + // 포맷 2: {"function": "toolName", "args": {...}} 또는 {"name": "toolName", "input": {...}} + else if ((propNames.Contains("function") || propNames.Contains("name")) && + (propNames.Contains("args") || propNames.Contains("input"))) + { + detectedTool = (propNames.Contains("function") + ? jsonDoc.RootElement.GetProperty("function") + : jsonDoc.RootElement.GetProperty("name")).GetString(); + var paramsKey = propNames.Contains("args") ? "args" : "input"; + if (jsonDoc.RootElement.TryGetProperty(paramsKey, out var paramsEl) && paramsEl.ValueKind == JsonValueKind.Object) + { + foreach (var prop in paramsEl.EnumerateObject()) + { + var key = (detectedTool == "query_with_nl" && prop.Name == "query") ? "question" : prop.Name; + args[key] = prop.Value.ValueKind == JsonValueKind.String + ? (object)(prop.Value.GetString() ?? "") + : JsonSerializer.Deserialize(prop.Value.GetRawText()) ?? ""; + } + } + } + // 포맷 3: 플랫 JSON — 파라미터명으로 도구 추론 + else if (propNames.Contains("sql")) detectedTool = "run_sql"; + else if (propNames.Contains("tag_names")) detectedTool = "query_pv_history"; + else if (propNames.Contains("question")) detectedTool = "query_with_nl"; + else if (propNames.Contains("query")) detectedTool = "query_with_nl"; + + // 포맷 3일 때 args 채우기 + if (detectedTool != null && args.Count == 0) + { + foreach (var prop in jsonDoc.RootElement.EnumerateObject()) + { + var key = (detectedTool == "query_with_nl" && prop.Name == "query") ? "question" : prop.Name; + args[key] = prop.Value.ValueKind == JsonValueKind.String + ? (object)(prop.Value.GetString() ?? "") + : JsonSerializer.Deserialize(prop.Value.GetRawText()) ?? ""; + } + } + + if (detectedTool != null && args.Count > 0) + { + try + { + var toolResult = await _mcpClient.CallToolAsync(detectedTool, args, HttpContext.RequestAborted); + messages.Add(new { role = "assistant", content = stopContent }); + messages.Add(new { role = "user", content = $"[{detectedTool} 실행 결과]\n{toolResult}\n\n위 결과를 바탕으로 사용자의 질문에 자연어로 답변해주세요." }); + continue; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "[OllamaController] 텍스트 형식 도구 호출 실패: {Tool}", detectedTool); + } + } + } + } + catch { } + } + + // 첫 번째 비스트리밍 응답의 content를 직접 전달 (두 번째 LLM 호출 없이) + if (!string.IsNullOrEmpty(stopContent)) + { + var msgJson = JsonSerializer.Serialize(new { message = new { content = stopContent } }); + await Response.WriteAsync($"event: message\ndata: {msgJson}\n\n"); + await Response.Body.FlushAsync(); + await Response.WriteAsync("event: done\ndata: {}\n\n"); + await Response.Body.FlushAsync(); + return; + } + + // content가 없는 경우에만 스트리밍 호출로 fallback + var streamPayload = new + { + model = req.Model, + messages, + stream = true + }; + + var streamContent = new StringContent( + JsonSerializer.Serialize(streamPayload), Encoding.UTF8, "application/json"); + + var streamReq = new HttpRequestMessage(HttpMethod.Post, "/v1/chat/completions") + { + Content = streamContent + }; + + var streamRes = await _vllmClient.SendAsync(streamReq, HttpCompletionOption.ResponseHeadersRead, HttpContext.RequestAborted); + if (!streamRes.IsSuccessStatusCode) + { + await Response.WriteAsync($"event: error\ndata: {streamRes.StatusCode}\n\n"); + await Response.Body.FlushAsync(); + return; + } + + var sseStream = await streamRes.Content.ReadAsStreamAsync(); + using var sseReader = new StreamReader(sseStream, Encoding.UTF8); + while (true) + { + if (HttpContext.RequestAborted.IsCancellationRequested) return; + var line = await sseReader.ReadLineAsync(); + if (line == null) break; + + if (!line.StartsWith("data: ")) continue; + var data = line.Substring(6); + if (string.IsNullOrWhiteSpace(data) || data == "[DONE]") continue; + + try + { + var sseDoc = JsonDocument.Parse(data); + if (sseDoc.RootElement.TryGetProperty("choices", out var sseChoices) && sseChoices.GetArrayLength() > 0) + { + var delta = sseChoices[0]; + if (delta.TryGetProperty("delta", out var d) && d.TryGetProperty("content", out var c)) + { + var text = c.GetString() ?? ""; + if (!string.IsNullOrEmpty(text)) + { + var msgJson = JsonSerializer.Serialize(new { message = new { content = text } }); + await Response.WriteAsync($"event: message\ndata: {msgJson}\n\n"); + await Response.Body.FlushAsync(); + } + } + } + } + catch { } + } + + await Response.WriteAsync("event: done\ndata: {}\n\n"); + await Response.Body.FlushAsync(); + return; + } + + await Response.WriteAsync("event: error\ndata: Too many tool call rounds\n\n"); + await Response.Body.FlushAsync(); + } + + [HttpGet("vllm/ping")] + public async Task VllmPing() + { + try + { + var res = await _vllmClient.GetAsync("/v1/models"); + return Ok(new { success = res.IsSuccessStatusCode, model = LoadVllmModel() }); + } + catch (Exception ex) + { + return Ok(new { success = false, error = ex.Message }); + } + } + + // content 안에서 첫 번째 완전한 JSON 객체를 추출 (앞뒤 텍스트 무시, 모델 무관) + private static string ExtractFirstJsonObject(string content) + { + var start = content.IndexOf('{'); + if (start < 0) return ""; + var depth = 0; + for (int i = start; i < content.Length; i++) + { + if (content[i] == '{') depth++; + else if (content[i] == '}') { depth--; if (depth == 0) return content.Substring(start, i - start + 1); } + } + return ""; + } +} + +public class OllamaConfig +{ + public string Host { get; set; } = "localhost"; + public int Port { get; set; } = 11434; + + public string BaseUrl => $"http://{Host}:{Port}"; +} + +public class OllamaChatRequest +{ + public string Model { get; set; } = ""; + public OllamaMessage[] Messages { get; set; } = Array.Empty(); + public string? SystemPrompt { get; set; } + public OllamaTool[]? Tools { get; set; } +} + +public class OllamaMessage +{ + [JsonPropertyName("role")] + public string Role { get; set; } = ""; + [JsonPropertyName("content")] + public string? Content { get; set; } +} + +public class OllamaTool +{ + [JsonPropertyName("type")] + public string Type { get; set; } = "function"; + + [JsonPropertyName("function")] + public OllamaFunction Function { get; set; } = new(); +} + +public class OllamaFunction +{ + [JsonPropertyName("name")] + public string Name { get; set; } = ""; + + [JsonPropertyName("description")] + public string Description { get; set; } = ""; + + [JsonPropertyName("parameters")] + public JsonElement? Parameters { get; set; } +} diff --git a/src/Web/Program.cs b/src/Web/Program.cs index 49a26c2..7324dc4 100644 --- a/src/Web/Program.cs +++ b/src/Web/Program.cs @@ -86,7 +86,9 @@ builder.Services.AddHostedService(); // ── MCP Service ─────────────────────────────────────────────────────────────── // Python MCP 서버 (localhost:5001)와 통신 // McpClient: 저수준 HTTP 클라이언트 / McpService: IMcpService 구현 (McpClient 위임) -builder.Services.AddSingleton(); +// NOTE: 팩토리 등록 — DI가 HttpClient를 주입하면 BaseAddress 미설정 상태가 되므로 +// 직접 생성자 호출하여 자체 HttpClient를 사용하도록 함 +builder.Services.AddSingleton(_ => new McpClient()); builder.Services.AddSingleton(); builder.Services.AddHostedService(); @@ -117,6 +119,20 @@ builder.Services.AddSingleton(); +// ── Ollama HttpClient ───────────────────────────────────────────────────────── +builder.Services.AddHttpClient("Ollama", c => +{ + c.BaseAddress = new Uri("http://localhost:11434"); + c.Timeout = TimeSpan.FromSeconds(1800); +}).SetHandlerLifetime(Timeout.InfiniteTimeSpan); + +// ── vLLM HttpClient (OpenAI-compatible) ────────────────────────────────────── +builder.Services.AddHttpClient("Vllm", c => +{ + c.BaseAddress = new Uri("http://localhost:8000"); + c.Timeout = TimeSpan.FromSeconds(1800); +}).SetHandlerLifetime(Timeout.InfiniteTimeSpan); + // ── CORS ────────────────────────────────────────────────────────────────────── builder.Services.AddCors(opt => opt.AddDefaultPolicy(p => p.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader())); diff --git a/src/Web/wwwroot/css/style.css b/src/Web/wwwroot/css/style.css index bdb616c..ecdf44e 100644 --- a/src/Web/wwwroot/css/style.css +++ b/src/Web/wwwroot/css/style.css @@ -1538,3 +1538,362 @@ tr:last-child td { border-bottom: none; } } .evt-total strong { color: var(--t0); } + +/* ═══════════════════════════════════════════════════════ + 로컬 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-header-left .ck { + display: inline-flex; + align-items: center; + gap: 4px; + cursor: pointer; + color: var(--t1); + white-space: nowrap; + user-select: none; +} +.llm-header-left .ck input[type="checkbox"] { + margin: 0; + cursor: pointer; +} + +/* 설정 패널 */ +.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); } +} diff --git a/src/Web/wwwroot/index.html b/src/Web/wwwroot/index.html index 560b015..d132566 100644 --- a/src/Web/wwwroot/index.html +++ b/src/Web/wwwroot/index.html @@ -80,6 +80,10 @@ 12 이벤트 히스토리 +
    @@ -181,7 +185,7 @@
    - +
    @@ -1215,6 +1219,101 @@ + +
    +
    +
    +

    로컬 LLM 채팅

    +

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

    +
    +
    LLM / CHAT
    +
    + +
    +
    +
    + 대화 목록 + +
    +
    +
    대화가 없습니다. + 버튼을 눌러 새 대화를 시작하세요.
    +
    + +
    + +
    +
    +
    +
    + + +
    +
    + + +
    + + + +
    +
    + + +
    +
    + + + +
    +
    +
    💬
    +
    새 대화를 시작하세요
    +
    모델을 선택하고 메시지를 입력하세요
    +
    +
    + +
    +
    + +
    + + +
    +
    +
    +
    +
    +
    + diff --git a/src/Web/wwwroot/js/app.js b/src/Web/wwwroot/js/app.js index 53a6a87..949003f 100644 --- a/src/Web/wwwroot/js/app.js +++ b/src/Web/wwwroot/js/app.js @@ -173,7 +173,7 @@ async function llmLoadConfig() { } } -async function llmSaveConfig() { +async function llmSaveModelConfig() { const statusEl = document.getElementById('llm-status'); const model = document.getElementById('llm-model').value.trim(); if (!model) { @@ -3283,3 +3283,558 @@ document.querySelectorAll('[data-tab="pid"]').forEach(item => { // 추출 중이면 시계 유지, 테이블/통계 갱신 생략 }); }); + +/* ═══════════════════════════════════════════════════════ + 13 로컬 LLM 채팅 + ══════════════════════════════════════════════════════ */ + +// ── 상태 ─────────────────────────────────────────────── +let llmSessions = JSON.parse(localStorage.getItem('llmSessions') || '[]'); +let llmActiveSessionId = localStorage.getItem('llmActiveSessionId') || ''; +let llmAbortController = null; +let llmIsStreaming = false; +let llmType = localStorage.getItem('llmType') || 'ollama'; +let llmUseTools = localStorage.getItem('llmUseTools') === 'true'; +let llmMcpTools = []; + +// ── 초기화 (탭 진입 시 API 호출 없음) ────────────────── +document.querySelectorAll('[data-tab="llmchat"]').forEach(item => { + item.addEventListener('click', () => { + llmRenderSessionList(); + llmLoadActiveSession(); + llmLoadModels(); + llmLoadConfigToUI(); + llmLoadMcpTools(); + }); +}); + +// ── 세션 관리 ────────────────────────────────────────── +function llmCreateSession() { + const id = 'llm_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8); + const session = { + id, + title: '새 대화', + model: '', + systemPrompt: '', + messages: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString() + }; + llmSessions.unshift(session); + llmActiveSessionId = id; + llmSaveSessions(); + llmRenderSessionList(); + llmRenderMessages(); + return session; +} + +function llmNewSession() { + llmCreateSession(); + document.getElementById('llm-input').focus(); +} + +function llmSwitchSession(id) { + llmActiveSessionId = id; + localStorage.setItem('llmActiveSessionId', id); + llmRenderSessionList(); + llmRenderMessages(); +} + +function llmDeleteSession(id, e) { + e.stopPropagation(); + if (!confirm('이 대화를 삭제하시겠습니까?')) return; + llmSessions = llmSessions.filter(s => s.id !== id); + llmSaveSessions(); + if (llmActiveSessionId === id) { + llmActiveSessionId = llmSessions.length > 0 ? llmSessions[0].id : ''; + localStorage.setItem('llmActiveSessionId', llmActiveSessionId); + } + llmRenderSessionList(); + llmRenderMessages(); +} + +function llmGetActiveSession() { + return llmSessions.find(s => s.id === llmActiveSessionId) || null; +} + +function llmSaveSessions() { + localStorage.setItem('llmSessions', JSON.stringify(llmSessions)); + localStorage.setItem('llmActiveSessionId', llmActiveSessionId); +} + +function llmSaveSessionMeta() { + const sess = llmGetActiveSession(); + if (!sess) return; + sess.model = document.getElementById('llm-model-select').value; + sess.systemPrompt = document.getElementById('llm-system-prompt').value.trim(); + sess.updatedAt = new Date().toISOString(); + llmSaveSessions(); + llmRenderSessionList(); +} + +// ── 세션 목록 렌더링 ─────────────────────────────────── +function llmRenderSessionList() { + const el = document.getElementById('llm-session-list'); + if (!el) return; + + if (llmSessions.length === 0) { + el.innerHTML = '
    대화가 없습니다.
    + 버튼을 눌러 새 대화를 시작하세요.
    '; + return; + } + + el.innerHTML = llmSessions.map(s => { + const isActive = s.id === llmActiveSessionId; + const title = esc(s.title || '제목 없음'); + return ` +
    + ${title} + × +
    + `; + }).join(''); +} + +// ── 메시지 렌더링 ────────────────────────────────────── +function llmRenderMessages() { + const el = document.getElementById('llm-messages'); + if (!el) return; + + const sess = llmGetActiveSession(); + if (!sess || sess.messages.length === 0) { + el.innerHTML = ` +
    +
    💬
    +
    새 대화를 시작하세요
    +
    모델을 선택하고 메시지를 입력하세요
    +
    + `; + if (sess) { + const sel = document.getElementById('llm-model-select'); + if (sel && sess.model) sel.value = sess.model; + const ta = document.getElementById('llm-system-prompt'); + if (ta && sess.systemPrompt) ta.value = sess.systemPrompt; + } + return; + } + + const sel = document.getElementById('llm-model-select'); + if (sel && sess.model) sel.value = sess.model; + const ta = document.getElementById('llm-system-prompt'); + if (ta && sess.systemPrompt) ta.value = sess.systemPrompt; + + el.innerHTML = sess.messages.map(m => { + const role = m.role === 'user' ? 'user' : 'assistant'; + const avatar = role === 'user' ? 'U' : 'AI'; + const content = llmFormatMessage(m.content); + return ` +
    +
    ${avatar}
    +
    ${content}
    +
    + `; + }).join(''); + + el.scrollTop = el.scrollHeight; +} + +// ── 메시지 포맷팅 (placeholder 패턴으로 esc 순서 보장) ── +function llmFormatMessage(text) { + const blocks = []; + text = text.replace(/```(\w*)\n?([\s\S]*?)```/g, (_, __, code) => { + blocks.push(`
    ${esc(code.trim())}
    `); + return `\x00B${blocks.length - 1}\x00`; + }); + text = text.replace(/`([^`]+)`/g, (_, code) => { + blocks.push(`${esc(code)}`); + return `\x00B${blocks.length - 1}\x00`; + }); + text = esc(text).replace(/\n/g, '
    '); + return text.replace(/\x00B(\d+)\x00/g, (_, i) => blocks[+i]); +} + +// ── 모델 목록 로드 ───────────────────────────────────── +async function llmLoadModels() { + const prefix = llmType === 'vllm' ? '/api/ollama/vllm' : '/api/ollama'; + const label = llmType === 'vllm' ? 'vLLM' : 'Ollama'; + try { + const d = await api('GET', `${prefix}/models`); + const sel = document.getElementById('llm-model-select'); + if (!sel) return; + + const currentVal = sel.value; + sel.innerHTML = ''; + + if (d.success && d.models) { + d.models.forEach(m => { + const opt = document.createElement('option'); + opt.value = m; + opt.textContent = m; + sel.appendChild(opt); + }); + if (currentVal && [...sel.options].some(o => o.value === currentVal)) { + sel.value = currentVal; + } + } + + const dot = document.getElementById('llm-conn-status'); + if (dot) { + dot.className = d.success ? 'llm-conn-dot connected' : 'llm-conn-dot error'; + dot.title = d.success ? `${label} 연결됨` : `${label} 연결 실패`; + } + } catch (e) { + const dot = document.getElementById('llm-conn-status'); + if (dot) { + dot.className = 'llm-conn-dot error'; + dot.title = `${label} 연결 실패: ` + e.message; + } + } +} + +function llmOnTypeChange() { + llmType = document.getElementById('llm-type-select').value; + localStorage.setItem('llmType', llmType); + llmLoadModels(); + llmLoadMcpTools(); +} + +async function llmLoadMcpTools() { + const useTools = document.getElementById('llm-use-tools'); + const toolsRow = document.getElementById('llm-tools-row'); + if (llmType !== 'vllm') { + if (useTools) useTools.checked = false; + if (toolsRow) toolsRow.style.display = 'none'; + llmUseTools = false; + localStorage.setItem('llmUseTools', 'false'); + return; + } + if (toolsRow) toolsRow.style.display = ''; + if (useTools) { + llmUseTools = useTools.checked; + localStorage.setItem('llmUseTools', llmUseTools); + } + if (!llmUseTools) { llmMcpTools = []; return; } + try { + const d = await api('GET', '/api/text-to-sql/tools'); + if (d.success && d.tools) { + llmMcpTools = d.tools.map(t => ({ + type: 'function', + function: { + name: t.Name || t.name, + description: t.Description || t.description || '', + parameters: t.InputSchema || t.inputSchema || { type: 'object', properties: {} } + } + })); + } else { + llmMcpTools = []; + } + } catch { + llmMcpTools = []; + } +} + +function llmToggleTools() { + llmUseTools = document.getElementById('llm-use-tools').checked; + localStorage.setItem('llmUseTools', llmUseTools); +} + +// ── 메시지 전송 (스트리밍) ───────────────────────────── +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(); + + // 첫 번째 메시지 시 세션 제목 자동 갱신 + if (sess.messages.length === 0 && sess.title === '새 대화') { + sess.title = text.slice(0, 30) + (text.length > 30 ? '…' : ''); + } + + sess.messages.push({ role: 'user', content: text }); + input.value = ''; + input.style.height = 'auto'; + llmSaveSessions(); + llmRenderMessages(); + llmRenderSessionList(); + + sess.model = model; + sess.systemPrompt = systemPrompt; + + const assistantMsg = { role: 'assistant', content: '' }; + sess.messages.push(assistantMsg); + + llmIsStreaming = true; + llmUpdateButtons(); + llmRenderMessages(); + + const messagesEl = document.getElementById('llm-messages'); + const typingDiv = document.createElement('div'); + typingDiv.className = 'llm-msg assistant'; + typingDiv.id = 'llm-streaming-msg'; + typingDiv.innerHTML = ` +
    AI
    +
    +
    +
    + `; + messagesEl.appendChild(typingDiv); + messagesEl.scrollTop = messagesEl.scrollHeight; + + llmAbortController = new AbortController(); + + try { + const prefix = llmType === 'vllm' ? '/api/ollama/vllm' : '/api/ollama'; + const requestBody = { + model, + messages: sess.messages.slice(0, -1), + systemPrompt: systemPrompt || undefined + }; + if (llmType === 'vllm' && llmUseTools && llmMcpTools.length > 0) { + requestBody.tools = llmMcpTools; + if (!requestBody.systemPrompt) { + requestBody.systemPrompt = ''; + } + requestBody.systemPrompt += '\n\nYou have access to MCP tools for querying the Experion database: run_sql (execute SQL), query_pv_history (tag PV history), get_tag_metadata (tag metadata search), list_drawings (P&ID drawings), query_with_nl (natural language to SQL). When the user asks about tag values, history, or database information, call the appropriate tool using the function calling API.'; + } + const res = await fetch(`${prefix}/chat/stream`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(requestBody), + signal: llmAbortController.signal + }); + + if (!res.ok) { + throw new Error(`HTTP ${res.status}`); + } + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + let streamDone = false; + + while (true) { + if (streamDone) break; + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + + const parts = buffer.split('\n\n'); + buffer = parts.pop() || ''; + + for (const part of parts) { + if (streamDone) break; + 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')) { + streamDone = true; + 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) { + assistantMsg.content += json.response; + llmUpdateStreamingMessage(assistantMsg.content); + } + } catch { + } + } + } + } + + sess.updatedAt = new Date().toISOString(); + llmSaveSessions(); + llmRenderMessages(); + llmRenderSessionList(); + + } catch (e) { + if (e.name === 'AbortError') { + if (assistantMsg.content) { + sess.updatedAt = new Date().toISOString(); + llmSaveSessions(); + llmRenderMessages(); + } else { + sess.messages.pop(); + llmSaveSessions(); + llmRenderMessages(); + } + } else { + assistantMsg.content = `❌ 오류: ${e.message}`; + sess.messages.pop(); + sess.messages.push(assistantMsg); + sess.updatedAt = new Date().toISOString(); + llmSaveSessions(); + llmRenderMessages(); + } + } finally { + llmIsStreaming = false; + llmAbortController = null; + llmUpdateButtons(); + } +} + +function llmUpdateStreamingMessage(content) { + let msgEl = document.getElementById('llm-streaming-msg'); + if (!msgEl) { + const messagesEl = document.getElementById('llm-messages'); + msgEl = document.createElement('div'); + msgEl.className = 'llm-msg assistant'; + msgEl.id = 'llm-streaming-msg'; + msgEl.innerHTML = ` +
    AI
    +
    + `; + messagesEl.appendChild(msgEl); + } + + const bubble = msgEl.querySelector('.llm-msg-bubble'); + if (bubble) { + bubble.innerHTML = llmFormatMessage(content); + } + + const messagesEl = document.getElementById('llm-messages'); + if (messagesEl) { + messagesEl.scrollTop = messagesEl.scrollHeight; + } +} + +function llmStop() { + if (llmAbortController) { + llmAbortController.abort(); + } +} + +function llmUpdateButtons() { + const sendBtn = document.getElementById('llm-send-btn'); + const stopBtn = document.getElementById('llm-stop-btn'); + if (sendBtn) sendBtn.disabled = llmIsStreaming; + if (stopBtn) stopBtn.style.display = llmIsStreaming ? 'inline-flex' : 'none'; +} + +// ── 입력 키 처리 ─────────────────────────────────────── +function llmInputKeydown(e) { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + llmSend(); + } + const ta = e.target; + setTimeout(() => { + ta.style.height = 'auto'; + ta.style.height = Math.min(ta.scrollHeight, 150) + 'px'; + }, 0); +} + +// ── 세션 초기화 ──────────────────────────────────────── +function llmClearSession() { + const sess = llmGetActiveSession(); + if (!sess) return; + if (!confirm('현재 대화의 메시지를 모두 지우시겠습니까?')) return; + sess.messages = []; + sess.updatedAt = new Date().toISOString(); + llmSaveSessions(); + llmRenderMessages(); + llmRenderSessionList(); +} + +// ── 설정 ─────────────────────────────────────────────── +function llmToggleSettings() { + const panel = document.getElementById('llm-settings-panel'); + if (panel) { + const opening = panel.classList.contains('hidden'); + panel.classList.toggle('hidden'); + if (opening) llmLoadConfigToUI(); + } +} + +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) { + alert('설정 저장 완료. 변경 사항 적용을 위해 페이지를 새로고침하세요.'); + } else { + alert('설정 저장 실패: ' + (d.error || '알 수 없는 오류')); + } + } catch (e) { + alert('설정 저장 실패: ' + e.message); + } +} + +async function llmTestConnection() { + const prefix = llmType === 'vllm' ? '/api/ollama/vllm' : '/api/ollama'; + const label = llmType === 'vllm' ? 'vLLM' : 'Ollama'; + try { + const d = await api('GET', `${prefix}/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 ? `${label} 연결됨` : `${label} 연결 실패: ${d.error || ''}`; + } + alert(d.success ? `${label} 연결 성공!` : `${label} 연결 실패: ${d.error || ''}`); + } catch (e) { + alert(`${label} 연결 테스트 실패: ` + 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); +}