diff --git a/AGENTS.md b/AGENTS.md
index ac4f7c6..ef8bbaf 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -95,3 +95,52 @@ Vanilla JS SPA. `wwwroot/index.html` + `js/app.js` + `css/style.css`. No build s
## Deploy
`sudo bash deploy.sh` — publishes to `/opt/ExperionCrawler`, creates systemd service `experioncrawler`, sets up PKI dirs. Service runs as `www-data`.
+
+---
+
+## Frontend Architecture (refactored 2026-05-24)
+
+### Directory layout
+
+```
+wwwroot/
+├── index.html ← data-src shell (229 lines, -87%)
+├── panes/ ← 16 pane HTML partials (lazy-loaded on tab click)
+├── js/
+│ ├── core.js ← esc/setGlobal/log/api/fmtTs/fmtVal/parseEnumPv
+│ │ activateTab/paneInit + dt* date picker
+│ ├── app.js ← placeholder (10 lines)
+│ ├── cert.js conn.js … ← 15 tab-specific files
+│ └── docs.js ← separately maintained
+├── css/ ← style.css monolithic (Phase 4 pending)
+└── lib/ ← unchanged
+```
+
+### Tab lifecycle
+
+1. User clicks `.nav-item[data-tab="X"]`
+2. `core.js:activateTab(X)` fires:
+ - If `pane-X` not loaded → `fetch(/panes/X.html)` → `innerHTML` inject
+ - Activate pane + nav highlight
+ - `paneInit[X]?.()` — each tab file registers its own init
+
+### PaneInit registration
+
+| Tab | Init function | File |
+|-----|--------------|------|
+| docs | `paneInit.docs = docsInit` | `docs.js` |
+| opcsvr | `paneInit.opcsvr = srvLoad` | `opcsvr.js` |
+| t2s | `paneInit.t2s = t2sInitMode` | `t2s.js` |
+| fast | `paneInit.fast = function() { … }` | `fast.js` |
+| pid | `paneInit.pid = async function() { … }` | `pid.js` |
+| llmchat | `paneInit.llmchat = function() { … }` | `llmchat.js` |
+| kbadmin | `paneInit.kbadmin = async function() { … }` | `kbadmin.js` |
+| cert/conn/crawl/db/nm-dash/pb/hist/evt/write | init 불필요 | — |
+
+### JS load order (index.html)
+
+`core.js` → `app.js` → cert/conn/crawl/db/nm-dash/pb/hist/opcsvr/t2s/fast/pid/evt/llmchat/kbadmin/write → `docs.js`
+
+### Phase 4 pending — CSS 분리
+
+`style.css`(2,230줄)에서 탭별 스타일 분할 미완료. `docs.css`가 선례. `웹UI-개선플랜-byOPUS.md` §11 참조.
diff --git a/mcp-server/server.py b/mcp-server/server.py
index 40ad1fc..86e8e99 100644
--- a/mcp-server/server.py
+++ b/mcp-server/server.py
@@ -964,6 +964,137 @@ async def run_sql(sql: str) -> str:
return await _execute_sql_internal(sql)
+@mcp.tool()
+async def upsert_pid_connection(
+ tag_no: str,
+ from_tag: str | None = None,
+ to_tag: str | None = None,
+ from_at: str | None = None,
+ to_at: str | None = None,
+ role: str | None = None,
+ category: str | None = None,
+ tag_class: str | None = None,
+) -> str:
+ """운전자가 작성한 from/to 연결 1건을 pid_equipment 에 멱등(idempotent) 반영.
+
+ ⚠️ 오직 pid_equipment 테이블만, 아래 컬럼만 변경한다(나머지는 절대 안 건드림):
+ from_tag, to_tag, from_at, to_at, role, category, tag_class, connection_locked, updated_at
+ equipment_name/instrument_type/line_number/pid_drawing_no/pos_x/pos_y/confidence/is_active 는 보존.
+
+ 매칭 규칙(문서규칙 §5):
+ (1) 같은 연결((tag_no, from_tag, to_tag) 동일)이 있으면 그 행 UPDATE ← 재실행 안전
+ (2) 없고, 해당 tag_no 의 from_tag·to_tag 가 비어있는 미완성 행(DXF)이 있으면 그 행을 채워 UPDATE
+ (3) 둘 다 없으면 INSERT (같은 tag 추가 경로면 기존 행의 equipment_name/type/line/drawing/category 복사)
+
+ 자동 처리:
+ - from_tag 또는 to_tag 가 있으면 connection_locked=TRUE (연결분석이 덮어쓰지 않게)
+ - updated_at = now()
+
+ Args:
+ tag_no: 태그번호 (필수, 대소문자 무시 매칭).
+ from_tag/to_tag: 상류/하류 태그. 병렬이면 호출 측에서 "A, B" 콤마 병합해 전달(이 도구는 분리하지 않음).
+ from_at/to_at: 위치/서술 텍스트. role/category/tag_class: 그대로 저장(None이면 기존 유지).
+
+ Returns:
+ JSON { success, action(update_existing|update_filled|insert), id, tag_no, before, after }
+ """
+ def _n(v):
+ if v is None:
+ return None
+ v = str(v).strip()
+ return v or None
+
+ tag_no = _n(tag_no)
+ if not tag_no:
+ return json.dumps({"success": False, "error": "tag_no 가 비었습니다."}, ensure_ascii=False)
+ from_tag, to_tag = _n(from_tag), _n(to_tag)
+ from_at, to_at = _n(from_at), _n(to_at)
+ role, category, tag_class = _n(role), _n(category), _n(tag_class)
+ locked = (from_tag is not None) or (to_tag is not None)
+ _SNAP = ["tag_no", "from_tag", "to_tag", "from_at", "to_at", "role", "category", "tag_class", "connection_locked"]
+
+ def _snap(cur, _id):
+ cur.execute("""SELECT tag_no, from_tag, to_tag, from_at, to_at, role, category, tag_class, connection_locked
+ FROM pid_equipment WHERE id=%s""", (_id,))
+ r = cur.fetchone()
+ return dict(zip(_SNAP, r)) if r else None
+
+ conn = None
+ try:
+ conn = await _get_db_connection()
+ with conn.cursor() as cur:
+ cur.execute(f"SET statement_timeout = {SQL_STATEMENT_TIMEOUT_MS}")
+ # (1) 같은 연결 (재실행 멱등)
+ cur.execute(
+ """SELECT id FROM pid_equipment
+ WHERE lower(tag_no)=lower(%s)
+ AND from_tag IS NOT DISTINCT FROM %s
+ AND to_tag IS NOT DISTINCT FROM %s
+ ORDER BY id LIMIT 1""",
+ (tag_no, from_tag, to_tag))
+ row = cur.fetchone()
+ target_id = row[0] if row else None
+ action = "update_existing" if row else None
+
+ # (2) 빈 미완성(DXF) 행 채우기
+ if target_id is None:
+ cur.execute(
+ """SELECT id FROM pid_equipment
+ WHERE lower(tag_no)=lower(%s)
+ AND (from_tag IS NULL OR from_tag='')
+ AND (to_tag IS NULL OR to_tag='')
+ ORDER BY id LIMIT 1""",
+ (tag_no,))
+ r2 = cur.fetchone()
+ if r2:
+ target_id, action = r2[0], "update_filled"
+
+ if target_id is not None:
+ before = _snap(cur, target_id)
+ cur.execute(
+ """UPDATE pid_equipment SET
+ from_tag=%s, to_tag=%s, from_at=%s, to_at=%s, role=%s,
+ category=COALESCE(%s, category),
+ tag_class=COALESCE(%s, tag_class),
+ connection_locked=%s, updated_at=now()
+ WHERE id=%s""",
+ (from_tag, to_tag, from_at, to_at, role, category, tag_class, locked, target_id))
+ else:
+ action = "insert"
+ before = None
+ # 같은 tag 기존 행에서 정적 메타 복사 (없으면 NULL)
+ cur.execute("""SELECT equipment_name, instrument_type, line_number, pid_drawing_no, category
+ FROM pid_equipment WHERE lower(tag_no)=lower(%s) ORDER BY id LIMIT 1""", (tag_no,))
+ meta = cur.fetchone() or (None, None, None, None, None)
+ eq_name, inst_type, line_no, draw_no, ex_cat = meta
+ cur.execute(
+ """INSERT INTO pid_equipment
+ (tag_no, equipment_name, instrument_type, line_number, pid_drawing_no,
+ from_tag, to_tag, from_at, to_at, role, category, tag_class,
+ connection_locked, confidence, is_active, extracted_at, updated_at)
+ VALUES (%s,%s,%s,%s,%s, %s,%s,%s,%s,%s, COALESCE(%s,%s),%s, %s, 0, TRUE, now(), now())
+ RETURNING id""",
+ (tag_no, eq_name, inst_type, line_no, draw_no,
+ from_tag, to_tag, from_at, to_at, role, category, ex_cat, tag_class, locked))
+ target_id = cur.fetchone()[0]
+
+ after = _snap(cur, target_id)
+ conn.commit()
+ return json.dumps({"success": True, "action": action, "id": target_id,
+ "tag_no": tag_no, "before": before, "after": after},
+ ensure_ascii=False, default=str)
+ except Exception as e:
+ if conn:
+ try:
+ conn.rollback()
+ except Exception:
+ pass
+ return json.dumps({"success": False, "error": f"upsert 실패: {e}"}, ensure_ascii=False)
+ finally:
+ if conn:
+ conn.close()
+
+
@mcp.tool()
async def query_pv_history(tag_names: list[str], time_from: str, time_to: str, limit: int = 100) -> str:
"""과거 값(PV) 히스토리 조회.
diff --git a/src/Web/wwwroot/css/evt.css b/src/Web/wwwroot/css/evt.css
new file mode 100644
index 0000000..aff2c48
--- /dev/null
+++ b/src/Web/wwwroot/css/evt.css
@@ -0,0 +1,50 @@
+/* ── Event History ─────────────────────────────────────────── */
+.evt-badge {
+ display: inline-block;
+ font-family: var(--fm); font-size: 10px; font-weight: 700;
+ letter-spacing: .06em; padding: 2px 8px; border-radius: 3px;
+ text-transform: uppercase; white-space: nowrap;
+}
+.evt-badge.trip { background: rgba(239,68,68,.18); color: #f87171; }
+.evt-badge.run { background: rgba(16,185,129,.18); color: #34d399; }
+.evt-badge.alarm { background: rgba(245,158,11,.18); color: #fbbf24; }
+.evt-badge.normal { background: rgba(148,163,184,.18); color: #94a3b8; }
+.evt-badge.change { background: rgba(96,165,250,.18); color: #60a5fa; }
+
+.evt-summary-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
+ gap: 12px;
+ margin-top: 12px;
+}
+
+.evt-summary-item {
+ background: var(--s2);
+ border: 1px solid var(--bd);
+ border-radius: var(--r);
+ padding: 14px 16px;
+}
+
+.evt-summary-section {
+ font-family: var(--fm); font-size: 13px; font-weight: 700;
+ color: var(--t0); margin-bottom: 10px;
+}
+
+.evt-summary-counts {
+ display: flex; gap: 10px; flex-wrap: wrap;
+}
+
+.evt-count {
+ display: flex; align-items: center; gap: 5px;
+ font-family: var(--fm); font-size: 11px; color: var(--t2);
+}
+
+.evt-count strong { font-size: 14px; color: var(--t0); }
+
+.evt-total {
+ font-family: var(--fm); font-size: 11px; color: var(--t2);
+ margin-top: 8px; padding-top: 8px;
+ border-top: 1px solid var(--bd);
+}
+
+.evt-total strong { color: var(--t0); }
diff --git a/src/Web/wwwroot/css/hist.css b/src/Web/wwwroot/css/hist.css
new file mode 100644
index 0000000..05e8986
--- /dev/null
+++ b/src/Web/wwwroot/css/hist.css
@@ -0,0 +1,100 @@
+/* ── History Query Status Box ──────────────────────────────── */
+.hist-status-box {
+ background: var(--s2);
+ border: 1px solid var(--bd);
+ border-radius: var(--r);
+ padding: 14px 16px;
+ margin: 12px 0;
+ transition: all var(--tr);
+}
+.hist-status-box.pending { border-color: var(--t2); }
+.hist-status-box.busy { border-color: var(--blu); }
+.hist-status-box.ok { border-color: var(--grn); }
+.hist-status-box.err { border-color: var(--red); }
+
+.hist-status-header {
+ display: flex; align-items: center; gap: 10px;
+ font-size: 14px; font-weight: 600;
+}
+.hist-status-icon {
+ font-size: 18px;
+ width: 28px; height: 28px;
+ display: flex; align-items: center; justify-content: center;
+ background: var(--s3);
+ border-radius: 50%;
+}
+.hist-status-text { color: var(--t0); }
+.hist-status-detail {
+ margin-top: 8px;
+ font-size: 12px;
+ color: var(--t1);
+ font-family: var(--fm);
+ line-height: 1.5;
+ word-break: break-all;
+}
+
+/* ── Hypertable Management ─────────────────────────────────── */
+.ht-hidden { display: none !important; }
+
+.ht-status-box {
+ background: var(--s2);
+ border: 1px solid var(--bd);
+ border-radius: var(--r);
+ padding: 14px 16px;
+ margin: 12px 0;
+ transition: all var(--tr);
+}
+.ht-status-box.pending { border-color: var(--t2); }
+.ht-status-box.busy { border-color: var(--blu); }
+.ht-status-box.ok { border-color: var(--grn); }
+.ht-status-box.err { border-color: var(--red); }
+
+.ht-status-header {
+ display: flex; align-items: center; gap: 10px;
+ font-size: 14px; font-weight: 600;
+}
+.ht-status-icon {
+ font-size: 18px;
+ width: 28px; height: 28px;
+ display: flex; align-items: center; justify-content: center;
+ background: var(--s3);
+ border-radius: 50%;
+}
+.ht-status-text { color: var(--t0); }
+.ht-status-detail {
+ margin-top: 8px;
+ font-size: 12px;
+ color: var(--t1);
+ font-family: var(--fm);
+ line-height: 1.5;
+ word-break: break-all;
+}
+
+.ht-info-panel {
+ margin-top: 12px;
+ padding-top: 12px;
+ border-top: 1px solid var(--bd);
+}
+.ht-info-grid {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: 10px;
+}
+.ht-info-item {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+.ht-info-label {
+ font-size: 11px;
+ color: var(--t2);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ font-weight: 600;
+}
+.ht-info-value {
+ font-size: 13px;
+ color: var(--t0);
+ font-family: var(--fm);
+ font-weight: 500;
+}
diff --git a/src/Web/wwwroot/css/kbadmin.css b/src/Web/wwwroot/css/kbadmin.css
new file mode 100644
index 0000000..06baba5
--- /dev/null
+++ b/src/Web/wwwroot/css/kbadmin.css
@@ -0,0 +1,109 @@
+/* ═══════════════════════════════════════════════════════
+ 14 RAG 관리 (KB Admin)
+ ══════════════════════════════════════════════════════ */
+.kb-login-card { max-width: 460px; }
+.kb-main { display: flex; flex-direction: column; gap: 12px; }
+.kb-topbar {
+ display: flex; justify-content: space-between; align-items: center;
+ padding: 8px 12px; background: var(--s1); border: 1px solid var(--bd1);
+ border-radius: var(--rm);
+}
+.kb-session { display: flex; align-items: center; gap: 10px; font-size: 12px; color: var(--t1); }
+.kb-actions { display: flex; gap: 8px; }
+.kb-filters { padding: 12px; }
+.kb-msg { font-size: 12px; color: var(--t2); margin-left: 8px; }
+.kb-stats { font-size: 12px; color: var(--t2); padding: 4px 6px; }
+
+.kb-doc-tbl { width: 100%; font-size: 12px; }
+.kb-doc-tbl th, .kb-doc-tbl td { padding: 8px 10px; border-bottom: 1px solid var(--bd1); }
+.kb-doc-tbl th { background: var(--s1); text-align: left; font-weight: 600; color: var(--t1); }
+.kb-doc-tbl td.mono { font-family: var(--ffm); font-size: 11px; color: var(--t2); }
+
+.kb-tag {
+ display: inline-block; padding: 1px 6px; margin: 0 2px 2px 0;
+ background: var(--s2); border: 1px solid var(--bd1); border-radius: 8px;
+ font-size: 11px; color: var(--t1);
+}
+
+.kb-status {
+ display: inline-block; padding: 2px 8px; border-radius: 4px;
+ font-size: 11px; font-weight: 600; text-transform: uppercase;
+}
+.kb-st-pending { background: #3a3a55; color: #aab2d4; }
+.kb-st-parsing { background: #4a4a1a; color: #f3d76b; }
+.kb-st-embedding { background: #4a4a1a; color: #f3d76b; }
+.kb-st-indexed { background: #1f4a2a; color: #6bd58b; }
+.kb-st-failed { background: #5a1f1f; color: #f37070; }
+.kb-st-disabled { background: #303032; color: #888; }
+
+.kb-err {
+ font-size: 11px; color: #f37070; margin-top: 2px;
+ max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
+}
+
+/* 모달 */
+.kb-modal {
+ position: fixed; inset: 0; z-index: 950;
+ background: rgba(0,0,0,.55);
+ display: flex; align-items: center; justify-content: center;
+}
+.kb-modal.hidden { display: none; }
+.kb-modal-body {
+ background: var(--s2); border: 1px solid var(--bd2);
+ border-radius: var(--rl); padding: 22px;
+ width: 460px; max-width: 92vw; max-height: 90vh; overflow-y: auto;
+}
+.kb-modal-title { font-weight: 700; font-size: 15px; margin-bottom: 14px; color: var(--t0); }
+
+/* 청크 미리보기 모달 (큰 사이즈) */
+.kb-chunk-modal-body { width: 820px; max-width: 96vw; max-height: 92vh; }
+.kb-chunk-body { max-height: 70vh; overflow-y: auto; padding-right: 4px; margin-bottom: 12px; }
+.kb-chunk-stat { color: var(--t2); font-size: 12px; margin-bottom: 8px; }
+.kb-chunk-card {
+ border: 1px solid var(--bd1); border-radius: var(--rm);
+ margin-bottom: 8px; background: var(--s1); overflow: hidden;
+}
+.kb-chunk-head {
+ display: flex; align-items: center; gap: 10px;
+ padding: 8px 10px; cursor: pointer; user-select: none;
+ background: var(--s2); border-bottom: 1px solid var(--bd1);
+}
+.kb-chunk-badge {
+ display: inline-block; padding: 2px 8px; border-radius: 4px;
+ font-size: 11px; font-weight: 600;
+ background: #2a3a5a; color: #b8d4ff;
+}
+.kb-chunk-kind-table { background: #3a5a2a; color: #c0e0b0; }
+.kb-chunk-kind-page { background: #5a4a2a; color: #ffd09c; }
+.kb-chunk-kind-row { background: #2a5a4a; color: #a0e0d0; }
+.kb-chunk-locator {
+ flex: 1; font-family: var(--ffm); font-size: 11px; color: var(--t2);
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
+}
+.kb-chunk-len { font-family: var(--ffm); font-size: 11px; color: var(--t3); }
+.kb-chunk-preview {
+ padding: 8px 10px; font-size: 12px; line-height: 1.5; color: var(--t1);
+ white-space: pre-wrap; word-break: break-word;
+}
+.kb-chunk-full { display: none; }
+.kb-chunk-card.open .kb-chunk-preview { display: none; }
+.kb-chunk-card.open .kb-chunk-full { display: block; }
+.kb-chunk-full pre {
+ margin: 0; padding: 10px 12px;
+ font-size: 12px; line-height: 1.55; color: var(--t1);
+ white-space: pre-wrap; word-break: break-word;
+ background: var(--s0); font-family: var(--ffm);
+ max-height: 50vh; overflow-y: auto;
+}
+
+/* Field Instrument Inference */
+.kb-infer-card { border-left: 3px solid #4472C4; }
+.kb-infer-body { padding: 8px 0; }
+.kb-infer-desc { color: #666; font-size: 0.9em; margin: 8px 0; }
+.kb-infer-options { margin: 8px 0; }
+.kb-toggle { display: flex; align-items: center; gap: 6px; cursor: pointer; font-size: 0.9em; }
+.kb-toggle input { margin: 0; }
+.kb-infer-result { margin-top: 12px; padding: 12px; background: #f0f7ff; border-radius: 6px; }
+.kb-infer-stats { display: flex; gap: 16px; margin-bottom: 8px; }
+.kb-stat { font-weight: 600; color: #333; }
+.kb-stat::before { content: ''; display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: #4472C4; margin-right: 4px; }
diff --git a/src/Web/wwwroot/css/llmchat.css b/src/Web/wwwroot/css/llmchat.css
new file mode 100644
index 0000000..9027ac9
--- /dev/null
+++ b/src/Web/wwwroot/css/llmchat.css
@@ -0,0 +1,501 @@
+/* ═══════════════════════════════════════════════════════
+ 로컬 LLM 채팅
+ ══════════════════════════════════════════════════════ */
+
+.llm-layout {
+ display: flex;
+ gap: 0;
+ height: calc(100vh - var(--sw) - 80px);
+ min-height: 560px;
+ 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); }
+}
+
+/* ═══════════════════════════════════════════════════════
+ Phase 5 — 채팅 통합 (툴 카드 / KB 인용 / 표 / 추천 칩)
+ ══════════════════════════════════════════════════════ */
+
+/* 툴 카드 컨테이너 — assistant 메시지 버블 위 */
+.llm-tool-cards {
+ display: flex; flex-direction: column; gap: 6px;
+ margin: 4px 0 8px 0;
+}
+
+.llm-tool-card {
+ border: 1px solid var(--bd1);
+ border-radius: var(--rm);
+ background: var(--s1);
+ font-size: 12px;
+ overflow: hidden;
+}
+.llm-tool-card.running .llm-tool-icon { animation: spin 1.4s linear infinite; }
+.llm-tool-card.ok { border-color: #2a5a3a; }
+.llm-tool-card.err { border-color: #5a2a2a; }
+.llm-tool-card.extending { border-color: #8a6a1f; background: rgba(180, 130, 30, 0.06); }
+.llm-tool-card.extending .llm-tool-icon { animation: spin 0.7s linear infinite; }
+.llm-tool-card.extending .llm-tool-status { color: #e9b94a; }
+
+.llm-tool-head {
+ display: flex; align-items: center; gap: 8px;
+ padding: 6px 10px; cursor: pointer;
+ background: var(--s2);
+ user-select: none;
+}
+.llm-tool-head:hover { background: var(--s3, #2a2a2e); }
+.llm-tool-icon { font-size: 13px; }
+.llm-tool-name { font-weight: 600; color: var(--t0); }
+.llm-tool-args {
+ flex: 1; color: var(--t2); font-size: 11px;
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
+}
+.llm-tool-status { font-size: 11px; color: var(--t2); flex-shrink: 0; }
+.llm-tool-card.ok .llm-tool-status { color: #6bd58b; }
+.llm-tool-card.err .llm-tool-status { color: #f37070; }
+
+.llm-tool-body { display: none; padding: 8px 10px; }
+.llm-tool-card.open .llm-tool-body { display: block; }
+.llm-tool-raw {
+ margin: 0; padding: 6px 8px; max-height: 240px; overflow: auto;
+ font-family: var(--ffm); font-size: 11px;
+ background: var(--s0); border: 1px solid var(--bd1); border-radius: 4px;
+ white-space: pre-wrap; word-break: break-all;
+}
+.llm-tool-err {
+ color: #f37070; font-family: var(--ffm); font-size: 12px;
+ padding: 6px 8px;
+}
+.llm-tool-more {
+ font-size: 11px; color: var(--t2); margin-top: 4px; text-align: right;
+}
+
+/* 툴 결과 표 */
+.llm-tool-tbl-wrap { max-height: 320px; overflow: auto; border: 1px solid var(--bd1); border-radius: 4px; }
+.llm-tool-tbl {
+ width: 100%; border-collapse: collapse; font-size: 11px; font-family: var(--ffm);
+}
+.llm-tool-tbl th, .llm-tool-tbl td {
+ padding: 4px 8px; border-bottom: 1px solid var(--bd1);
+ text-align: left; white-space: nowrap;
+}
+.llm-tool-tbl th {
+ background: var(--s2); position: sticky; top: 0;
+ font-weight: 600; color: var(--t1);
+}
+
+/* 대화 요약 배너 (Phase 7.2) */
+.llm-summary-card {
+ margin: 4px 0 12px 0; padding: 8px 12px;
+ border: 1px dashed #4a5a7a; border-radius: 6px;
+ background: rgba(80, 110, 160, 0.08);
+ cursor: pointer; user-select: none;
+}
+.llm-summary-head { font-size: 12px; color: var(--t2); font-weight: 600; }
+.llm-summary-body {
+ display: none; margin-top: 8px; padding-top: 6px;
+ border-top: 1px solid var(--bd1);
+ font-size: 12px; color: var(--t1); line-height: 1.5;
+ white-space: pre-wrap; word-break: break-word;
+}
+.llm-summary-card.open .llm-summary-body { display: block; }
+
+/* 시계열 스파클라인 (툴 카드 안) */
+.llm-sparkline-box {
+ margin-bottom: 8px; padding: 8px 10px;
+ border: 1px solid var(--bd1); border-radius: 4px;
+ background: var(--s0);
+}
+.llm-sparkline-label { font-size: 11px; color: var(--t2); margin-bottom: 4px; }
+.llm-sparkline-chart { width: 100%; min-height: 90px; }
+.llm-sparkline-chart .u-legend { display: none !important; }
+
+/* KB 검색 결과 (search_kb 카드 안) */
+.llm-kb-hits { display: flex; flex-direction: column; gap: 6px; }
+.llm-kb-hit {
+ padding: 6px 8px; background: var(--s0);
+ border: 1px solid var(--bd1); border-radius: 4px;
+}
+.llm-kb-head { font-size: 12px; color: var(--t0); margin-bottom: 2px; }
+.llm-kb-head .mono { color: var(--t2); margin-right: 6px; }
+.llm-kb-snip { font-size: 11px; color: var(--t1); line-height: 1.4; }
+
+/* KB 인용 링크 (본문 안) */
+.kb-cite-link {
+ color: var(--a); text-decoration: none;
+ border-bottom: 1px dashed var(--a);
+ padding-bottom: 1px;
+}
+.kb-cite-link:hover { color: #fff; border-bottom-style: solid; }
+
+/* welcome 추천 질문 칩 */
+.llm-chip-row {
+ display: flex; flex-wrap: wrap; gap: 8px;
+ justify-content: center; margin-top: 16px;
+ max-width: 720px;
+}
+.llm-chip {
+ padding: 6px 12px;
+ background: var(--s2);
+ border: 1px solid var(--bd1);
+ border-radius: 16px;
+ color: var(--t0);
+ font-family: var(--ff);
+ font-size: 12px;
+ cursor: pointer;
+ transition: all .15s;
+}
+.llm-chip:hover {
+ background: var(--s3, var(--s2));
+ border-color: var(--a);
+ transform: translateY(-1px);
+}
+
+@keyframes spin {
+ from { transform: rotate(0deg); }
+ to { transform: rotate(360deg); }
+}
diff --git a/src/Web/wwwroot/css/opcsvr.css b/src/Web/wwwroot/css/opcsvr.css
new file mode 100644
index 0000000..1f6d08e
--- /dev/null
+++ b/src/Web/wwwroot/css/opcsvr.css
@@ -0,0 +1,14 @@
+/* ── OPC UA 서버 탭 ──────────────────────────────────────── */
+.srv-status-card {
+ background: var(--s2); border: 1px solid var(--bd);
+ border-radius: var(--r); padding: 18px 22px; margin-top: 8px;
+}
+.srv-status-row {
+ display: flex; align-items: center; gap: 10px; margin-bottom: 12px;
+}
+.srv-label { font-size: 18px; font-weight: 600; color: var(--t0); }
+.srv-meta {
+ display: flex; flex-wrap: wrap; gap: 16px;
+ font-size: 13px; color: var(--t1);
+}
+.srv-meta span b { color: var(--t0); }
diff --git a/src/Web/wwwroot/css/pb.css b/src/Web/wwwroot/css/pb.css
new file mode 100644
index 0000000..241f3a9
--- /dev/null
+++ b/src/Web/wwwroot/css/pb.css
@@ -0,0 +1,106 @@
+/* ── 포인트빌더 ──────────────────────────────────────────── */
+.pb-group-card {
+ background: var(--s3);
+ border: 1px solid var(--bd);
+ border-radius: var(--r);
+ padding: 12px 14px;
+ margin-bottom: 10px;
+}
+
+.pb-group-header {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 8px;
+}
+
+.pb-pattern-input {
+ width: 100%;
+}
+
+.pb-attr-checkboxes {
+ display: flex;
+ gap: 14px;
+ margin: 6px 0;
+ flex-wrap: wrap;
+}
+
+.pb-attr-checkboxes label {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ font-size: 13px;
+ color: var(--t1);
+ cursor: pointer;
+}
+
+.pb-custom-attr-inputs {
+ display: flex;
+ gap: 8px;
+ margin-bottom: 6px;
+}
+
+.pb-custom-attr-inputs .inp {
+ flex: 1;
+ min-width: 0;
+ width: auto;
+}
+
+.pb-datatype-select {
+ max-width: 260px;
+}
+
+@media (max-width: 900px) {
+ .pb-custom-attr-inputs { flex-direction: column; }
+}
+
+/* ── 포인트빌더 미리보기 ───────────────────────────────────── */
+.pb-preview {
+ background: var(--s3);
+ border: 1px solid var(--bd);
+ border-radius: var(--r);
+ padding: 14px 16px;
+ margin-top: 10px;
+}
+
+.pb-preview-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 4px;
+ font-weight: 600;
+ font-size: 13px;
+}
+
+.pb-preview-actions {
+ display: flex;
+ gap: 6px;
+ align-items: center;
+}
+
+.pb-preview table th:first-child,
+.pb-preview table td:first-child {
+ width: 36px;
+ text-align: center;
+}
+
+.pb-preview table input[type="checkbox"] {
+ cursor: pointer;
+ width: 15px;
+ height: 15px;
+}
+
+.pb-preview .group-badge {
+ display: inline-block;
+ font-size: 10px;
+ padding: 1px 6px;
+ border-radius: 3px;
+ background: var(--ag);
+ color: var(--a);
+ font-weight: 600;
+}
+
+@media (max-width: 900px) {
+ .pb-preview-header { flex-direction: column; gap: 8px; align-items: flex-start; }
+ .pb-preview-actions { flex-wrap: wrap; }
+}
diff --git a/src/Web/wwwroot/css/pid.css b/src/Web/wwwroot/css/pid.css
new file mode 100644
index 0000000..c923636
--- /dev/null
+++ b/src/Web/wwwroot/css/pid.css
@@ -0,0 +1,236 @@
+/* ── P&ID 추출 스타일 ───────────────────────────────────────── */
+#pane-pid .card-cap {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+#pane-pid .btn-sm {
+ padding: 4px 10px;
+ font-size: 12px;
+ border-radius: 4px;
+ border: 1px solid var(--bd2);
+ background: var(--s1);
+ color: var(--t1);
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+#pane-pid .btn-sm:hover {
+ background: var(--s2);
+ border-color: var(--a);
+}
+
+#pane-pid .btn-sm.btn-a {
+ background: var(--a);
+ border-color: var(--a);
+ color: #fff;
+}
+
+#pane-pid .btn-sm.btn-a:hover {
+ background: var(--a2);
+}
+
+#pane-pid .btn-sm.btn-b {
+ background: var(--s1);
+ border-color: var(--bd2);
+ color: var(--t1);
+}
+
+#pane-pid .btn-sm.btn-b:hover {
+ background: var(--s2);
+}
+
+#pane-pid .badge {
+ display: inline-block;
+ padding: 2px 8px;
+ font-size: 11px;
+ font-weight: 600;
+ border-radius: 4px;
+ white-space: nowrap;
+}
+
+#pane-pid .badge.ok {
+ background: rgba(16,185,129,.15);
+ color: var(--grn);
+}
+
+#pane-pid .badge.warn {
+ background: rgba(234,179,8,.15);
+ color: #eab308;
+}
+
+#pane-pid .badge.err {
+ background: rgba(239,68,68,.15);
+ color: var(--red);
+}
+
+#pane-pid .badge.inf {
+ background: rgba(59,130,246,.15);
+ color: var(--blu);
+}
+
+/* ── Category-grouped prefix rules ──────────────────────────── */
+#pane-pid .pid-cat-group {
+ margin-bottom: 8px;
+ border: 1px solid var(--bd);
+ border-radius: var(--r);
+ overflow: hidden;
+}
+
+#pane-pid .pid-cat-header {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 6px 10px;
+ background: var(--s2);
+ border-bottom: 1px solid var(--bd);
+ font-size: 13px;
+}
+
+#pane-pid .pid-cat-count {
+ font-size: 11px;
+ color: var(--t2);
+}
+
+#pane-pid .pid-cat-add {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+}
+
+#pane-pid .pid-cat-body {
+ padding: 0;
+}
+
+#pane-pid .pid-cat-row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 5px 10px;
+ font-size: 12px;
+ border-bottom: 1px solid var(--bd);
+}
+
+#pane-pid .pid-cat-row:last-child {
+ border-bottom: none;
+}
+
+#pane-pid .pid-cat-prefix {
+ width: 80px;
+ flex-shrink: 0;
+}
+
+#pane-pid .pid-cat-desc {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+#pane-pid .pid-cat-order {
+ width: 40px;
+ text-align: center;
+ flex-shrink: 0;
+ color: var(--t2);
+}
+
+#pane-pid .pid-cat-actions {
+ width: 90px;
+ display: flex;
+ gap: 3px;
+ flex-shrink: 0;
+}
+
+#pane-pid .stat-box {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding: 12px;
+ background: var(--s1);
+ border-radius: var(--r);
+ border: 1px solid var(--bd);
+}
+
+#pane-pid .stat-label {
+ font-size: 12px;
+ color: var(--t2);
+ margin-bottom: 4px;
+}
+
+#pane-pid .stat-value {
+ font-size: 20px;
+ font-weight: 700;
+ color: var(--t0);
+ font-family: var(--fm);
+}
+
+#pane-pid .pagination {
+ display: flex;
+ justify-content: center;
+ gap: 4px;
+ padding: 8px;
+}
+
+#pane-pid .pagination button {
+ padding: 4px 10px;
+ font-size: 12px;
+ border-radius: 4px;
+ border: 1px solid var(--bd2);
+ background: var(--s1);
+ color: var(--t1);
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+#pane-pid .pagination button:hover {
+ background: var(--s2);
+ border-color: var(--a);
+}
+
+#pane-pid .pagination button.btn-a {
+ background: var(--a);
+ border-color: var(--a);
+ color: #fff;
+}
+
+#pane-pid .pagination button.btn-a:hover {
+ background: var(--a2);
+}
+
+#pane-pid .pagination button.btn-b {
+ background: var(--s1);
+ border-color: var(--bd2);
+ color: var(--t1);
+}
+
+#pane-pid .pagination button.btn-b:hover {
+ background: var(--s2);
+}
+
+#pane-pid .loading {
+ padding: 20px;
+ text-align: center;
+ color: var(--t2);
+}
+
+#pane-pid .logbox {
+ background: var(--s1);
+ border: 1px solid var(--bd);
+ border-radius: var(--r);
+ padding: 12px;
+ margin-top: 12px;
+ max-height: 200px;
+ overflow-y: auto;
+ font-family: var(--fm);
+ font-size: 12px;
+}
+
+#pane-pid .logbox .ll {
+ padding: 2px 0;
+}
+
+#pane-pid .logbox .ok { color: var(--grn); }
+#pane-pid .logbox .err { color: var(--red); }
+#pane-pid .logbox .inf { color: var(--blu); }
diff --git a/src/Web/wwwroot/css/style.css b/src/Web/wwwroot/css/style.css
index d70a517..5a24195 100644
--- a/src/Web/wwwroot/css/style.css
+++ b/src/Web/wwwroot/css/style.css
@@ -114,7 +114,7 @@ html, body { height: 100%; background: var(--s0); color: var(--t1); font-family:
background: var(--t2); transition: all var(--tr);
flex-shrink: 0;
}
-.dot.ok { background: var(--grn); box-shadow: 0 0 6px var(--grn); }
+.dot.ok, .dot.grn { background: var(--grn); box-shadow: 0 0 6px var(--grn); }
.dot.err { background: var(--red); box-shadow: 0 0 6px var(--red); }
.dot.busy { background: var(--a); animation: blink 1s infinite; }
@@ -392,7 +392,7 @@ tr:last-child td { border-bottom: none; }
color: var(--t0); margin-bottom: 3px;
}
-/* ── Nodemap row ─────────────────────────────────────────── */
+/* ── Nodemap row & Import mode toggle (shared across tabs) ── */
.nm-row {
display: flex; align-items: flex-end; gap: 16px; flex-wrap: wrap;
}
@@ -407,7 +407,6 @@ tr:last-child td { border-bottom: none; }
border-radius: 3px; color: var(--a);
}
-/* ── Import mode toggle ──────────────────────────────────── */
.mode-group {
display: flex; gap: 8px;
}
@@ -435,7 +434,7 @@ tr:last-child td { border-bottom: none; }
.mode-opt:hover { border-color: var(--bd2); color: var(--t0); }
-/* ── Nodemap Dashboard ───────────────────────────────────── */
+/* ── Nodemap Dashboard (shared: nm-dash / evt / hist / crawl) ── */
.nm-stat-row {
display: grid;
grid-template-columns: repeat(5, 1fr);
@@ -484,7 +483,6 @@ tr:last-child td { border-bottom: none; }
border: 1px solid rgba(76,166,255,.2);
}
-/* ── 이름 OR 선택 드롭다운 ────────────────────────────────── */
.nm-name-row { margin-top: 10px; }
.nm-name-selects {
@@ -500,7 +498,7 @@ tr:last-child td { border-bottom: none; }
.nm-name-selects { grid-template-columns: repeat(2, 1fr); }
}
-/* ── 포인트빌더 ──────────────────────────────────────────── */
+/* ── pb-name-grid (shared: pb + hist) ─────────────────────── */
.pb-name-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
@@ -508,21 +506,6 @@ tr:last-child td { border-bottom: none; }
margin-top: 6px;
}
-.pb-group-card {
- background: var(--s3);
- border: 1px solid var(--bd);
- border-radius: var(--r);
- padding: 12px 14px;
- margin-bottom: 10px;
-}
-
-.pb-group-header {
- display: flex;
- align-items: center;
- gap: 8px;
- margin-bottom: 8px;
-}
-
.card-sub-cap {
font-size: 12px;
font-weight: 700;
@@ -531,48 +514,11 @@ tr:last-child td { border-bottom: none; }
letter-spacing: .5px;
}
-.pb-pattern-input {
- width: 100%;
-}
-
-.pb-attr-checkboxes {
- display: flex;
- gap: 14px;
- margin: 6px 0;
- flex-wrap: wrap;
-}
-
-.pb-attr-checkboxes label {
- display: flex;
- align-items: center;
- gap: 4px;
- font-size: 13px;
- color: var(--t1);
- cursor: pointer;
-}
-
-.pb-custom-attr-inputs {
- display: flex;
- gap: 8px;
- margin-bottom: 6px;
-}
-
-.pb-custom-attr-inputs .inp {
- flex: 1;
- min-width: 0;
- width: auto;
-}
-
-.pb-datatype-select {
- max-width: 260px;
-}
-
@media (max-width: 900px) {
.pb-name-grid { grid-template-columns: repeat(2, 1fr); }
- .pb-custom-attr-inputs { flex-direction: column; }
}
-/* ── Custom DateTime Picker ──────────────────────────────── */
+/* ── Custom DateTime Picker (shared component) ──────────────── */
.dt-display {
cursor: pointer; user-select: none;
color: var(--t0); display: flex; align-items: center;
@@ -657,563 +603,7 @@ tr:last-child td { border-bottom: none; }
display: flex; justify-content: flex-end; gap: 6px;
}
-/* ── OPC UA 서버 탭 ──────────────────────────────────────── */
-.srv-status-card {
- background: var(--s2); border: 1px solid var(--bd);
- border-radius: var(--r); padding: 18px 22px; margin-top: 8px;
-}
-.srv-status-row {
- display: flex; align-items: center; gap: 10px; margin-bottom: 12px;
-}
-.srv-label { font-size: 18px; font-weight: 600; color: var(--t0); }
-.srv-meta {
- display: flex; flex-wrap: wrap; gap: 16px;
- font-size: 13px; color: var(--t1);
-}
-.srv-meta span b { color: var(--t0); }
-.dot.grn { background: var(--grn); box-shadow: 0 0 6px var(--grn); }
-
-/* ── Text-to-SQL Dashboard ───────────────────────────────── */
-
-/* 입력 행 */
-.t2s-input-row {
- display: flex; gap: 8px; align-items: flex-end;
-}
-.t2s-input-row .fg { flex: 1; margin-bottom: 0; }
-
-/* 제안 칩 */
-.t2s-chips { display: flex; flex-wrap: wrap; gap: 6px; }
-.t2s-chip {
- background: var(--s3); border: 1px solid var(--bd);
- color: var(--t1); border-radius: 12px;
- padding: 4px 12px; cursor: pointer; font-size: 12px;
- transition: all var(--tr);
-}
-.t2s-chip:hover { background: var(--s4); color: var(--t0); border-color: var(--a); }
-
-/* SQL 텍스트영역 */
-.t2s-sql-area {
- width: 100%; min-height: 80px; resize: vertical;
- background: var(--s2); border: 1px solid var(--bd);
- color: var(--t0); border-radius: var(--r);
- padding: 10px 12px; font-family: var(--fm); font-size: 13px;
- line-height: 1.5;
-}
-.t2s-sql-area:focus { outline: none; border-color: var(--a); }
-.t2s-sql-area:disabled { opacity: 0.6; }
-
-/* 태그 분석 옵션 */
-.t2s-tag-label {
- display: block; font-size: 12px; color: var(--t1);
- margin-bottom: 4px;
-}
-
-/* 결과 테이블 - 스크롤 및 높이 설정 */
-.t2s-result-info {
- font-size: 13px; color: var(--t1); margin-bottom: 10px;
- padding: 8px 0;
-}
-
-/* 조회 결과 컨테이너 - 스크롤 활성화 및 높이 증가 */
-.t2s-result-container {
- max-height: 600px;
- min-height: 400px;
- overflow-y: auto;
- overflow-x: auto;
- border: 1px solid var(--bd);
- border-radius: var(--r);
-}
-
-/* 조회 결과 영역 - 여러 값 표시 및 스크롤바 */
-#t2s-results {
- max-height: 600px;
- min-height: 400px;
- overflow-y: auto;
- overflow-x: auto;
- min-height: 200px;
-}
-
-/* 스크롤바 스타일링 - 오른쪽에 명시적으로 표시 */
-#t2s-results::-webkit-scrollbar {
- width: 10px;
- height: 10px;
-}
-
-#t2s-results::-webkit-scrollbar-track {
- background: var(--s1);
- border-radius: 5px;
-}
-
-#t2s-results::-webkit-scrollbar-thumb {
- background: var(--bd2);
- border-radius: 5px;
-}
-
-#t2s-results::-webkit-scrollbar-thumb:hover {
- background: var(--t2);
-}
-
-#t2s-results::-webkit-scrollbar-corner {
- background: var(--s1);
-}
-
-.t2s-table {
- width: 100%; border-collapse: collapse;
- font-size: 13px; font-family: var(--fm);
-}
-.t2s-table thead { background: var(--s3); }
-.t2s-table th {
- padding: 8px 12px; text-align: left;
- color: var(--t0); font-weight: 600;
- border-bottom: 2px solid var(--bd);
- white-space: nowrap;
-}
-.t2s-table td {
- padding: 6px 12px;
- border-bottom: 1px solid var(--bd);
- color: var(--t1);
- max-width: 300px; overflow: hidden;
- text-overflow: ellipsis; white-space: nowrap;
-}
-.t2s-table tbody tr:hover { background: var(--s3); }
-.t2s-null { color: var(--t2); font-style: italic; }
-
-/* 로딩 / 빈 결과 / 오류 */
-.t2s-loading, .t2s-empty {
- text-align: center; padding: 40px 20px;
- color: var(--t1); font-size: 14px;
-}
-.t2s-loading { color: var(--a); }
-.t2s-error {
- text-align: center; padding: 40px 20px;
- color: var(--red); font-size: 14px;
-}
-
-/* 분석 결과 그리드 */
-.t2s-analysis-grid {
- display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
- gap: 12px; margin-top: 10px;
-}
-.t2s-tag-card {
- background: var(--s2); border: 1px solid var(--bd);
- border-radius: var(--r); padding: 14px 16px;
-}
-.t2s-tag-card h4 {
- font-size: 14px; color: var(--t0); margin-bottom: 10px;
- font-weight: 600;
-}
-.t2s-tag-stats { font-size: 13px; }
-.t2s-stat-row {
- display: flex; justify-content: space-between;
- padding: 4px 0; border-bottom: 1px solid var(--bd);
-}
-.t2s-stat-row:last-child { border-bottom: none; }
-.t2s-stat-row span:first-child { color: var(--t1); }
-.t2s-value { color: var(--t0); font-family: var(--fm); font-weight: 500; }
-.t2s-max { color: var(--red); }
-.t2s-min { color: var(--blu); }
-
-/* ── 채팅 UI 스타일 ────────────────────────────────────────── */
-
-/* 채팅 카드 */
-.t2s-chat-card {
- margin-bottom: 18px;
-}
-
-/* 채팅 컨테이너 - 스크롤 활성화 */
-.t2s-chat-container {
- max-height: 450px;
- min-height: 300px;
- overflow-y: auto;
- overflow-x: hidden;
- padding: 4px 0;
-}
-
-/* 채팅 메시지 */
-.t2s-chat-msg {
- margin-bottom: 12px;
- display: flex;
- animation: t2s-msg-fade-in 0.3s ease;
-}
-
-@keyframes t2s-msg-fade-in {
- from { opacity: 0; transform: translateY(10px); }
- to { opacity: 1; transform: translateY(0); }
-}
-
-.t2s-chat-msg.user {
- justify-content: flex-end;
-}
-
-.t2s-chat-msg.system {
- justify-content: flex-start;
-}
-
-/* 채팅 버블 */
-.t2s-chat-bubble {
- max-width: 85%;
- padding: 10px 14px;
- border-radius: 12px;
- font-size: 13px;
- line-height: 1.5;
- word-wrap: break-word;
-}
-
-/* 사용자 메시지 */
-.t2s-chat-msg.user .t2s-chat-bubble {
- background: var(--a);
- color: var(--t0);
- border-bottom-right-radius: 4px;
-}
-
-/* 시스템 메시지 */
-.t2s-chat-msg.system .t2s-chat-bubble {
- background: var(--s2);
- color: var(--t1);
- border: 1px solid var(--bd);
- border-bottom-left-radius: 4px;
-}
-
-.t2s-chat-msg.system .t2s-chat-bubble strong {
- color: var(--t0);
-}
-
-/* 채팅 입력 행 */
-.t2s-chat-input-row {
- display: flex;
- gap: 8px;
- align-items: flex-end;
- margin-top: 12px;
- padding-top: 12px;
- border-top: 1px solid var(--bd);
-}
-
-.t2s-chat-input {
- flex: 1;
-}
-
-/* 채팅 SQL 표시 */
-.t2s-chat-sql {
- background: var(--s1);
- border: 1px solid var(--bd);
- border-radius: 6px;
- padding: 8px 10px;
- font-family: var(--fm);
- font-size: 12px;
- color: var(--t0);
- margin-top: 6px;
- white-space: pre-wrap;
- max-height: 150px;
- overflow-y: auto;
-}
-
-/* 타이핑 인디케이터 */
-.t2s-typing {
- display: inline-flex;
- align-items: center;
- gap: 4px;
- color: var(--t2);
- font-style: italic;
-}
-
-.t2s-typing::after {
- content: '';
- width: 6px;
- height: 6px;
- background: var(--a);
- border-radius: 50%;
- animation: t2s-typing-dot 1.4s infinite both;
-}
-
-.t2s-typing::before {
- content: '';
- width: 6px;
- height: 6px;
- background: var(--a);
- border-radius: 50%;
- animation: t2s-typing-dot 1.4s infinite both 0.2s;
-}
-
-@keyframes t2s-typing-dot {
- 0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); }
- 40% { opacity: 1; transform: scale(1); }
-}
-
-/* ── API 대화 페이지 스타일 ────────────────────────────────── */
-
-/* API 채팅 컨테이너 - 스크롤 활성화 */
-.api-chat-container {
- flex: 1;
- display: flex;
- flex-direction: column;
- overflow: hidden;
-}
-
-.api-chat-messages {
- flex: 1;
- overflow-y: auto;
- padding: 12px;
-}
-
-/* API 채팅 메시지 */
-.api-chat-msg {
- margin-bottom: 12px;
- display: flex;
- animation: api-msg-fade-in 0.3s ease;
-}
-
-@keyframes api-msg-fade-in {
- from { opacity: 0; transform: translateY(10px); }
- to { opacity: 1; transform: translateY(0); }
-}
-
-.api-chat-msg.user {
- justify-content: flex-end;
-}
-
-.api-chat-msg.system {
- justify-content: flex-start;
-}
-
-.api-chat-msg.assistant {
- justify-content: flex-start;
-}
-
-/* API 채팅 버블 */
-.api-chat-bubble {
- max-width: 85%;
- padding: 10px 14px;
- border-radius: 12px;
- font-size: 13px;
- line-height: 1.5;
- word-wrap: break-word;
-}
-
-/* 사용자 메시지 */
-.api-chat-msg.user .api-chat-bubble {
- background: var(--a);
- color: var(--t0);
- border-bottom-right-radius: 4px;
-}
-
-/* 시스템 메시지 */
-.api-chat-msg.system .api-chat-bubble {
- background: var(--s2);
- color: var(--t1);
- border: 1px solid var(--bd);
- border-bottom-left-radius: 4px;
-}
-
-.api-chat-msg.system .api-chat-bubble strong {
- color: var(--t0);
-}
-
-/* 어시스턴트 메시지 */
-.api-chat-msg.assistant .api-chat-bubble {
- background: var(--s2);
- color: var(--t1);
- border: 1px solid var(--bd);
- border-bottom-left-radius: 4px;
-}
-
-.api-chat-msg.assistant .api-chat-bubble strong {
- color: var(--t0);
-}
-
-/* API 채팅 입력 행 */
-.api-chat-input-row {
- display: flex;
- flex-direction: column;
- gap: 8px;
- padding: 12px;
- border-top: 1px solid var(--bd);
- background: var(--s1);
-}
-
-.api-chat-input {
- flex: 1;
- resize: none;
- font-family: var(--fm);
- font-size: 13px;
-}
-
-.api-chat-btn-row {
- display: flex;
- gap: 8px;
- justify-content: flex-end;
-}
-
-/* SQL 쿼리 표시 */
-.api-chat-sql {
- background: var(--s1);
- border: 1px solid var(--bd);
- border-radius: 6px;
- padding: 8px 10px;
- font-family: var(--fm);
- font-size: 12px;
- color: var(--t0);
- margin-top: 6px;
- white-space: pre-wrap;
- max-height: 150px;
- overflow-y: auto;
-}
-
-/* 응답 컨테이너 */
-.api-response-container {
- flex: 1;
- display: flex;
- flex-direction: column;
- overflow: hidden;
-}
-
-.api-response-content {
- flex: 1;
- overflow-y: auto;
- padding: 12px;
- font-family: var(--fm);
- font-size: 13px;
- color: var(--t1);
-}
-
-.api-response-content .placeholder {
- color: var(--t2);
- font-style: italic;
-}
-
-/* 테이블 스타일 */
-.api-response-table {
- width: 100%;
- border-collapse: collapse;
- font-size: 12px;
-}
-
-.api-response-table th {
- background: var(--s2);
- color: var(--t0);
- font-weight: 600;
- padding: 8px 12px;
- text-align: left;
- border: 1px solid var(--bd);
- position: sticky;
- top: 0;
-}
-
-.api-response-table td {
- padding: 6px 12px;
- border: 1px solid var(--bd);
- color: var(--t1);
-}
-
-.api-response-table tr:nth-child(even) {
- background: var(--s1);
-}
-
-.api-response-table tr:nth-child(odd) {
- background: var(--s2);
-}
-
-/* ── History Query Status Box ──────────────────────────────── */
-.hist-status-box {
- background: var(--s2);
- border: 1px solid var(--bd);
- border-radius: var(--r);
- padding: 14px 16px;
- margin: 12px 0;
- transition: all var(--tr);
-}
-.hist-status-box.pending { border-color: var(--t2); }
-.hist-status-box.busy { border-color: var(--blu); }
-.hist-status-box.ok { border-color: var(--grn); }
-.hist-status-box.err { border-color: var(--red); }
-
-.hist-status-header {
- display: flex; align-items: center; gap: 10px;
- font-size: 14px; font-weight: 600;
-}
-.hist-status-icon {
- font-size: 18px;
- width: 28px; height: 28px;
- display: flex; align-items: center; justify-content: center;
- background: var(--s3);
- border-radius: 50%;
-}
-.hist-status-text { color: var(--t0); }
-.hist-status-detail {
- margin-top: 8px;
- font-size: 12px;
- color: var(--t1);
- font-family: var(--fm);
- line-height: 1.5;
- word-break: break-all;
-}
-
-/* ── Hypertable Management ─────────────────────────────────── */
-.ht-hidden { display: none !important; }
-
-.ht-status-box {
- background: var(--s2);
- border: 1px solid var(--bd);
- border-radius: var(--r);
- padding: 14px 16px;
- margin: 12px 0;
- transition: all var(--tr);
-}
-.ht-status-box.pending { border-color: var(--t2); }
-.ht-status-box.busy { border-color: var(--blu); }
-.ht-status-box.ok { border-color: var(--grn); }
-.ht-status-box.err { border-color: var(--red); }
-
-.ht-status-header {
- display: flex; align-items: center; gap: 10px;
- font-size: 14px; font-weight: 600;
-}
-.ht-status-icon {
- font-size: 18px;
- width: 28px; height: 28px;
- display: flex; align-items: center; justify-content: center;
- background: var(--s3);
- border-radius: 50%;
-}
-.ht-status-text { color: var(--t0); }
-.ht-status-detail {
- margin-top: 8px;
- font-size: 12px;
- color: var(--t1);
- font-family: var(--fm);
- line-height: 1.5;
- word-break: break-all;
-}
-
-.ht-info-panel {
- margin-top: 12px;
- padding-top: 12px;
- border-top: 1px solid var(--bd);
-}
-.ht-info-grid {
- display: grid;
- grid-template-columns: repeat(2, 1fr);
- gap: 10px;
-}
-.ht-info-item {
- display: flex;
- flex-direction: column;
- gap: 4px;
-}
-.ht-info-label {
- font-size: 11px;
- color: var(--t2);
- text-transform: uppercase;
- letter-spacing: 0.05em;
- font-weight: 600;
-}
-.ht-info-value {
- font-size: 13px;
- color: var(--t0);
- font-family: var(--fm);
- font-weight: 500;
-}
-
-/* ── 이력 조회 상태 표시기 ─────────────────────────────────── */
+/* ── hist-status (shared: hist + evt) ───────────────────────── */
.hist-status {
display: inline-flex;
align-items: center;
@@ -1278,954 +668,3 @@ tr:last-child td { border-bottom: none; }
::-webkit-scrollbar-track { background: var(--s1); }
::-webkit-scrollbar-thumb { background: var(--bd2); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--t2); }
-
-/* ── P&ID 추출 스타일 ───────────────────────────────────────── */
-#pane-pid .card-cap {
- display: flex;
- justify-content: space-between;
- align-items: center;
-}
-
-#pane-pid .btn-sm {
- padding: 4px 10px;
- font-size: 12px;
- border-radius: 4px;
- border: 1px solid var(--bd2);
- background: var(--s1);
- color: var(--t1);
- cursor: pointer;
- transition: all 0.2s;
-}
-
-#pane-pid .btn-sm:hover {
- background: var(--s2);
- border-color: var(--a);
-}
-
-#pane-pid .btn-sm.btn-a {
- background: var(--a);
- border-color: var(--a);
- color: #fff;
-}
-
-#pane-pid .btn-sm.btn-a:hover {
- background: var(--a2);
-}
-
-#pane-pid .btn-sm.btn-b {
- background: var(--s1);
- border-color: var(--bd2);
- color: var(--t1);
-}
-
-#pane-pid .btn-sm.btn-b:hover {
- background: var(--s2);
-}
-
-#pane-pid .badge {
- display: inline-block;
- padding: 2px 8px;
- font-size: 11px;
- font-weight: 600;
- border-radius: 4px;
- white-space: nowrap;
-}
-
-#pane-pid .badge.ok {
- background: rgba(16,185,129,.15);
- color: var(--grn);
-}
-
-#pane-pid .badge.warn {
- background: rgba(234,179,8,.15);
- color: #eab308;
-}
-
-#pane-pid .badge.err {
- background: rgba(239,68,68,.15);
- color: var(--red);
-}
-
-#pane-pid .badge.inf {
- background: rgba(59,130,246,.15);
- color: var(--blu);
-}
-
-/* ── Category-grouped prefix rules ──────────────────────────── */
-#pane-pid .pid-cat-group {
- margin-bottom: 8px;
- border: 1px solid var(--bd);
- border-radius: var(--r);
- overflow: hidden;
-}
-
-#pane-pid .pid-cat-header {
- display: flex;
- align-items: center;
- gap: 8px;
- padding: 6px 10px;
- background: var(--s2);
- border-bottom: 1px solid var(--bd);
- font-size: 13px;
-}
-
-#pane-pid .pid-cat-count {
- font-size: 11px;
- color: var(--t2);
-}
-
-#pane-pid .pid-cat-add {
- display: flex;
- align-items: center;
- gap: 4px;
-}
-
-#pane-pid .pid-cat-body {
- padding: 0;
-}
-
-#pane-pid .pid-cat-row {
- display: flex;
- align-items: center;
- gap: 8px;
- padding: 5px 10px;
- font-size: 12px;
- border-bottom: 1px solid var(--bd);
-}
-
-#pane-pid .pid-cat-row:last-child {
- border-bottom: none;
-}
-
-#pane-pid .pid-cat-prefix {
- width: 80px;
- flex-shrink: 0;
-}
-
-#pane-pid .pid-cat-desc {
- flex: 1;
- min-width: 0;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-#pane-pid .pid-cat-order {
- width: 40px;
- text-align: center;
- flex-shrink: 0;
- color: var(--t2);
-}
-
-#pane-pid .pid-cat-actions {
- width: 90px;
- display: flex;
- gap: 3px;
- flex-shrink: 0;
-}
-
-#pane-pid .stat-box {
- display: flex;
- flex-direction: column;
- align-items: center;
- padding: 12px;
- background: var(--s1);
- border-radius: var(--r);
- border: 1px solid var(--bd);
-}
-
-#pane-pid .stat-label {
- font-size: 12px;
- color: var(--t2);
- margin-bottom: 4px;
-}
-
-#pane-pid .stat-value {
- font-size: 20px;
- font-weight: 700;
- color: var(--t0);
- font-family: var(--fm);
-}
-
-#pane-pid .pagination {
- display: flex;
- justify-content: center;
- gap: 4px;
- padding: 8px;
-}
-
-#pane-pid .pagination button {
- padding: 4px 10px;
- font-size: 12px;
- border-radius: 4px;
- border: 1px solid var(--bd2);
- background: var(--s1);
- color: var(--t1);
- cursor: pointer;
- transition: all 0.2s;
-}
-
-#pane-pid .pagination button:hover {
- background: var(--s2);
- border-color: var(--a);
-}
-
-#pane-pid .pagination button.btn-a {
- background: var(--a);
- border-color: var(--a);
- color: #fff;
-}
-
-#pane-pid .pagination button.btn-a:hover {
- background: var(--a2);
-}
-
-#pane-pid .pagination button.btn-b {
- background: var(--s1);
- border-color: var(--bd2);
- color: var(--t1);
-}
-
-#pane-pid .pagination button.btn-b:hover {
- background: var(--s2);
-}
-
-#pane-pid .loading {
- padding: 20px;
- text-align: center;
- color: var(--t2);
-}
-
-#pane-pid .logbox {
- background: var(--s1);
- border: 1px solid var(--bd);
- border-radius: var(--r);
- padding: 12px;
- margin-top: 12px;
- max-height: 200px;
- overflow-y: auto;
- font-family: var(--fm);
- font-size: 12px;
-}
-
-#pane-pid .logbox .ll {
- padding: 2px 0;
-}
-
-#pane-pid .logbox .ok { color: var(--grn); }
-#pane-pid .logbox .err { color: var(--red); }
-#pane-pid .logbox .inf { color: var(--blu); }
-
-/* ── 포인트빌더 미리보기 ───────────────────────────────────── */
-.pb-preview {
- background: var(--s3);
- border: 1px solid var(--bd);
- border-radius: var(--r);
- padding: 14px 16px;
- margin-top: 10px;
-}
-
-.pb-preview-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 4px;
- font-weight: 600;
- font-size: 13px;
-}
-
-.pb-preview-actions {
- display: flex;
- gap: 6px;
- align-items: center;
-}
-
-.pb-preview table th:first-child,
-.pb-preview table td:first-child {
- width: 36px;
- text-align: center;
-}
-
-.pb-preview table input[type="checkbox"] {
- cursor: pointer;
- width: 15px;
- height: 15px;
-}
-
-.pb-preview .group-badge {
- display: inline-block;
- font-size: 10px;
- padding: 1px 6px;
- border-radius: 3px;
- background: var(--ag);
- color: var(--a);
- font-weight: 600;
-}
-
-@media (max-width: 900px) {
- .pb-preview-header { flex-direction: column; gap: 8px; align-items: flex-start; }
- .pb-preview-actions { flex-wrap: wrap; }
-}
-
-/* ── Event History ─────────────────────────────────────────── */
-.evt-badge {
- display: inline-block;
- font-family: var(--fm); font-size: 10px; font-weight: 700;
- letter-spacing: .06em; padding: 2px 8px; border-radius: 3px;
- text-transform: uppercase; white-space: nowrap;
-}
-.evt-badge.trip { background: rgba(239,68,68,.18); color: #f87171; }
-.evt-badge.run { background: rgba(16,185,129,.18); color: #34d399; }
-.evt-badge.alarm { background: rgba(245,158,11,.18); color: #fbbf24; }
-.evt-badge.normal { background: rgba(148,163,184,.18); color: #94a3b8; }
-.evt-badge.change { background: rgba(96,165,250,.18); color: #60a5fa; }
-
-.evt-summary-grid {
- display: grid;
- grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
- gap: 12px;
- margin-top: 12px;
-}
-
-.evt-summary-item {
- background: var(--s2);
- border: 1px solid var(--bd);
- border-radius: var(--r);
- padding: 14px 16px;
-}
-
-.evt-summary-section {
- font-family: var(--fm); font-size: 13px; font-weight: 700;
- color: var(--t0); margin-bottom: 10px;
-}
-
-.evt-summary-counts {
- display: flex; gap: 10px; flex-wrap: wrap;
-}
-
-.evt-count {
- display: flex; align-items: center; gap: 5px;
- font-family: var(--fm); font-size: 11px; color: var(--t2);
-}
-
-.evt-count strong { font-size: 14px; color: var(--t0); }
-
-.evt-total {
- font-family: var(--fm); font-size: 11px; color: var(--t2);
- margin-top: 8px; padding-top: 8px;
- border-top: 1px solid var(--bd);
-}
-
-.evt-total strong { color: var(--t0); }
-
-/* ═══════════════════════════════════════════════════════
- 로컬 LLM 채팅
- ══════════════════════════════════════════════════════ */
-
-.llm-layout {
- display: flex;
- gap: 0;
- height: calc(100vh - var(--sw) - 80px);
- min-height: 560px;
- 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); }
-}
-
-/* ═══════════════════════════════════════════════════════
- 14 RAG 관리 (KB Admin)
- ══════════════════════════════════════════════════════ */
-.kb-login-card { max-width: 460px; }
-.kb-main { display: flex; flex-direction: column; gap: 12px; }
-.kb-topbar {
- display: flex; justify-content: space-between; align-items: center;
- padding: 8px 12px; background: var(--s1); border: 1px solid var(--bd1);
- border-radius: var(--rm);
-}
-.kb-session { display: flex; align-items: center; gap: 10px; font-size: 12px; color: var(--t1); }
-.kb-actions { display: flex; gap: 8px; }
-.kb-filters { padding: 12px; }
-.kb-msg { font-size: 12px; color: var(--t2); margin-left: 8px; }
-.kb-stats { font-size: 12px; color: var(--t2); padding: 4px 6px; }
-
-.kb-doc-tbl { width: 100%; font-size: 12px; }
-.kb-doc-tbl th, .kb-doc-tbl td { padding: 8px 10px; border-bottom: 1px solid var(--bd1); }
-.kb-doc-tbl th { background: var(--s1); text-align: left; font-weight: 600; color: var(--t1); }
-.kb-doc-tbl td.mono { font-family: var(--ffm); font-size: 11px; color: var(--t2); }
-
-.kb-tag {
- display: inline-block; padding: 1px 6px; margin: 0 2px 2px 0;
- background: var(--s2); border: 1px solid var(--bd1); border-radius: 8px;
- font-size: 11px; color: var(--t1);
-}
-
-.kb-status {
- display: inline-block; padding: 2px 8px; border-radius: 4px;
- font-size: 11px; font-weight: 600; text-transform: uppercase;
-}
-.kb-st-pending { background: #3a3a55; color: #aab2d4; }
-.kb-st-parsing { background: #4a4a1a; color: #f3d76b; }
-.kb-st-embedding { background: #4a4a1a; color: #f3d76b; }
-.kb-st-indexed { background: #1f4a2a; color: #6bd58b; }
-.kb-st-failed { background: #5a1f1f; color: #f37070; }
-.kb-st-disabled { background: #303032; color: #888; }
-
-.kb-err {
- font-size: 11px; color: #f37070; margin-top: 2px;
- max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
-}
-
-/* 모달 */
-.kb-modal {
- position: fixed; inset: 0; z-index: 950;
- background: rgba(0,0,0,.55);
- display: flex; align-items: center; justify-content: center;
-}
-.kb-modal.hidden { display: none; }
-.kb-modal-body {
- background: var(--s2); border: 1px solid var(--bd2);
- border-radius: var(--rl); padding: 22px;
- width: 460px; max-width: 92vw; max-height: 90vh; overflow-y: auto;
-}
-.kb-modal-title { font-weight: 700; font-size: 15px; margin-bottom: 14px; color: var(--t0); }
-
-/* 청크 미리보기 모달 (큰 사이즈) */
-.kb-chunk-modal-body { width: 820px; max-width: 96vw; max-height: 92vh; }
-.kb-chunk-body { max-height: 70vh; overflow-y: auto; padding-right: 4px; margin-bottom: 12px; }
-.kb-chunk-stat { color: var(--t2); font-size: 12px; margin-bottom: 8px; }
-.kb-chunk-card {
- border: 1px solid var(--bd1); border-radius: var(--rm);
- margin-bottom: 8px; background: var(--s1); overflow: hidden;
-}
-.kb-chunk-head {
- display: flex; align-items: center; gap: 10px;
- padding: 8px 10px; cursor: pointer; user-select: none;
- background: var(--s2); border-bottom: 1px solid var(--bd1);
-}
-.kb-chunk-badge {
- display: inline-block; padding: 2px 8px; border-radius: 4px;
- font-size: 11px; font-weight: 600;
- background: #2a3a5a; color: #b8d4ff;
-}
-.kb-chunk-kind-table { background: #3a5a2a; color: #c0e0b0; }
-.kb-chunk-kind-page { background: #5a4a2a; color: #ffd09c; }
-.kb-chunk-kind-row { background: #2a5a4a; color: #a0e0d0; }
-.kb-chunk-locator {
- flex: 1; font-family: var(--ffm); font-size: 11px; color: var(--t2);
- overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
-}
-.kb-chunk-len { font-family: var(--ffm); font-size: 11px; color: var(--t3); }
-.kb-chunk-preview {
- padding: 8px 10px; font-size: 12px; line-height: 1.5; color: var(--t1);
- white-space: pre-wrap; word-break: break-word;
-}
-.kb-chunk-full { display: none; }
-.kb-chunk-card.open .kb-chunk-preview { display: none; }
-.kb-chunk-card.open .kb-chunk-full { display: block; }
-.kb-chunk-full pre {
- margin: 0; padding: 10px 12px;
- font-size: 12px; line-height: 1.55; color: var(--t1);
- white-space: pre-wrap; word-break: break-word;
- background: var(--s0); font-family: var(--ffm);
- max-height: 50vh; overflow-y: auto;
-}
-
-/* ═══════════════════════════════════════════════════════
- Phase 5 — 채팅 통합 (툴 카드 / KB 인용 / 표 / 추천 칩)
- ══════════════════════════════════════════════════════ */
-
-/* 툴 카드 컨테이너 — assistant 메시지 버블 위 */
-.llm-tool-cards {
- display: flex; flex-direction: column; gap: 6px;
- margin: 4px 0 8px 0;
-}
-
-.llm-tool-card {
- border: 1px solid var(--bd1);
- border-radius: var(--rm);
- background: var(--s1);
- font-size: 12px;
- overflow: hidden;
-}
-.llm-tool-card.running .llm-tool-icon { animation: spin 1.4s linear infinite; }
-.llm-tool-card.ok { border-color: #2a5a3a; }
-.llm-tool-card.err { border-color: #5a2a2a; }
-.llm-tool-card.extending { border-color: #8a6a1f; background: rgba(180, 130, 30, 0.06); }
-.llm-tool-card.extending .llm-tool-icon { animation: spin 0.7s linear infinite; }
-.llm-tool-card.extending .llm-tool-status { color: #e9b94a; }
-
-.llm-tool-head {
- display: flex; align-items: center; gap: 8px;
- padding: 6px 10px; cursor: pointer;
- background: var(--s2);
- user-select: none;
-}
-.llm-tool-head:hover { background: var(--s3, #2a2a2e); }
-.llm-tool-icon { font-size: 13px; }
-.llm-tool-name { font-weight: 600; color: var(--t0); }
-.llm-tool-args {
- flex: 1; color: var(--t2); font-size: 11px;
- overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
-}
-.llm-tool-status { font-size: 11px; color: var(--t2); flex-shrink: 0; }
-.llm-tool-card.ok .llm-tool-status { color: #6bd58b; }
-.llm-tool-card.err .llm-tool-status { color: #f37070; }
-
-.llm-tool-body { display: none; padding: 8px 10px; }
-.llm-tool-card.open .llm-tool-body { display: block; }
-.llm-tool-raw {
- margin: 0; padding: 6px 8px; max-height: 240px; overflow: auto;
- font-family: var(--ffm); font-size: 11px;
- background: var(--s0); border: 1px solid var(--bd1); border-radius: 4px;
- white-space: pre-wrap; word-break: break-all;
-}
-.llm-tool-err {
- color: #f37070; font-family: var(--ffm); font-size: 12px;
- padding: 6px 8px;
-}
-.llm-tool-more {
- font-size: 11px; color: var(--t2); margin-top: 4px; text-align: right;
-}
-
-/* 툴 결과 표 */
-.llm-tool-tbl-wrap { max-height: 320px; overflow: auto; border: 1px solid var(--bd1); border-radius: 4px; }
-.llm-tool-tbl {
- width: 100%; border-collapse: collapse; font-size: 11px; font-family: var(--ffm);
-}
-.llm-tool-tbl th, .llm-tool-tbl td {
- padding: 4px 8px; border-bottom: 1px solid var(--bd1);
- text-align: left; white-space: nowrap;
-}
-.llm-tool-tbl th {
- background: var(--s2); position: sticky; top: 0;
- font-weight: 600; color: var(--t1);
-}
-
-/* 대화 요약 배너 (Phase 7.2) */
-.llm-summary-card {
- margin: 4px 0 12px 0; padding: 8px 12px;
- border: 1px dashed #4a5a7a; border-radius: 6px;
- background: rgba(80, 110, 160, 0.08);
- cursor: pointer; user-select: none;
-}
-.llm-summary-head { font-size: 12px; color: var(--t2); font-weight: 600; }
-.llm-summary-body {
- display: none; margin-top: 8px; padding-top: 6px;
- border-top: 1px solid var(--bd1);
- font-size: 12px; color: var(--t1); line-height: 1.5;
- white-space: pre-wrap; word-break: break-word;
-}
-.llm-summary-card.open .llm-summary-body { display: block; }
-
-/* 시계열 스파클라인 (툴 카드 안) */
-.llm-sparkline-box {
- margin-bottom: 8px; padding: 8px 10px;
- border: 1px solid var(--bd1); border-radius: 4px;
- background: var(--s0);
-}
-.llm-sparkline-label { font-size: 11px; color: var(--t2); margin-bottom: 4px; }
-.llm-sparkline-chart { width: 100%; min-height: 90px; }
-.llm-sparkline-chart .u-legend { display: none !important; }
-
-/* KB 검색 결과 (search_kb 카드 안) */
-.llm-kb-hits { display: flex; flex-direction: column; gap: 6px; }
-.llm-kb-hit {
- padding: 6px 8px; background: var(--s0);
- border: 1px solid var(--bd1); border-radius: 4px;
-}
-.llm-kb-head { font-size: 12px; color: var(--t0); margin-bottom: 2px; }
-.llm-kb-head .mono { color: var(--t2); margin-right: 6px; }
-.llm-kb-snip { font-size: 11px; color: var(--t1); line-height: 1.4; }
-
-/* KB 인용 링크 (본문 안) */
-.kb-cite-link {
- color: var(--a); text-decoration: none;
- border-bottom: 1px dashed var(--a);
- padding-bottom: 1px;
-}
-.kb-cite-link:hover { color: #fff; border-bottom-style: solid; }
-
-/* welcome 추천 질문 칩 */
-.llm-chip-row {
- display: flex; flex-wrap: wrap; gap: 8px;
- justify-content: center; margin-top: 16px;
- max-width: 720px;
-}
-.llm-chip {
- padding: 6px 12px;
- background: var(--s2);
- border: 1px solid var(--bd1);
- border-radius: 16px;
- color: var(--t0);
- font-family: var(--ff);
- font-size: 12px;
- cursor: pointer;
- transition: all .15s;
-}
-.llm-chip:hover {
- background: var(--s3, var(--s2));
- border-color: var(--a);
- transform: translateY(-1px);
-}
-
-@keyframes spin {
- from { transform: rotate(0deg); }
- to { transform: rotate(360deg); }
-}
-
-/* Field Instrument Inference */
-.kb-infer-card { border-left: 3px solid #4472C4; }
-.kb-infer-body { padding: 8px 0; }
-.kb-infer-desc { color: #666; font-size: 0.9em; margin: 8px 0; }
-.kb-infer-options { margin: 8px 0; }
-.kb-toggle { display: flex; align-items: center; gap: 6px; cursor: pointer; font-size: 0.9em; }
-.kb-toggle input { margin: 0; }
-.kb-infer-result { margin-top: 12px; padding: 12px; background: #f0f7ff; border-radius: 6px; }
-.kb-infer-stats { display: flex; gap: 16px; margin-bottom: 8px; }
-.kb-stat { font-weight: 600; color: #333; }
-.kb-stat::before { content: ''; display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: #4472C4; margin-right: 4px; }
diff --git a/src/Web/wwwroot/css/t2s.css b/src/Web/wwwroot/css/t2s.css
new file mode 100644
index 0000000..def8196
--- /dev/null
+++ b/src/Web/wwwroot/css/t2s.css
@@ -0,0 +1,437 @@
+/* ── Text-to-SQL Dashboard ───────────────────────────────── */
+
+/* 입력 행 */
+.t2s-input-row {
+ display: flex; gap: 8px; align-items: flex-end;
+}
+.t2s-input-row .fg { flex: 1; margin-bottom: 0; }
+
+/* 제안 칩 */
+.t2s-chips { display: flex; flex-wrap: wrap; gap: 6px; }
+.t2s-chip {
+ background: var(--s3); border: 1px solid var(--bd);
+ color: var(--t1); border-radius: 12px;
+ padding: 4px 12px; cursor: pointer; font-size: 12px;
+ transition: all var(--tr);
+}
+.t2s-chip:hover { background: var(--s4); color: var(--t0); border-color: var(--a); }
+
+/* SQL 텍스트영역 */
+.t2s-sql-area {
+ width: 100%; min-height: 80px; resize: vertical;
+ background: var(--s2); border: 1px solid var(--bd);
+ color: var(--t0); border-radius: var(--r);
+ padding: 10px 12px; font-family: var(--fm); font-size: 13px;
+ line-height: 1.5;
+}
+.t2s-sql-area:focus { outline: none; border-color: var(--a); }
+.t2s-sql-area:disabled { opacity: 0.6; }
+
+/* 태그 분석 옵션 */
+.t2s-tag-label {
+ display: block; font-size: 12px; color: var(--t1);
+ margin-bottom: 4px;
+}
+
+/* 결과 테이블 - 스크롤 및 높이 설정 */
+.t2s-result-info {
+ font-size: 13px; color: var(--t1); margin-bottom: 10px;
+ padding: 8px 0;
+}
+
+/* 조회 결과 컨테이너 - 스크롤 활성화 및 높이 증가 */
+.t2s-result-container {
+ max-height: 600px;
+ min-height: 400px;
+ overflow-y: auto;
+ overflow-x: auto;
+ border: 1px solid var(--bd);
+ border-radius: var(--r);
+}
+
+/* 조회 결과 영역 - 여러 값 표시 및 스크롤바 */
+#t2s-results {
+ max-height: 600px;
+ min-height: 400px;
+ overflow-y: auto;
+ overflow-x: auto;
+ min-height: 200px;
+}
+
+#t2s-results::-webkit-scrollbar {
+ width: 10px;
+ height: 10px;
+}
+
+#t2s-results::-webkit-scrollbar-track {
+ background: var(--s1);
+ border-radius: 5px;
+}
+
+#t2s-results::-webkit-scrollbar-thumb {
+ background: var(--bd2);
+ border-radius: 5px;
+}
+
+#t2s-results::-webkit-scrollbar-thumb:hover {
+ background: var(--t2);
+}
+
+#t2s-results::-webkit-scrollbar-corner {
+ background: var(--s1);
+}
+
+.t2s-table {
+ width: 100%; border-collapse: collapse;
+ font-size: 13px; font-family: var(--fm);
+}
+.t2s-table thead { background: var(--s3); }
+.t2s-table th {
+ padding: 8px 12px; text-align: left;
+ color: var(--t0); font-weight: 600;
+ border-bottom: 2px solid var(--bd);
+ white-space: nowrap;
+}
+.t2s-table td {
+ padding: 6px 12px;
+ border-bottom: 1px solid var(--bd);
+ color: var(--t1);
+ max-width: 300px; overflow: hidden;
+ text-overflow: ellipsis; white-space: nowrap;
+}
+.t2s-table tbody tr:hover { background: var(--s3); }
+.t2s-null { color: var(--t2); font-style: italic; }
+
+/* 로딩 / 빈 결과 / 오류 */
+.t2s-loading, .t2s-empty {
+ text-align: center; padding: 40px 20px;
+ color: var(--t1); font-size: 14px;
+}
+.t2s-loading { color: var(--a); }
+.t2s-error {
+ text-align: center; padding: 40px 20px;
+ color: var(--red); font-size: 14px;
+}
+
+/* 분석 결과 그리드 */
+.t2s-analysis-grid {
+ display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
+ gap: 12px; margin-top: 10px;
+}
+.t2s-tag-card {
+ background: var(--s2); border: 1px solid var(--bd);
+ border-radius: var(--r); padding: 14px 16px;
+}
+.t2s-tag-card h4 {
+ font-size: 14px; color: var(--t0); margin-bottom: 10px;
+ font-weight: 600;
+}
+.t2s-tag-stats { font-size: 13px; }
+.t2s-stat-row {
+ display: flex; justify-content: space-between;
+ padding: 4px 0; border-bottom: 1px solid var(--bd);
+}
+.t2s-stat-row:last-child { border-bottom: none; }
+.t2s-stat-row span:first-child { color: var(--t1); }
+.t2s-value { color: var(--t0); font-family: var(--fm); font-weight: 500; }
+.t2s-max { color: var(--red); }
+.t2s-min { color: var(--blu); }
+
+/* ── 채팅 UI 스타일 ────────────────────────────────────────── */
+
+/* 채팅 카드 */
+.t2s-chat-card {
+ margin-bottom: 18px;
+}
+
+/* 채팅 컨테이너 - 스크롤 활성화 */
+.t2s-chat-container {
+ max-height: 450px;
+ min-height: 300px;
+ overflow-y: auto;
+ overflow-x: hidden;
+ padding: 4px 0;
+}
+
+/* 채팅 메시지 */
+.t2s-chat-msg {
+ margin-bottom: 12px;
+ display: flex;
+ animation: t2s-msg-fade-in 0.3s ease;
+}
+
+@keyframes t2s-msg-fade-in {
+ from { opacity: 0; transform: translateY(10px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+
+.t2s-chat-msg.user {
+ justify-content: flex-end;
+}
+
+.t2s-chat-msg.system {
+ justify-content: flex-start;
+}
+
+/* 채팅 버블 */
+.t2s-chat-bubble {
+ max-width: 85%;
+ padding: 10px 14px;
+ border-radius: 12px;
+ font-size: 13px;
+ line-height: 1.5;
+ word-wrap: break-word;
+}
+
+/* 사용자 메시지 */
+.t2s-chat-msg.user .t2s-chat-bubble {
+ background: var(--a);
+ color: var(--t0);
+ border-bottom-right-radius: 4px;
+}
+
+/* 시스템 메시지 */
+.t2s-chat-msg.system .t2s-chat-bubble {
+ background: var(--s2);
+ color: var(--t1);
+ border: 1px solid var(--bd);
+ border-bottom-left-radius: 4px;
+}
+
+.t2s-chat-msg.system .t2s-chat-bubble strong {
+ color: var(--t0);
+}
+
+/* 채팅 입력 행 */
+.t2s-chat-input-row {
+ display: flex;
+ gap: 8px;
+ align-items: flex-end;
+ margin-top: 12px;
+ padding-top: 12px;
+ border-top: 1px solid var(--bd);
+}
+
+.t2s-chat-input {
+ flex: 1;
+}
+
+/* 채팅 SQL 표시 */
+.t2s-chat-sql {
+ background: var(--s1);
+ border: 1px solid var(--bd);
+ border-radius: 6px;
+ padding: 8px 10px;
+ font-family: var(--fm);
+ font-size: 12px;
+ color: var(--t0);
+ margin-top: 6px;
+ white-space: pre-wrap;
+ max-height: 150px;
+ overflow-y: auto;
+}
+
+/* 타이핑 인디케이터 */
+.t2s-typing {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ color: var(--t2);
+ font-style: italic;
+}
+
+.t2s-typing::after {
+ content: '';
+ width: 6px;
+ height: 6px;
+ background: var(--a);
+ border-radius: 50%;
+ animation: t2s-typing-dot 1.4s infinite both;
+}
+
+.t2s-typing::before {
+ content: '';
+ width: 6px;
+ height: 6px;
+ background: var(--a);
+ border-radius: 50%;
+ animation: t2s-typing-dot 1.4s infinite both 0.2s;
+}
+
+@keyframes t2s-typing-dot {
+ 0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); }
+ 40% { opacity: 1; transform: scale(1); }
+}
+
+/* ── API 대화 페이지 스타일 ────────────────────────────────── */
+
+/* API 채팅 컨테이너 - 스크롤 활성화 */
+.api-chat-container {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+.api-chat-messages {
+ flex: 1;
+ overflow-y: auto;
+ padding: 12px;
+}
+
+/* API 채팅 메시지 */
+.api-chat-msg {
+ margin-bottom: 12px;
+ display: flex;
+ animation: api-msg-fade-in 0.3s ease;
+}
+
+@keyframes api-msg-fade-in {
+ from { opacity: 0; transform: translateY(10px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+
+.api-chat-msg.user {
+ justify-content: flex-end;
+}
+
+.api-chat-msg.system {
+ justify-content: flex-start;
+}
+
+.api-chat-msg.assistant {
+ justify-content: flex-start;
+}
+
+/* API 채팅 버블 */
+.api-chat-bubble {
+ max-width: 85%;
+ padding: 10px 14px;
+ border-radius: 12px;
+ font-size: 13px;
+ line-height: 1.5;
+ word-wrap: break-word;
+}
+
+/* 사용자 메시지 */
+.api-chat-msg.user .api-chat-bubble {
+ background: var(--a);
+ color: var(--t0);
+ border-bottom-right-radius: 4px;
+}
+
+/* 시스템 메시지 */
+.api-chat-msg.system .api-chat-bubble {
+ background: var(--s2);
+ color: var(--t1);
+ border: 1px solid var(--bd);
+ border-bottom-left-radius: 4px;
+}
+
+.api-chat-msg.system .api-chat-bubble strong {
+ color: var(--t0);
+}
+
+/* 어시스턴트 메시지 */
+.api-chat-msg.assistant .api-chat-bubble {
+ background: var(--s2);
+ color: var(--t1);
+ border: 1px solid var(--bd);
+ border-bottom-left-radius: 4px;
+}
+
+.api-chat-msg.assistant .api-chat-bubble strong {
+ color: var(--t0);
+}
+
+/* API 채팅 입력 행 */
+.api-chat-input-row {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ padding: 12px;
+ border-top: 1px solid var(--bd);
+ background: var(--s1);
+}
+
+.api-chat-input {
+ flex: 1;
+ resize: none;
+ font-family: var(--fm);
+ font-size: 13px;
+}
+
+.api-chat-btn-row {
+ display: flex;
+ gap: 8px;
+ justify-content: flex-end;
+}
+
+/* SQL 쿼리 표시 */
+.api-chat-sql {
+ background: var(--s1);
+ border: 1px solid var(--bd);
+ border-radius: 6px;
+ padding: 8px 10px;
+ font-family: var(--fm);
+ font-size: 12px;
+ color: var(--t0);
+ margin-top: 6px;
+ white-space: pre-wrap;
+ max-height: 150px;
+ overflow-y: auto;
+}
+
+/* 응답 컨테이너 */
+.api-response-container {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+.api-response-content {
+ flex: 1;
+ overflow-y: auto;
+ padding: 12px;
+ font-family: var(--fm);
+ font-size: 13px;
+ color: var(--t1);
+}
+
+.api-response-content .placeholder {
+ color: var(--t2);
+ font-style: italic;
+}
+
+/* 테이블 스타일 */
+.api-response-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 12px;
+}
+
+.api-response-table th {
+ background: var(--s2);
+ color: var(--t0);
+ font-weight: 600;
+ padding: 8px 12px;
+ text-align: left;
+ border: 1px solid var(--bd);
+ position: sticky;
+ top: 0;
+}
+
+.api-response-table td {
+ padding: 6px 12px;
+ border: 1px solid var(--bd);
+ color: var(--t1);
+}
+
+.api-response-table tr:nth-child(even) {
+ background: var(--s1);
+}
+
+.api-response-table tr:nth-child(odd) {
+ background: var(--s2);
+}
diff --git a/src/Web/wwwroot/index.html b/src/Web/wwwroot/index.html
index 33f9d8d..3f33e8c 100644
--- a/src/Web/wwwroot/index.html
+++ b/src/Web/wwwroot/index.html
@@ -5,6 +5,14 @@
ExperionCrawler
+
+
+
+
+
+
+
+
@@ -105,1574 +113,25 @@
-
+
-
-
-
-
-
-
-
-
인증서 생성
-
- Client Hostname
-
-
-
- Subject Alt Names (쉼표 구분)
-
-
-
- PFX Password (없으면 비워 두세요)
-
-
-
🔑 인증서 생성
-
-
-
-
현재 인증서 상태
-
상태 확인
-
- 상태 확인 버튼을 눌러 주세요
-
-
-
-
-
-
-
-
-
-
-
-
-
서버 설정
-
-
- 🔌 접속 테스트
- 🌲 노드 탐색
-
-
-
-
-
단일 태그 읽기
-
-
- 읽기
-
-
-
-
-
-
LLM 설정
-
-
- 🔄 불러오기
- 💾 저장
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
수집 노드 목록 (한 줄에 하나씩)
-
-
📡 크롤링 시작
-
-
-
-
-
-
-
-
-
-
-
-
-
-
전체 노드 탐색 설정
-
-
- 최대 탐색 깊이
-
-
-
🗺 전체 노드맵 수집
-
-
- 서버 설정은 위 크롤링 설정을 그대로 사용합니다 ·
- 노드 수에 따라 수 분이 소요될 수 있습니다 ·
- 결과는 data/csv/{서버명}_*.csv 에 저장됩니다
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
CSV → DB 임포트
-
- 🔄 파일 목록 갱신
-
-
- 갱신 버튼을 눌러 주세요
-
-
- 선택된 파일
-
-
-
-
💾 DB에 저장
-
-
-
-
DB 레코드 조회
-
-
- 조회
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
필터 조건
-
-
-
-
- 이름 선택 (OR 조건, 최대 4개)
- ▼ 옵션 불러오기
-
-
- — 선택 안 함 —
- — 선택 안 함 —
- — 선택 안 함 —
- — 선택 안 함 —
-
-
-
-
🔍 조회
-
초기화
-
- 페이지당
-
- 건
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
조건으로 테이블 작성
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 🔍 미리보기
- 🔨 테이블 작성하기
- 📋 테이블 조회
-
-
-
-
-
-
-
-
-
- 취소
- ✓ 선택된 포인트 적용하기
- + 기존 데이터에 추가하기
-
-
-
-
-
-
수동 포인트 추가
-
- Node ID 직접 입력
-
-
-
+ 추가
-
-
-
실시간 구독 제어
-
-
- ▶ 구독 시작
- ■ 구독 중지
- 상태 확인
-
-
-
-
-
-
-
메타데이터 관리
-
- 태그의 desc, area, state descriptor 정보를 OPC UA에서 조회하여 저장합니다.
-
-
- 🔄 메타데이터 갱신
- 📋 메타데이터 조회
-
-
-
-
-
-
-
-
Sub-Area (세부 Area) 관리
-
- area(P6 등)를 Column 단위 sub_area(P6-1/P6-2)로 분류합니다. 공용 설비는 P6-1,P6-2처럼
- 두 코드가 함께 부여되어 어느 쪽 필터에도 잡힙니다. Seed는 번호 prefix 규칙 + pid_equipment 공용 검출을 사용합니다.
-
-
- Area:
-
- P6 (6차 플랜트)
- P9 (9차 플랜트)
- P10 (10차 플랜트)
- P1 (1차 플랜트)
- P2 (2차 플랜트)
-
- 📋 조회
- 🧪 Seed DryRun
- ⚙️ Seed 실행
-
-
-
-
-
-
-
-
-
- 포인트 목록 (0개)
- ↻ 새로 고침
-
-
-
포인트가 없습니다. 위에서 테이블을 작성하세요.
-
-
-
-
-
-
-
-
-
-
조회 조건
-
-
- 태그 선택 (최대 8개, OR 조건)
- ▼ 옵션 불러오기
- 대기 중
-
-
- — 선택 안 함 —
- — 선택 안 함 —
- — 선택 안 함 —
- — 선택 안 함 —
- — 선택 안 함 —
- — 선택 안 함 —
- — 선택 안 함 —
- — 선택 안 함 —
-
-
-
-
-
-
- 조회 간격
-
- 원시 데이터 (기본)
- 5분 집계
- 10분 집계
- 30분 집계
- 1시간 집계
- 1일 집계
-
-
-
- 최대 행 수
-
-
-
-
- 🔍 조회
- 초기화
-
-
-
-
-
-
하이퍼테이블 관리
-
- history_table이 현재 하이퍼테이블 상태입니다. 아래 옵션을 설정하여 수동으로 생성할 수 있습니다.
-
-
-
-
-
-
- 연속 집계 생성 (선택사항)
-
-
-
- 🔧 하이퍼테이블 생성
- 🔄 상태 새로고침
-
-
-
-
-
-
-
-
-
-
- 테이블명
- -
-
-
- 레코드 수
- -
-
-
- 보관 정책
- -
-
-
- 압축
- -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- ▶ 서버 시작
- ■ 서버 중지
- ↺ 주소공간 재구성
- ↻ 상태 새로고침
-
-
-
-
-
-
-
-
-
-
-
-
🗣 자연어 쿼리
-
-
- SQL 변환
- ▶ 실행
- 📊 분석
-
-
- 추천 쿼리:
- 최근 1시간 평균
- 24시간 최대값
- 7일 최소값
- 추세 분석
-
-
-
-
-
-
-
-
-
🏷 태그 분석 옵션
-
-
- 태그명 (쉼표 구분, 비우면 전체)
-
-
-
- 집계 간격
-
- 1분
- 5분
- 15분
- 1시간
- 1일
-
-
-
- 데이터 제한
-
-
-
-
-
-
-
-
-
-
📊 조회 결과
-
- 쿼리를 실행하면 여기에 결과가 표시됩니다
-
-
-
-
-
📈 태그 분석 결과
-
- 분석을 실행하면 여기에 결과가 표시됩니다
-
-
-
-
-
-
-
-
-
-
-
-
-
-
세션 목록
-
- DB 미연결
- DB 접속
- + 신규
-
-
-
-
-
-
-
-
-
세션 상세
-
- ■ 중지
- Excel
- CSV
- 고정
- 삭제
-
-
-
-
-
- 0 / 0 (0%)
- 경과: 0s
-
-
-
-
-
-
-
-
-
-
-
-
-
① PC → 서버 파일 전송
-
-
- 📤 서버로 전송
-
-
- 파일을 선택하고 서버로 전송하세요.
-
-
-
-
-
-
② 서버 파일 선택 → 추출
-
-
- -- 서버 파일 목록 로드 필요 --
-
-
-
- 🔄 목록 갱신
- 🚀 추출 시작
- 🔗 연결 분석
- 로그 지우기
- 🗑 전체 삭제
-
-
- 대기 중...
-
-
-
-
-
-
-
-
- Prefix 분류 정의 ▼
-
- 태그 prefix 기준으로 카테고리 자동 분류
-
-
-
-
-
-
-
-
-
추출 결과
-
- CSV
- Excel
-
-
-
-
-
-
- ID
- 태그번호
- 장비명
- 유형
- 라인번호
- 도면번호
- 신뢰도
- 상태
- 매핑
- 카테고리
- 삭제
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
조회 조건
-
-
- 태그 필터
- ▼ 태그 목록 불러오기
-
-
-
-
- — 전체 태그 —
-
-
-
-
-
- 이벤트 타입
-
- 전체
- TRIP
- RUN
- ALARM
- NORMAL
- CHANGE
-
-
-
- Area (예: P6)
-
-
-
- Section (예: 1-2차)
-
-
-
- 최대 행 수
-
-
-
-
-
-
-
- 🔍 이벤트 조회
- 📊 구간 요약
- 초기화
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
시스템 프롬프트
-
-
-
- 💾 설정 저장
- 🔌 연결 테스트
-
-
-
-
-
-
💬
-
새 대화를 시작하세요
-
모델을 선택하고 메시지를 입력하세요
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
🔒 관리자 로그인
-
- 비밀번호
-
-
-
- 로그인
-
-
-
-
-
-
-
-
-
-
- 세션: --:--
- 비밀번호 변경
- 로그아웃
-
-
- 📁 파일 업로드
- 🔄 새로고침
-
-
-
-
-
-
Field Instrument 자동 유추
-
-
DCS 태그 ficq-6101 → FT-6101, FIC-6101, FCV-6101 등 현장 계기 자동 유추 후 Excel 초안 생성.
-
-
-
- LLM description 보강 (Phase C에서 활성화)
-
-
-
- 초안 생성
-
-
-
-
-
-
-
-
-
-
- 컬렉션
-
- 전체
-
-
-
- 상태
-
- 전체
- pending
- parsing
- embedding
- indexed
- failed
- disabled
-
-
-
- 제목 검색
-
-
-
-
-
-
-
-
-
-
- 🚫 동일 제목 일괄 비활성화
- 🗑 비활성화 영구삭제
-
-
-
-
-
-
-
📁 KB 문서 업로드
-
- 컬렉션 *
-
- -- 선택 --
-
-
-
- 제목 (비워두면 파일명 사용)
-
-
-
- 태그 (콤마 분리, 예: unit-a, P-6201)
-
-
-
- 파일
-
-
-
-
- 업로드
- 취소
-
-
-
-
-
-
-
-
-
-
-
🔐 비밀번호 변경
-
- 현재 비밀번호
-
-
-
- 새 비밀번호 (6자 이상)
-
-
-
-
- 변경
- 취소
-
-
-
-
-
-
-
-
-
-
-
-
-
직접 쓰기 (SP / OP)
-
- Node ID
-
-
-
- 값 (숫자)
-
-
-
✏️ 쓰기
-
-
-
-
-
Mode 변경 (MD / MODE)
-
- Node ID
-
-
-
- Mode
-
- MAN (0)
- AUTO (1)
-
-
-
🔄 Mode 변경
-
-
-
-
-
통합 제어 (OP 변경 — 자동 MAN→AUTO)
-
- 태그명을 입력하면 MD/MODE를 MAN으로 전환 → OP 쓰기 → AUTO로 복귀하는 전체 시퀀스를 실행합니다.
-
-
-
⚡ 통합 제어 실행
-
-
-
-
-
태그 읽기 (확인용)
-
-
- 📖 읽기
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
@@ -1755,7 +214,22 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+