feat: 로컬 LLM 채팅 기능 추가 (Ollama + vLLM, 스트리밍, MCP 도구 호출)
- OllamaController: Ollama/vLLM 프록시 API (채팅, 스트리밍, 모델 목록, 설정) - UI: 새 대화 탭, 세션 관리, Markdown 렌더링, 스트리밍 응답 - vLLM: OpenAI-compatible API 지원, MCP function calling 통합 - Fix: McpClient DI 팩토리 등록 (HttpClient BaseAddress 문제 해결) - Fix: llm-model.json 직렬화 JsonSerializer 사용 - Fix: nl2sql_worker KST 시간대 표시 (AT TIME ZONE Asia/Seoul) - Program.cs: Ollama/vLLM HttpClient 등록 (1800s timeout)
This commit is contained in:
28
gemma4-dflash-run.sh
Normal file
28
gemma4-dflash-run.sh
Normal file
@@ -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
|
||||||
|
"
|
||||||
@@ -1,3 +1 @@
|
|||||||
{
|
{"vllm_model":"Qwen3.6-27B-FP8"}
|
||||||
"vllm_model": "Qwen3.6-27B-FP8"
|
|
||||||
}
|
|
||||||
4
mcp-server/ollama-config.json
Normal file
4
mcp-server/ollama-config.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"host": "localhost",
|
||||||
|
"port": 11434
|
||||||
|
}
|
||||||
@@ -110,13 +110,14 @@ N분 간격 집계 공식 (time_bucket 금지, date_trunc 사용):
|
|||||||
10분 버킷: to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/600)*600) AS bucket
|
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
|
N분 버킷: to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/(N*60))*(N*60)) AS bucket
|
||||||
|
|
||||||
예시 (2분 간격, 여러 태그):
|
예시 (2분 간격, 여러 태그, KST 표시):
|
||||||
SELECT to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/120)*120) AS bucket,
|
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
|
tagname, AVG(value::double precision) AS avg_val
|
||||||
FROM history_table
|
FROM history_table
|
||||||
WHERE tagname IN ('tag1', 'tag2')
|
WHERE tagname IN ('tag1', 'tag2')
|
||||||
AND recorded_at >= NOW() - INTERVAL '3 hours'
|
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 등 불가)
|
- 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"
|
" 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"
|
" * 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"
|
"- 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"
|
"- value column is TEXT; cast with ::double precision only when aggregating.\n"
|
||||||
"- All tagnames are lowercase (e.g. 'ficq-6113.pv'). Match exactly.\n"
|
"- All tagnames are lowercase (e.g. 'ficq-6113.pv'). Match exactly.\n"
|
||||||
"- PostgreSQL LIKE: dot has no special meaning, no escaping needed.\n"
|
"- PostgreSQL LIKE: dot has no special meaning, no escaping needed.\n"
|
||||||
|
|||||||
21
opencode.json
Normal file
21
opencode.json
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
770
plans/LLM채팅+지식증강플랜.md
Normal file
770
plans/LLM채팅+지식증강플랜.md
Normal file
@@ -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번 탭 추가, `<section id="pane-kbadmin">` 신설
|
||||||
|
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 제한** — 운영 형태 확정 후
|
||||||
1389
plans/채팅-페이지-플랜.md
Normal file
1389
plans/채팅-페이지-플랜.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1350,7 +1350,7 @@ public class LlmConfigController : ControllerBase
|
|||||||
return Ok(new { success = false, error = "vllm_model is required" });
|
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);
|
System.IO.File.WriteAllText(path, json);
|
||||||
_logger.LogInformation("[LlmConfigController] 모델 변경: {Model}", model);
|
_logger.LogInformation("[LlmConfigController] 모델 변경: {Model}", model);
|
||||||
return Ok(new { success = true, vllmModel = model });
|
return Ok(new { success = true, vllmModel = model });
|
||||||
|
|||||||
835
src/Web/Controllers/OllamaController.cs
Normal file
835
src/Web/Controllers/OllamaController.cs
Normal file
@@ -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<OllamaController> _logger;
|
||||||
|
private readonly McpClient _mcpClient;
|
||||||
|
|
||||||
|
public OllamaController(
|
||||||
|
IHttpClientFactory httpClientFactory,
|
||||||
|
IConfiguration config,
|
||||||
|
ILogger<OllamaController> 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<OllamaConfig>(json) ?? new OllamaConfig();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "[OllamaController] 설정 로드 실패, 기본값 사용");
|
||||||
|
}
|
||||||
|
return new OllamaConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("models")]
|
||||||
|
public async Task<IActionResult> GetModels()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var cfg = LoadConfig();
|
||||||
|
var res = await _httpClient.GetAsync($"{cfg.BaseUrl}/api/tags");
|
||||||
|
if (!res.IsSuccessStatusCode)
|
||||||
|
return Ok(new { success = false, error = $"Ollama HTTP {(int)res.StatusCode}", models = Array.Empty<string>() });
|
||||||
|
|
||||||
|
var body = await res.Content.ReadAsStringAsync();
|
||||||
|
var doc = JsonDocument.Parse(body);
|
||||||
|
var models = new List<string>();
|
||||||
|
if (doc.RootElement.TryGetProperty("models", out var arr))
|
||||||
|
{
|
||||||
|
foreach (var m in arr.EnumerateArray())
|
||||||
|
{
|
||||||
|
if (m.TryGetProperty("name", out var name))
|
||||||
|
models.Add(name.GetString() ?? "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Ok(new { success = true, models = models.OrderBy(x => x).ToArray() });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "[OllamaController] 모델 조회 실패");
|
||||||
|
return Ok(new { success = false, error = ex.Message, models = Array.Empty<string>() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("chat")]
|
||||||
|
public async Task<IActionResult> Chat([FromBody] OllamaChatRequest req)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var cfg = LoadConfig();
|
||||||
|
var payload = new
|
||||||
|
{
|
||||||
|
model = req.Model,
|
||||||
|
messages = req.Messages,
|
||||||
|
system = req.SystemPrompt,
|
||||||
|
stream = false
|
||||||
|
};
|
||||||
|
var content = new StringContent(
|
||||||
|
JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json");
|
||||||
|
|
||||||
|
var res = await _httpClient.PostAsync($"{cfg.BaseUrl}/api/chat", content);
|
||||||
|
if (!res.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var err = await res.Content.ReadAsStringAsync();
|
||||||
|
return Ok(new { success = false, error = err });
|
||||||
|
}
|
||||||
|
|
||||||
|
var body = await res.Content.ReadAsStringAsync();
|
||||||
|
var doc = JsonDocument.Parse(body);
|
||||||
|
string? reply = null;
|
||||||
|
if (doc.RootElement.TryGetProperty("message", out var msg)
|
||||||
|
&& msg.TryGetProperty("content", out var cnt))
|
||||||
|
{
|
||||||
|
reply = cnt.GetString();
|
||||||
|
}
|
||||||
|
return Ok(new { success = true, reply });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "[OllamaController] 채팅 실패");
|
||||||
|
return Ok(new { success = false, error = ex.Message, reply = (string?)null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[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<IActionResult> Ping()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var cfg = LoadConfig();
|
||||||
|
var res = await _httpClient.GetAsync($"{cfg.BaseUrl}/api/tags");
|
||||||
|
return Ok(new { success = res.IsSuccessStatusCode, host = cfg.Host, port = cfg.Port });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Ok(new { success = false, error = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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<IActionResult> 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<string>() });
|
||||||
|
|
||||||
|
var body = await res.Content.ReadAsStringAsync();
|
||||||
|
var doc = JsonDocument.Parse(body);
|
||||||
|
var models = new List<string>();
|
||||||
|
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<string>() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("vllm/chat")]
|
||||||
|
public async Task<IActionResult> VllmChat([FromBody] OllamaChatRequest req)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var model = req.Model;
|
||||||
|
var msgList = new List<object>();
|
||||||
|
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<object>();
|
||||||
|
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<object>();
|
||||||
|
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<object>();
|
||||||
|
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<Dictionary<string, object>>(funcArgs)
|
||||||
|
?? new Dictionary<string, object>();
|
||||||
|
|
||||||
|
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<string, object>();
|
||||||
|
|
||||||
|
// 포맷 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<object>(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<object>(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<object>(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<IActionResult> 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<OllamaMessage>();
|
||||||
|
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; }
|
||||||
|
}
|
||||||
@@ -86,7 +86,9 @@ builder.Services.AddHostedService<DigitalEventDetectorService>();
|
|||||||
// ── MCP Service ───────────────────────────────────────────────────────────────
|
// ── MCP Service ───────────────────────────────────────────────────────────────
|
||||||
// Python MCP 서버 (localhost:5001)와 통신
|
// Python MCP 서버 (localhost:5001)와 통신
|
||||||
// McpClient: 저수준 HTTP 클라이언트 / McpService: IMcpService 구현 (McpClient 위임)
|
// McpClient: 저수준 HTTP 클라이언트 / McpService: IMcpService 구현 (McpClient 위임)
|
||||||
builder.Services.AddSingleton<McpClient>();
|
// NOTE: 팩토리 등록 — DI가 HttpClient를 주입하면 BaseAddress 미설정 상태가 되므로
|
||||||
|
// 직접 생성자 호출하여 자체 HttpClient를 사용하도록 함
|
||||||
|
builder.Services.AddSingleton<McpClient>(_ => new McpClient());
|
||||||
builder.Services.AddSingleton<IMcpService, McpService>();
|
builder.Services.AddSingleton<IMcpService, McpService>();
|
||||||
builder.Services.AddHostedService<McpServerHostedService>();
|
builder.Services.AddHostedService<McpServerHostedService>();
|
||||||
|
|
||||||
@@ -117,6 +119,20 @@ builder.Services.AddSingleton<IPidGraphEventBroadcaster, PidGraphEventBroadcaste
|
|||||||
// ── FastTable Cleanup Service ─────────────────────────────────────────────────
|
// ── FastTable Cleanup Service ─────────────────────────────────────────────────
|
||||||
builder.Services.AddHostedService<ExperionFastCleanupService>();
|
builder.Services.AddHostedService<ExperionFastCleanupService>();
|
||||||
|
|
||||||
|
// ── 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 ──────────────────────────────────────────────────────────────────────
|
// ── CORS ──────────────────────────────────────────────────────────────────────
|
||||||
builder.Services.AddCors(opt =>
|
builder.Services.AddCors(opt =>
|
||||||
opt.AddDefaultPolicy(p => p.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader()));
|
opt.AddDefaultPolicy(p => p.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader()));
|
||||||
|
|||||||
@@ -1538,3 +1538,362 @@ tr:last-child td { border-bottom: none; }
|
|||||||
}
|
}
|
||||||
|
|
||||||
.evt-total strong { color: var(--t0); }
|
.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); }
|
||||||
|
}
|
||||||
|
|||||||
@@ -80,6 +80,10 @@
|
|||||||
<span class="ni">12</span>
|
<span class="ni">12</span>
|
||||||
<span class="nl">이벤트 히스토리</span>
|
<span class="nl">이벤트 히스토리</span>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item" data-tab="llmchat">
|
||||||
|
<span class="ni">13</span>
|
||||||
|
<span class="nl">로컬 LLM 채팅</span>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div class="sb-foot">
|
<div class="sb-foot">
|
||||||
@@ -181,7 +185,7 @@
|
|||||||
<div class="row-inp">
|
<div class="row-inp">
|
||||||
<input id="llm-model" class="inp flex1" placeholder="모델명" />
|
<input id="llm-model" class="inp flex1" placeholder="모델명" />
|
||||||
<button class="btn-b" onclick="llmLoadConfig()">🔄 불러오기</button>
|
<button class="btn-b" onclick="llmLoadConfig()">🔄 불러오기</button>
|
||||||
<button class="btn-a" onclick="llmSaveConfig()">💾 저장</button>
|
<button class="btn-a" onclick="llmSaveModelConfig()">💾 저장</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="llm-status" class="kv-box" style="margin-top:8px"></div>
|
<div id="llm-status" class="kv-box" style="margin-top:8px"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1215,6 +1219,101 @@
|
|||||||
<div id="evt-table" class="tbl-wrap hidden"></div>
|
<div id="evt-table" class="tbl-wrap hidden"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- ══════════════════════════════════════════════════════
|
||||||
|
13 로컬 LLM 채팅
|
||||||
|
═══════════════════════════════════════════════════════ -->
|
||||||
|
<section class="pane" id="pane-llmchat">
|
||||||
|
<header class="pane-hdr">
|
||||||
|
<div>
|
||||||
|
<h1>로컬 LLM 채팅</h1>
|
||||||
|
<p>로컬 Ollama 서버에 연결하여 LLM과 대화합니다.</p>
|
||||||
|
</div>
|
||||||
|
<div class="pane-tag">LLM / CHAT</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="llm-layout">
|
||||||
|
<div class="llm-sidebar">
|
||||||
|
<div class="llm-sidebar-header">
|
||||||
|
<span class="llm-sidebar-title">대화 목록</span>
|
||||||
|
<button class="btn-a btn-sm" onclick="llmNewSession()" title="새 대화">+</button>
|
||||||
|
</div>
|
||||||
|
<div id="llm-session-list" class="llm-session-list">
|
||||||
|
<div class="llm-empty">대화가 없습니다. + 버튼을 눌러 새 대화를 시작하세요.</div>
|
||||||
|
</div>
|
||||||
|
<div class="llm-sidebar-footer">
|
||||||
|
<button class="btn-b btn-sm" onclick="llmExportAll()" style="width:100%">📋 전체 내보내기</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="llm-main">
|
||||||
|
<div class="llm-header">
|
||||||
|
<div class="llm-header-left">
|
||||||
|
<div class="fg" style="margin:0;width:100px">
|
||||||
|
<label>LLM</label>
|
||||||
|
<select id="llm-type-select" class="inp" onchange="llmOnTypeChange()">
|
||||||
|
<option value="ollama">Ollama</option>
|
||||||
|
<option value="vllm">vLLM</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="fg" style="margin:0;width:160px">
|
||||||
|
<label>모델</label>
|
||||||
|
<select id="llm-model-select" class="inp" onchange="llmSaveSessionMeta()">
|
||||||
|
<option value="">-- 모델을 선택하세요 --</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button class="btn-b btn-sm" onclick="llmLoadModels()">🔄 갱신</button>
|
||||||
|
<label id="llm-tools-row" class="ck" style="margin:0;font-size:13px;display:none">
|
||||||
|
<input type="checkbox" id="llm-use-tools" onchange="llmToggleTools()" checked>
|
||||||
|
MCP 도구
|
||||||
|
</label>
|
||||||
|
<span id="llm-conn-status" class="llm-conn-dot" title="연결 상태">●</span>
|
||||||
|
</div>
|
||||||
|
<div class="llm-header-right">
|
||||||
|
<button class="btn-b btn-sm" onclick="llmClearSession()">🗑 초기화</button>
|
||||||
|
<button class="btn-b btn-sm" onclick="llmToggleSettings()">⚙ 설정</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="llm-settings-panel" class="llm-settings hidden">
|
||||||
|
<div class="cols-2">
|
||||||
|
<div class="fg"><label>Ollama Host</label>
|
||||||
|
<input id="llm-host" class="inp" value="localhost"/></div>
|
||||||
|
<div class="fg"><label>Port</label>
|
||||||
|
<input id="llm-port" class="inp" type="number" value="11434"/></div>
|
||||||
|
</div>
|
||||||
|
<div class="fg"><label>시스템 프롬프트</label>
|
||||||
|
<textarea id="llm-system-prompt" class="ta" rows="3"
|
||||||
|
placeholder="예: 너는 산업 자동화 분야의 전문가입니다."></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="btn-row">
|
||||||
|
<button class="btn-a btn-sm" onclick="llmSaveConfig()">💾 설정 저장</button>
|
||||||
|
<button class="btn-b btn-sm" onclick="llmTestConnection()">🔌 연결 테스트</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="llm-messages" class="llm-messages">
|
||||||
|
<div class="llm-welcome">
|
||||||
|
<div class="llm-welcome-icon">💬</div>
|
||||||
|
<div class="llm-welcome-text">새 대화를 시작하세요</div>
|
||||||
|
<div class="llm-welcome-hint">모델을 선택하고 메시지를 입력하세요</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="llm-input-area">
|
||||||
|
<div class="llm-input-box">
|
||||||
|
<textarea id="llm-input" class="llm-textarea" rows="1"
|
||||||
|
placeholder="메시지를 입력하세요... (Shift+Enter: 줄바꿈, Enter: 전송)"
|
||||||
|
onkeydown="llmInputKeydown(event)"></textarea>
|
||||||
|
<div class="llm-input-btns">
|
||||||
|
<button id="llm-send-btn" class="btn-a btn-sm" onclick="llmSend()">전송</button>
|
||||||
|
<button id="llm-stop-btn" class="btn-b btn-sm" onclick="llmStop()" style="display:none">중단</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -173,7 +173,7 @@ async function llmLoadConfig() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function llmSaveConfig() {
|
async function llmSaveModelConfig() {
|
||||||
const statusEl = document.getElementById('llm-status');
|
const statusEl = document.getElementById('llm-status');
|
||||||
const model = document.getElementById('llm-model').value.trim();
|
const model = document.getElementById('llm-model').value.trim();
|
||||||
if (!model) {
|
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 = '<div class="llm-empty">대화가 없습니다.<br>+ 버튼을 눌러 새 대화를 시작하세요.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
el.innerHTML = llmSessions.map(s => {
|
||||||
|
const isActive = s.id === llmActiveSessionId;
|
||||||
|
const title = esc(s.title || '제목 없음');
|
||||||
|
return `
|
||||||
|
<div class="llm-session-item ${isActive ? 'active' : ''}" onclick="llmSwitchSession('${s.id}')">
|
||||||
|
<span class="llm-sess-title" title="${title}">${title}</span>
|
||||||
|
<span class="llm-sess-del" onclick="llmDeleteSession('${s.id}', event)" title="삭제">×</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 메시지 렌더링 ──────────────────────────────────────
|
||||||
|
function llmRenderMessages() {
|
||||||
|
const el = document.getElementById('llm-messages');
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const sess = llmGetActiveSession();
|
||||||
|
if (!sess || sess.messages.length === 0) {
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="llm-welcome">
|
||||||
|
<div class="llm-welcome-icon">💬</div>
|
||||||
|
<div class="llm-welcome-text">새 대화를 시작하세요</div>
|
||||||
|
<div class="llm-welcome-hint">모델을 선택하고 메시지를 입력하세요</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
if (sess) {
|
||||||
|
const sel = document.getElementById('llm-model-select');
|
||||||
|
if (sel && sess.model) sel.value = sess.model;
|
||||||
|
const ta = document.getElementById('llm-system-prompt');
|
||||||
|
if (ta && sess.systemPrompt) ta.value = sess.systemPrompt;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sel = document.getElementById('llm-model-select');
|
||||||
|
if (sel && sess.model) sel.value = sess.model;
|
||||||
|
const ta = document.getElementById('llm-system-prompt');
|
||||||
|
if (ta && sess.systemPrompt) ta.value = sess.systemPrompt;
|
||||||
|
|
||||||
|
el.innerHTML = sess.messages.map(m => {
|
||||||
|
const role = m.role === 'user' ? 'user' : 'assistant';
|
||||||
|
const avatar = role === 'user' ? 'U' : 'AI';
|
||||||
|
const content = llmFormatMessage(m.content);
|
||||||
|
return `
|
||||||
|
<div class="llm-msg ${role}">
|
||||||
|
<div class="llm-msg-avatar">${avatar}</div>
|
||||||
|
<div class="llm-msg-bubble">${content}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
el.scrollTop = el.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 메시지 포맷팅 (placeholder 패턴으로 esc 순서 보장) ──
|
||||||
|
function llmFormatMessage(text) {
|
||||||
|
const blocks = [];
|
||||||
|
text = text.replace(/```(\w*)\n?([\s\S]*?)```/g, (_, __, code) => {
|
||||||
|
blocks.push(`<pre><code>${esc(code.trim())}</code></pre>`);
|
||||||
|
return `\x00B${blocks.length - 1}\x00`;
|
||||||
|
});
|
||||||
|
text = text.replace(/`([^`]+)`/g, (_, code) => {
|
||||||
|
blocks.push(`<code>${esc(code)}</code>`);
|
||||||
|
return `\x00B${blocks.length - 1}\x00`;
|
||||||
|
});
|
||||||
|
text = esc(text).replace(/\n/g, '<br>');
|
||||||
|
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 = '<option value="">-- 모델을 선택하세요 --</option>';
|
||||||
|
|
||||||
|
if (d.success && d.models) {
|
||||||
|
d.models.forEach(m => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = m;
|
||||||
|
opt.textContent = m;
|
||||||
|
sel.appendChild(opt);
|
||||||
|
});
|
||||||
|
if (currentVal && [...sel.options].some(o => o.value === currentVal)) {
|
||||||
|
sel.value = currentVal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dot = document.getElementById('llm-conn-status');
|
||||||
|
if (dot) {
|
||||||
|
dot.className = d.success ? 'llm-conn-dot connected' : 'llm-conn-dot error';
|
||||||
|
dot.title = d.success ? `${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 = `
|
||||||
|
<div class="llm-msg-avatar">AI</div>
|
||||||
|
<div class="llm-msg-bubble">
|
||||||
|
<div class="llm-typing"><span></span><span></span><span></span></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
messagesEl.appendChild(typingDiv);
|
||||||
|
messagesEl.scrollTop = messagesEl.scrollHeight;
|
||||||
|
|
||||||
|
llmAbortController = new AbortController();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const 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 = `
|
||||||
|
<div class="llm-msg-avatar">AI</div>
|
||||||
|
<div class="llm-msg-bubble"></div>
|
||||||
|
`;
|
||||||
|
messagesEl.appendChild(msgEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bubble = msgEl.querySelector('.llm-msg-bubble');
|
||||||
|
if (bubble) {
|
||||||
|
bubble.innerHTML = llmFormatMessage(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
const messagesEl = document.getElementById('llm-messages');
|
||||||
|
if (messagesEl) {
|
||||||
|
messagesEl.scrollTop = messagesEl.scrollHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function llmStop() {
|
||||||
|
if (llmAbortController) {
|
||||||
|
llmAbortController.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function llmUpdateButtons() {
|
||||||
|
const sendBtn = document.getElementById('llm-send-btn');
|
||||||
|
const stopBtn = document.getElementById('llm-stop-btn');
|
||||||
|
if (sendBtn) sendBtn.disabled = llmIsStreaming;
|
||||||
|
if (stopBtn) stopBtn.style.display = llmIsStreaming ? 'inline-flex' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 입력 키 처리 ───────────────────────────────────────
|
||||||
|
function llmInputKeydown(e) {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
llmSend();
|
||||||
|
}
|
||||||
|
const ta = e.target;
|
||||||
|
setTimeout(() => {
|
||||||
|
ta.style.height = 'auto';
|
||||||
|
ta.style.height = Math.min(ta.scrollHeight, 150) + 'px';
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 세션 초기화 ────────────────────────────────────────
|
||||||
|
function llmClearSession() {
|
||||||
|
const sess = llmGetActiveSession();
|
||||||
|
if (!sess) return;
|
||||||
|
if (!confirm('현재 대화의 메시지를 모두 지우시겠습니까?')) return;
|
||||||
|
sess.messages = [];
|
||||||
|
sess.updatedAt = new Date().toISOString();
|
||||||
|
llmSaveSessions();
|
||||||
|
llmRenderMessages();
|
||||||
|
llmRenderSessionList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 설정 ───────────────────────────────────────────────
|
||||||
|
function llmToggleSettings() {
|
||||||
|
const panel = document.getElementById('llm-settings-panel');
|
||||||
|
if (panel) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user