let selectedPdServiceId; // 제품(서비스) 아이디 let selectedPdServiceVersionIds = []; // 선택된 버전 아이디 let reader; let currentStreamId; let converter; let currentRoomId = null; let pendingVersionRestore = null; // 히스토리 로드 시 복원할 버전 ID 목록 let isLoadingRoom = false; // 백그라운드 스트리밍 상태 추적: roomId → { accumulatedText, updaters: [fn] } const activeStreams = {}; let currentRagRequest = null; // 현재 진행 중인 fetchRagDocs AJAX (중지 버튼으로 abort 가능) let currentFetchController = null; // 현재 진행 중인 AI stream fetch AbortController let isSendingMessage = false; // sendMessage 중복 호출 방지 let navVersion = 0; // 화면 이동(새 대화/방 전환)마다 증가 — 비동기 콜백에서 이동 여부 판단용 let lastUserQuery = null; // 마지막 사용자 질문 (체크박스 재답변용) let lastAiMessage = null; // 마지막 AI 메시지 DOM 요소 (재답변 시 교체용) let ragPanelVersion = 0; // fetchRagDocs가 패널 렌더링할 때마다 증가 — loadRagDocsForRoom 덮어쓰기 방지용 const ragFetchInProgress = {}; // roomId → true: fetchRagDocs 진행 중인 방 추적 function scrollChatToBottom(force) { var el = document.getElementById("chat_container"); if (!el) return; if (!force) { // 스트리밍 중: 사용자가 위로 올라가 있으면 건드리지 않음 var distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; if (distanceFromBottom > 100) return; } el.scrollTop = el.scrollHeight; } function execDocReady() { const pluginGroups = [ [ "../reference/lightblue4/docs/lib/widgster/widgster.js", "./js/common/showdown/showdown.js", // markdown 변환용 "../reference/jquery-plugins/highlight.js-11.10.0/highlight.js.lib/highlight.min.js", // 검색결과 코드 하이라이트 "../reference/jquery-plugins/highlight.js-11.10.0/highlight.js.lib/src/styles/arta.css", ], [ // pdServiceId select-box 용 "../reference/jquery-plugins/select2-4.0.2/dist/css/select2_lightblue4.css", "../reference/jquery-plugins/lou-multi-select-0.9.12/css/multiselect-lightblue4.css", "../reference/jquery-plugins/multiple-select-1.5.2/dist/multiple-select-bluelight.css", "../reference/jquery-plugins/select2-4.0.2/dist/js/select2.min.js", "../reference/jquery-plugins/lou-multi-select-0.9.12/js/jquery.quicksearch.js", "../reference/jquery-plugins/lou-multi-select-0.9.12/js/jquery.multi-select.js", "../reference/jquery-plugins/multiple-select-1.5.2/dist/multiple-select.min.js", ] ]; loadPluginGroupsParallelAndSequential(pluginGroups) .then(function () { hljs.highlightAll(); $(".widget").widgster(); setSideMenu("sidebar_large_menu_ai", "sidebar_medium_menu_ai_adms", "sidebar_small_menu_ai_ai_chat"); setSideMenu("toggle_large_menu_ai", "toggle_medium_menu_ai_dashboard"); // aiChat 페이지는 자체 로딩 UI를 사용하므로 전역 AJAX 로더를 비활성화 $(document).off("ajaxStart"); $(document).on("ajaxStop", function () { $(".loader").addClass("hide"); }); $(".loader").addClass("hide"); //제품(서비스) 셀렉트 박스 이니시에이터 makePdServiceSelectBox(); //버전 멀티 셀렉트 박스 이니시에이터 makeVersionMultiSelectBox(); // 카드 영역 초기 로드 loadCardContext(); // showdown.js converter 이니시에이터 initConverter(); initSendMessageEvent(); // 이력 관리 initHistoryEvent(); // 좌측 하단 대화 히스토리 로드 loadLeftPanelHistory(); }) .catch(function () { }); $("#message_stop_btn").click(function () { cancel(); }); } //////////////////////////////////////////////////////////////////////////////////////// //제품 서비스 셀렉트 박스 //////////////////////////////////////////////////////////////////////////////////////// function makePdServiceSelectBox() { //제품 서비스 셀렉트 박스 이니시에이터 $(".chzn-select").each(function() { $(this).select2($(this).data()); }); //제품 서비스 셀렉트 박스 데이터 바인딩 $.ajax({ url: "/auth-user/api/arms/pdServicePure/getPdServiceMonitor.do", type: "GET", contentType: "application/json;charset=UTF-8", dataType: "json", progress: true, statusCode: { 200: function(data) { ////////////////////////////////////////////////////////// for (var k in data.response) { var obj = data.response[k]; var newOption = new Option(obj.c_title, obj.c_id, false, false); $("#selected_pdService").append(newOption); } $("#selected_pdService").trigger("change"); ////////////////////////////////////////////////////////// jSuccess("제품(서비스) 조회가 완료 되었습니다."); } }, error: function (e) { jError("제품(서비스) 조회 중 에러가 발생했습니다."); } }); // --- select2 ( 제품(서비스) 검색 및 선택 ) 이벤트 --- // $("#selected_pdService").on("select2:select", function(e) { // loadChatRoom에서 programmatic하게 변경 중이면 사이드이펙트 차단 if (isLoadingRoom) return; selectedPdServiceId = $("#selected_pdService").val(); bind_VersionData_By_PdService(); }); // 제품(서비스) 선택 해제 시 $("#selected_pdService").on("select2:unselect select2:clear", function(e) { // loadChatRoom에서 programmatic하게 변경 중이면 사이드이펙트 차단 if (isLoadingRoom) return; selectedPdServiceId = null; selectedPdServiceVersionIds = []; $("#multiversion option").remove(); $("#multiversion").multipleSelect("refresh"); deleteLastSelection(); if (currentRoomId) { updateRoomPdService(currentRoomId, null, []); } }); } // end makePdServiceSelectBox() //////////////////////////////////////////////////////////////////////////////////////// //버전 멀티 셀렉트 박스 //////////////////////////////////////////////////////////////////////////////////////// function makeVersionMultiSelectBox() { //버전 선택시 셀렉트 박스 이니시에이터 $("#multiversion").multipleSelect({ filter: true, onClose: function () { let versionIds = []; $("#multiversion option:selected").map(function (a, item) { versionIds.push(item.value); }); selectedPdServiceVersionIds = versionIds; saveLastSelection(); if (currentRoomId) { updateRoomPdService(currentRoomId, selectedPdServiceId, selectedPdServiceVersionIds); } }, onCheckAll: function () { let versionIds = []; $("#multiversion option").map(function (a, item) { versionIds.push(item.value); }); selectedPdServiceVersionIds = versionIds; saveLastSelection(); if (currentRoomId) { updateRoomPdService(currentRoomId, selectedPdServiceId, selectedPdServiceVersionIds); } }, onUncheckAll: function () { selectedPdServiceVersionIds = []; saveLastSelection(); if (currentRoomId) { updateRoomPdService(currentRoomId, selectedPdServiceId, selectedPdServiceVersionIds); } } }); } function bind_VersionData_By_PdService() { var requestedId = selectedPdServiceId; $("#multiversion option").remove(); selectedPdServiceVersionIds = []; $.ajax({ url: "/auth-user/api/arms/pdService/getVersionList?c_id=" + requestedId, type: "GET", dataType: "json", progress: true, statusCode: { 200: function (data) { if (selectedPdServiceId !== requestedId) return; ////////////////////////////////////////////////////////// for (var k in data.response) { var obj = data.response[k]; selectedPdServiceVersionIds.push(obj.c_id); var newOption = new Option(obj.c_title, obj.c_id, true, true); $("#multiversion").append(newOption); } $("#multiversion").multipleSelect("refresh"); // 히스토리 로드 시 저장된 버전만 선택 복원 if (pendingVersionRestore !== null) { $("#multiversion option").prop("selected", false); pendingVersionRestore.forEach(function(vId) { $("#multiversion option[value='" + vId + "']").prop("selected", true); }); selectedPdServiceVersionIds = pendingVersionRestore.slice(); pendingVersionRestore = null; $("#multiversion").multipleSelect("refresh"); } // 버전 목록이 모두 채워진 후 저장 (product + 선택된 versions 모두 포함) saveLastSelection(); if (currentRoomId && !isLoadingRoom) { updateRoomPdService(currentRoomId, selectedPdServiceId, selectedPdServiceVersionIds); } ////////////////////////////////////////////////////////// } }, error: function (e) { jError("버전 조회 중 에러가 발생했습니다."); } }); } function saveLastSelection() { if (!selectedPdServiceId) { return; } $.ajax({ url: "/auth-user/api/aichat/last-selection", type: "POST", contentType: "application/json", data: JSON.stringify({ pdServiceId: selectedPdServiceId, pdServiceVersionIds: selectedPdServiceVersionIds.length > 0 ? selectedPdServiceVersionIds : [] }) }); } function deleteLastSelection() { $.ajax({ url: "/auth-user/api/aichat/last-selection", type: "DELETE" }); } function updateRoomPdService(roomId, pdServiceId, pdServiceVersionIds) { $.ajax({ url: "/auth-user/api/aichat/rooms/" + roomId, type: "PATCH", contentType: "application/json", data: JSON.stringify({ pdServiceId: pdServiceId || null, pdServiceVersionIds: pdServiceVersionIds && pdServiceVersionIds.length > 0 ? pdServiceVersionIds : [] }) }); } function cancel() { // fetchRagDocs 진행 중이면 abort if (currentRagRequest) { currentRagRequest.abort(); currentRagRequest = null; } // AI stream fetch 응답 대기 중이면 abort (reader 설정 전 구간 포함) if (currentFetchController) { currentFetchController.abort(); currentFetchController = null; } // 스트리밍 중이면 중지 (reader 설정 후 구간) if (reader && currentStreamId) { fetch(`/ai-api/chat/stop-stream?sessionId=${currentStreamId}`) .catch(function () {}); reader.cancel(); currentStreamId = null; } toggleButtonState(true); isSendingMessage = false; } function initConverter() { converter = new showdown.Converter({ tables: true, // 테이블 strikethrough: true, // 취소선 tasklists: true, // 체크박스 ghCodeBlocks: true, // 깃헙 스타일 코드 블록 emoji: true, // 이모지 openLinksInNewWindow: true, }); } function initSendMessageEvent() { // 전송 버튼 클릭 시 $("#message_send_btn").on("click", function () { sendMessage(); }); // 새대화 버튼 클릭 시 $("#new_chat_btn").on("click", function () { startNewChat(); }); } function initHistoryEvent() { // 엔터 키 눌렀을 때도 전송 $("#new_message").on("keyup", function (e) { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); // 줄바꿈 방지 sendMessage(); } }); } //////////////////////////////////////////////////////////////////////////////////////// // Helper functions for refactored code //////////////////////////////////////////////////////////////////////////////////////// // 버튼 상태 토글 (true: 전송 버튼 표시, false: 정지 버튼 표시) function toggleButtonState(showSend) { const sendBtn = $("#message_send_btn"); const stopBtn = $("#message_stop_btn"); if (showSend) { sendBtn.show(); stopBtn.hide(); $(".rag-doc-checkbox").prop("disabled", false); } else { sendBtn.hide(); stopBtn.show(); $(".rag-doc-checkbox").prop("disabled", true); } } // 채팅 UI 준비 function prepareChatUI() { hideGreetingMessage(); hideCardContainer(); $("#history_readonly_banner").hide(); $("#chat_input_area").addClass("input-pinned"); changeFlexCenterToSpaceBetween(); showChatContent(); } // AI 메시지 컨테이너 생성 function createAiMessageContainer() { const aiMessage = $(`
loading...
`); $("#chat").append(aiMessage); scrollChatToBottom(); return aiMessage; } // 스트림 응답 처리 공통 함수 // onChunk(accumulatedText): 청크마다 호출 (activeStreams 업데이트용) // isBackground: true면 현재 화면 UI 건드리지 않음 (toggleButtonState, 에러메시지 등 스킵) function handleStreamResponse(url, params, aiMessage, onComplete = null, onChunk = null, isBackground = false, onError = null) { // background가 아닌 경우에만 currentFetchController 설정 (stop 버튼 대상) const controller = new AbortController(); if (!isBackground) { currentFetchController = controller; } var streamStart = performance.now(); var firstChunkLogged = false; fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: params, signal: controller.signal }).then((res) => { if (!isBackground) currentFetchController = null; aiMessage.find(".loading-gif").remove(); aiMessage.addClass("streaming"); let accumulatedText = ""; let sseBuffer = ""; if (res.status !== 200) { if (!isBackground) { aiMessage.find(".text").text("지금은 답변을 드릴 수가 없습니다."); toggleButtonState(true); } if (onError) onError(); return; } // localReader: 이 스트림 전용 reader (전역 reader 교체로 인한 체인 오염 방지) const localReader = res.body.pipeThrough(new TextDecoderStream()).getReader(); if (!isBackground) { reader = localReader; } function processStream({ done, value }) { if (done) { aiMessage.removeClass("streaming"); const html = converter.makeHtml(accumulatedText); aiMessage.find(".text").html(html); if (!isBackground) { toggleButtonState(true); currentStreamId = null; } if (onComplete) { onComplete(accumulatedText); } return; } if (!isBackground && !firstChunkLogged && value) { firstChunkLogged = true; } // SSE 파싱: 청크가 이벤트 경계에 걸칠 수 있으므로 버퍼로 완전한 이벤트 단위 처리 sseBuffer += value; var events = sseBuffer.split("\n\n"); sseBuffer = events.pop(); // 마지막 미완성 이벤트는 버퍼에 유지 for (var i = 0; i < events.length; i++) { var lines = events[i].split("\n"); var dataLines = []; var hasData = false; for (var j = 0; j < lines.length; j++) { if (lines[j].startsWith("data:")) { hasData = true; // SSE 스펙: "data:" 뒤 첫 번째 공백 한 개만 제거 var content = lines[j].slice(5); dataLines.push(content); } } if (hasData) { // 동일 이벤트 내 다중 data 라인은 \n으로 결합 (줄바꿈 토큰 처리) accumulatedText += dataLines.join("\n"); } } renderStreamingText(aiMessage.find(".text"), accumulatedText); if (onChunk) { onChunk(accumulatedText); } if (!isBackground) scrollChatToBottom(); localReader.read().then(processStream); } localReader.read().then(processStream); }).catch(function (err) { if (!isBackground) currentFetchController = null; aiMessage.find(".loading-gif").remove(); if (!isBackground) { if (err && err.name !== "AbortError") { aiMessage.find(".text").text("네트워크 오류가 발생했습니다."); } toggleButtonState(true); } if (onError) onError(); }); } // 줄을 코드블록/테이블/리스트항목 단위로 묶어 변환 function convertLinesBlockAware(lines) { const blocks = []; let i = 0; while (i < lines.length) { const trimmed = lines[i].trim(); // 코드블록 if (trimmed.startsWith("```")) { const codeLines = [lines[i]]; i++; while (i < lines.length) { codeLines.push(lines[i]); if (lines[i].trim().startsWith("```")) { i++; break; } i++; } blocks.push(converter.makeHtml(codeLines.join("\n"))); // 표 } else if (trimmed.startsWith("|")) { const tableLines = []; while (i < lines.length && lines[i].trim().startsWith("|")) { tableLines.push(lines[i]); i++; } if (tableLines.length >= 2 && /^\|[\s\-:|]+\|/.test(tableLines[1])) { blocks.push(converter.makeHtml(tableLines.join("\n"))); } else { tableLines.forEach(l => blocks.push(converter.makeHtml(l))); } // 리스트 항목 } else if (/^[-*+] /.test(trimmed) || /^\d+\. /.test(trimmed)) { const listLines = []; while (i < lines.length) { const t = lines[i].trim(); // 리스트 항목이거나 들여쓰기 된 줄 (중첩 리스트 continuation) if (/^[-*+] /.test(t) || /^\d+\. /.test(t) || /^\s{2,}/.test(lines[i])) { listLines.push(lines[i]); i++; } else { break; } } blocks.push(converter.makeHtml(listLines.join("\n"))); // 일반 } else { blocks.push(converter.makeHtml(lines[i])); i++; } } return blocks.join(""); } // 스트리밍 텍스트를 엘리먼트에 렌더링 (줄바꿈 기준 블록 변환) function renderStreamingText($el, text) { var lines = text.split("\n"); if (lines.length > 1) { $el.html(convertLinesBlockAware(lines.slice(0, -1)) + lines[lines.length - 1]); } else { $el.text(text); } } //////////////////////////////////////////////////////////////////////////////////////// // 사용자 메시지 처리 함수 따로 빼기 function sendMessage() { const messageText = $("#new_message").val().trim(); if (messageText === "") return; if (isSendingMessage) { return; } if (currentRoomId !== null && activeStreams[currentRoomId]) { return; } isSendingMessage = true; lastUserQuery = messageText; prepareChatUI(); // 기존 대화방이 없을 때(새 대화)만 채팅 비우기 if (!currentRoomId) { $("#chat").empty(); } // 사용자 메시지 UI 추가 const userMessage = $(`
${$("
").text(messageText).html()}
`); $("#chat").append(userMessage); // 입력창 초기화 + 스크롤 하단 이동 $("#new_message").val(""); scrollChatToBottom(true); if (!currentRoomId) { // 일반 모드: 방 생성 후 저장 + 전송 var versionIds = selectedPdServiceVersionIds.length > 0 ? selectedPdServiceVersionIds : null; var capturedNav = navVersion; // 이 sendMessage 시점의 navVersion 캡처 $.ajax({ url: "/auth-user/api/aichat/rooms", type: "POST", contentType: "application/json;charset=UTF-8", dataType: "json", data: JSON.stringify({ title: messageText, pdServiceId: selectedPdServiceId || null, pdServiceVersionIds: versionIds }) }).done(function (room) { saveMessageToRedis(room.roomId, "user", messageText, function() { loadLeftPanelHistory(); }); if (navVersion !== capturedNav) { // 방 생성 완료 전에 사용자가 다른 화면으로 이동 // fetchRagDocs는 silent 모드로 실행(UI 변경 없이 Redis 저장), 이후 background sendChat isSendingMessage = false; fetchRagDocs(messageText, room.roomId).done(function() { sendChat(messageText, room.roomId); }); return; } currentRoomId = room.roomId; fetchRagDocs(messageText).done(function () { isSendingMessage = false; if (navVersion !== capturedNav) { // fetchRagDocs 완료 전에 이동 → 백그라운드 스트리밍 toggleButtonState(true); sendChat(messageText, room.roomId); return; } sendChat(messageText); }); }).fail(function (err) { isSendingMessage = false; toggleButtonState(true); }); } else { var roomIdAtSend = currentRoomId; var capturedNavAtSend = navVersion; saveMessageToRedis(currentRoomId, "user", messageText); fetchRagDocs(messageText).done(function () { isSendingMessage = false; if (navVersion !== capturedNavAtSend) { // fetchRagDocs 완료 전에 이동 → 백그라운드 스트리밍 toggleButtonState(true); sendChat(messageText, roomIdAtSend); return; } sendChat(messageText); }); } } // roomId: 명시적으로 전달하면 해당 방 기준으로 실행 (백그라운드 모드) // roomId 미전달 시 currentRoomId 사용 (포어그라운드 모드) function sendChat(userInput, roomId) { const savedRoomId = roomId !== undefined ? roomId : currentRoomId; const isBackground = savedRoomId !== currentRoomId; // 포어그라운드일 때만 중지 버튼 표시 if (!isBackground) { toggleButtonState(false); } // 백그라운드 모드: 현재 DOM에 붙이지 않는 detached 컨테이너 사용 // 사용자가 나중에 해당 방으로 돌아오면 loadChatRoom이 activeStreams에 연결 var aiMessage; if (isBackground) { aiMessage = $('
'); } else { aiMessage = createAiMessageContainer(); lastAiMessage = aiMessage; } const streamId = generateStreamId(); if (!isBackground) { currentStreamId = streamId; } const params = JSON.stringify({ queryText: userInput, sessionId: streamId, roomId: savedRoomId, pdServiceId: selectedPdServiceId ? parseInt(selectedPdServiceId) : null, pdServiceVersionIds: selectedPdServiceVersionIds.length > 0 ? selectedPdServiceVersionIds.map(function(id) { return parseInt(id); }) : null }); // 백그라운드 스트리밍 상태 등록 (방이 있는 경우) if (savedRoomId) { activeStreams[savedRoomId] = { accumulatedText: "", updaters: [], doneUpdaters: [] }; } handleStreamResponse("/ai-api/chat/execute-stream", params, aiMessage, (accumulatedText) => { // 스트림 완료: doneUpdater 호출 후 정리 if (savedRoomId && activeStreams[savedRoomId]) { activeStreams[savedRoomId].doneUpdaters.forEach(function(fn) { fn(accumulatedText); }); delete activeStreams[savedRoomId]; } // AI 응답 저장 if (accumulatedText && savedRoomId) { saveMessageToRedis(savedRoomId, "assistant", accumulatedText, function() { // ZADD 완료 후 history 갱신 (race condition 방지) loadLeftPanelHistory(); }); } // 스트림 완료 시점에 해당 방을 보고 있으면 버튼 복원 if (savedRoomId === currentRoomId) { toggleButtonState(true); } trimChatMessages(); }, (accumulatedText) => { // 청크마다: activeStreams 누적 텍스트 업데이트 및 등록된 updater 호출 if (savedRoomId && activeStreams[savedRoomId]) { activeStreams[savedRoomId].accumulatedText = accumulatedText; activeStreams[savedRoomId].updaters.forEach(function(fn) { fn(accumulatedText); }); } }, isBackground, function() { // 백그라운드 스트림 에러 시 activeStreams 정리 (미정리 시 해당 방 영구 블로킹) if (savedRoomId && activeStreams[savedRoomId]) { delete activeStreams[savedRoomId]; } }); } var MAX_CHAT_MESSAGES = 20; // 백엔드 MAX_MESSAGES와 동일 function trimChatMessages() { var messages = $("#chat").children(".chat-message"); var excess = messages.length - MAX_CHAT_MESSAGES; if (excess > 0) { messages.slice(0, excess).remove(); } } function saveMessageToRedis(roomId, role, content, onSaved) { $.ajax({ url: "/auth-user/api/aichat/rooms/" + roomId + "/messages", type: "POST", contentType: "application/json;charset=UTF-8", dataType: "json", data: JSON.stringify({ role: role, content: content }) }).done(function (res) { if (typeof onSaved === "function") onSaved(); }).fail(function (err) { }); } function loadLeftPanelHistory() { $.ajax({ url: "/auth-user/api/aichat/rooms", type: "GET", contentType: "application/json;charset=UTF-8", dataType: "json" }).done(function (data) { var list = $("#chat_history_list"); list.empty(); if (!data || data.length === 0) { list.append('
대화 히스토리가 없습니다.
'); return; } var items = data.slice(0, 10); for (var i = 0; i < items.length; i++) { var room = items[i]; var activeClass = (room.roomId === currentRoomId) ? " active" : ""; var item = $('
' + '' + $("
").text(room.title).html() + '' + '' + '
'); item.data("roomId", room.roomId); item.data("room", room); item.find(".question-text").on("click", function () { var roomData = $(this).parent().data("room"); loadChatRoom(roomData.roomId, roomData.pdServiceId, roomData.pdServiceVersionIds); $(".recommend-question-item").removeClass("active"); $(this).parent().addClass("active"); }); item.find(".question-delete-btn").on("click", function (e) { e.stopPropagation(); var roomId = $(this).parent().data("roomId"); deleteChatRoom(roomId); }); list.append(item); } }).fail(function (err) { }); } function loadChatRoom(roomId, pdServiceId, pdServiceVersionIds) { navVersion++; isSendingMessage = false; toggleButtonState(true); hideRagPanel(); lastUserQuery = null; lastAiMessage = null; currentRoomId = roomId; // UI 전환: 채팅 모드로 (히스토리 조회 시 입력창 → 안내 배너) prepareChatUI(); $("#chat_input_area").hide(); $("#history_readonly_banner").show(); // 입력창 초기화 $("#new_message").val(""); // 기존 메시지 비우기 $("#chat").empty(); var requestedRoomId = roomId; $.ajax({ url: "/auth-user/api/aichat/rooms/" + roomId + "/messages", type: "GET", contentType: "application/json;charset=UTF-8", dataType: "json" }).done(function (messages) { if (currentRoomId !== requestedRoomId) { return; } for (var i = 0; i < messages.length; i++) { var msg = messages[i]; if (msg.role === "user") { var userHtml = $('
' + '
' + '' + $("
").text(msg.content).html() + '' + '
'); $("#chat").append(userHtml); lastUserQuery = msg.content; } else { var aiHtml = $('
' + '
' + '
' + converter.makeHtml(msg.content) + '
' + '
'); $("#chat").append(aiHtml); lastAiMessage = aiHtml; } } if (messages.length > 0 && messages[messages.length - 1].role === "user") { lastAiMessage = null; } // 백그라운드에서 스트리밍 중인 경우: 현재 누적 텍스트 표시 후 실시간 연결 if (activeStreams[requestedRoomId]) { var stream = activeStreams[requestedRoomId]; var liveMessage = $('
' + '
' + '
' + 'loading...' + '
' + '
'); $("#chat").append(liveMessage); var liveText = liveMessage.find(".text"); // 현재 누적된 텍스트 즉시 표시 if (stream.accumulatedText) { liveText.find(".loading-gif").remove(); renderStreamingText(liveText, stream.accumulatedText); } // 이후 청크 실시간 반영 updater 등록 var updater = function(accumulatedText) { if (currentRoomId !== requestedRoomId) { // 다른 방으로 이동했으면 updater 제거 var idx = stream.updaters.indexOf(updater); if (idx !== -1) { stream.updaters.splice(idx, 1); } return; } renderStreamingText(liveText, accumulatedText); scrollChatToBottom(); }; stream.updaters.push(updater); // 스트림 완료 시 최종 markdown 렌더링 stream.doneUpdaters.push(function(finalText) { if (currentRoomId !== requestedRoomId) { return; } liveText.html(converter.makeHtml(finalText)); liveText.removeClass("streaming-live"); toggleButtonState(true); scrollChatToBottom(); }); toggleButtonState(false); } // Redis에 저장된 마지막 RAG 문서 복원 — 로딩 상태 먼저 표시 showRagPanelLoading(); loadRagDocsForRoom(requestedRoomId); // 브라우저 렌더링 완료 후 스크롤 최하단 이동 (강제) requestAnimationFrame(function() { requestAnimationFrame(function() { scrollChatToBottom(true); }); }); // pdService 컨텍스트 복원 if (pdServiceId) { selectedPdServiceId = pdServiceId; // 복원할 버전 목록 지정 (bind_VersionData_By_PdService 완료 후 적용) if (pdServiceVersionIds && pdServiceVersionIds.length > 0) { pendingVersionRestore = pdServiceVersionIds.slice(); } isLoadingRoom = true; $("#selected_pdService").val(pdServiceId).trigger("change"); isLoadingRoom = false; bind_VersionData_By_PdService(); } else { pendingVersionRestore = null; selectedPdServiceId = null; selectedPdServiceVersionIds = []; isLoadingRoom = true; $("#selected_pdService").val(null).trigger("change"); isLoadingRoom = false; $("#multiversion option").remove(); $("#multiversion").multipleSelect("refresh"); } }).fail(function (err) { }); } function deleteChatRoom(roomId) { $.ajax({ url: "/auth-user/api/aichat/rooms/" + roomId, type: "DELETE" }).done(function () { // 현재 보고 있던 방이면 초기화 if (currentRoomId === roomId) { startNewChat(); } // 목록 새로고침 loadLeftPanelHistory(); }).fail(function (err) { }); } function startNewChat() { navVersion++; isSendingMessage = false; toggleButtonState(true); hideRagPanel(); currentRoomId = null; lastUserQuery = null; lastAiMessage = null; $("#new_message").val(""); $("#chat").empty(); $("#chat_container").hide(); $("#history_readonly_banner").hide(); $("#chat_input_area").removeClass("input-pinned").show(); $("#main_content").removeClass("flex-space-between").addClass("flex-center"); // 제품/버전 선택 초기화 selectedPdServiceId = null; selectedPdServiceVersionIds = []; isLoadingRoom = true; $("#selected_pdService").val(null).trigger("change"); isLoadingRoom = false; $("#multiversion").multipleSelect("uncheckAll"); $("#multiversion option").remove(); $("#multiversion").multipleSelect("refresh"); deleteLastSelection(); // 검색 모드 해제 $("#greeting_message").show(); loadCardContext(); } function generateStreamId() { return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) { var r = (Math.random() * 16) | 0, v = c == "x" ? r : (r & 0x3) | 0x8; return v.toString(16); }); } function hideCardContainer() { $("#card_section_header").hide(); $("#card_container").hide(); } function showCardContainer() { $("#card_section_header").css('display', 'flex'); $("#card_container").css('display', 'flex'); } function hideGreetingMessage() { $("#greeting_message").hide(); } function showChatContent() { $("#chat_container").show(); } function changeFlexCenterToSpaceBetween() { $("#main_content") .removeClass('flex-center') .addClass('flex-space-between'); } // 카드 영역 로드 (입력창 하단, 고정 추천질의 - Redis 관리) function loadCardContext() { // 활성 채팅 중이거나 스트리밍 중이거나 메시지 전송 중에는 카드 미표시 if (currentRoomId !== null || Object.keys(activeStreams).length > 0 || isSendingMessage) { return; } $.ajax({ url: "/auth-user/api/aichat/cards/context", type: "GET", contentType: "application/json;charset=UTF-8", dataType: "json" }).done(function (data) { if (!data || data.length === 0) { hideCardContainer(); return; } var container = $("#card_container"); container.empty(); data.forEach(function(item) { var escapedQuestion = $("
").text(item.question).html(); var html = '
' + '
' + '

' + escapedQuestion + '

' + '
' + '
'; container.append(html); }); container.off("click", ".card").on("click", ".card", function () { var cardQuestion = $(this).data("card-question").trim(); $("#new_message").val(cardQuestion); sendMessage(); }); showCardContainer(); }).fail(function () { hideCardContainer(); }); } //////////////////////////////////////////////////////////////////////////////////////// // RAG 참조 문서 패널 //////////////////////////////////////////////////////////////////////////////////////// // backgroundRoomId가 있으면 background 모드: UI 변경 없이 Redis 저장만 수행 function fetchRagDocs(query, backgroundRoomId) { var capturedRoomId = backgroundRoomId !== undefined ? backgroundRoomId : currentRoomId; var isSilent = backgroundRoomId !== undefined; var t0 = performance.now(); if (!isSilent) { showRagPanelLoading(); toggleButtonState(false); // 문서 로드 시작 시점부터 중지 버튼 } var deferred = $.Deferred(); // 방별 fetch 진행 상태 등록 if (capturedRoomId) ragFetchInProgress[capturedRoomId] = true; // .then() 결과는 Promise이므로 .abort()가 없어 직접 저장하면 중지 불가 currentRagRequest = $.ajax({ url: "/ai-api/chat/context", type: "POST", contentType: "application/json", data: JSON.stringify({ queryText: query }), dataType: "json" }); currentRagRequest.then(function (result) { currentRagRequest = null; var ragDocs = result.ragResults || []; var wikiDocs = []; var keywords = result.keywords || []; var keyword = keywords.length > 0 ? keywords.join(", ") : ""; var searchDocuments = result.searchDocuments || []; for (var i = 0; i < searchDocuments.length; i++) { var doc = searchDocuments[i]; wikiDocs.push({ text: stripHtml(doc.content || ""), metadata: { source: "Wiki · " + (doc.title || keyword) } }); } // 최초 질문: wiki는 unchecked 상태로 저장/렌더링 (재답변 시에만 사용자 선택 반영) if (capturedRoomId) { saveRagDocsToRoom(capturedRoomId, wikiDocs, ragDocs, keyword, [], null); } // 방별 fetch 진행 상태 해제 if (capturedRoomId) delete ragFetchInProgress[capturedRoomId]; if (currentRoomId === capturedRoomId) { ragPanelVersion++; renderRagPanel(wikiDocs, ragDocs, keyword, [], null); } deferred.resolve(); }, function (xhr, status) { currentRagRequest = null; if (capturedRoomId) delete ragFetchInProgress[capturedRoomId]; // 사용자가 중지 버튼으로 abort한 경우 → sendChat도 실행 안 함 if (status === "abort") { if (!isSilent && currentRoomId === capturedRoomId) renderRagPanel([], [], ""); deferred.reject("abort"); return; } if (isSilent || currentRoomId !== capturedRoomId) { deferred.resolve(); return; } renderRagPanel([], [], ""); deferred.resolve(); }); return deferred.promise(); } function loadRagDocsForRoom(roomId) { var capturedPanelVersion = ragPanelVersion; $.ajax({ url: "/auth-user/api/aichat/rooms/" + roomId + "/rag-docs", type: "GET", dataType: "json" }).done(function (result) { if (currentRoomId !== roomId) { hideRagPanel(); return; } var wikiDocs = result && result.wikiDocs ? result.wikiDocs : []; var ragDocs = result && result.ragDocs ? result.ragDocs : []; if (wikiDocs.length === 0 && ragDocs.length === 0) { if (ragFetchInProgress[roomId]) return; if (ragPanelVersion !== capturedPanelVersion) return; } renderRagPanel( wikiDocs, ragDocs, result && result.keyword ? result.keyword : "", result && result.checkedWikiIndexList ? result.checkedWikiIndexList : null, result && result.checkedRagIndexList ? result.checkedRagIndexList : null ); $(".rag-doc-checkbox").prop("disabled", true); }); } function saveRagDocsToRoom(roomId, wikiDocs, ragDocs, keyword, selectedWikiIndices, selectedRagIndices) { $.ajax({ url: "/auth-user/api/aichat/rooms/" + roomId + "/rag-docs", type: "POST", contentType: "application/json", data: JSON.stringify({ wikiDocs: wikiDocs, ragDocs: ragDocs, keyword: keyword || "", checkedWikiIndexList: selectedWikiIndices !== undefined ? selectedWikiIndices : null, checkedRagIndexList: selectedRagIndices !== undefined ? selectedRagIndices : null }) }); } function showRagPanelLoading() { var loadingHtml = '
검색 중...
'; $("#rag_wiki_section .rag-section-label").html(' Wiki 문서'); $("#rag_wiki_list").empty().append(loadingHtml); $("#rag_wiki_section").show(); $("#rag_doc_list").empty().append(loadingHtml); $("#rag_vector_section").show(); $("#rag_side").addClass("visible"); $("#chat_container").removeClass("col-lg-12").addClass("col-lg-8"); } function renderDocItems(list, docs, docType, selectedIndices) { $.each(docs, function (i, doc) { var content = doc.text || doc.content || ""; var metadata = doc.metadata || {}; var source = metadata.source || metadata.file_name || metadata.fileName || ""; var score = doc.score ? Math.round(doc.score * 100) + "%" : ""; var previewText = content.length > 150 ? content.substring(0, 150) + "..." : content; var checkboxId = "rag-doc-" + docType + "-" + i; var isChecked = selectedIndices ? selectedIndices.indexOf(i) !== -1 : true; var $item = $('
'); var $label = $(''); var $checkbox = $(''); var $content = $('
'); if (source) { $content.append('
' + $("
").text(source).html() + '
'); } $content.append('
' + $("
").text(previewText).html() + '
'); if (score) { $content.append('
유사도 ' + score + '
'); } $label.append($checkbox).append($content); $item.append($label); $item.data("doc", doc); list.append($item); }); } function renderRagPanel(wikiDocs, ragDocs, keyword, selectedWikiIndices, selectedRagIndices) { // Wiki 섹션 (상단) — 항상 표시, keyword 우측 노출 var keywordHtml = keyword ? 'keyword: ' + $("").text(keyword).html() + '' : ''; $("#rag_wiki_section .rag-section-label").html( ' Wiki 문서' + keywordHtml ); var wikiList = $("#rag_wiki_list"); wikiList.empty(); if (wikiDocs && wikiDocs.length > 0) { renderDocItems(wikiList, wikiDocs, "wiki", selectedWikiIndices); } else { wikiList.append('
Wiki 문서가 없습니다
'); } $("#rag_wiki_section").show(); // RAG 섹션 (하단) var ragList = $("#rag_doc_list"); ragList.empty(); if (!ragDocs || ragDocs.length === 0) { ragList.append('
RAG 문서가 없습니다
'); } else { renderDocItems(ragList, ragDocs, "rag", selectedRagIndices); } $("#rag_vector_section").show(); // 패널 표시 $("#rag_side").addClass("visible"); $("#chat_container").removeClass("col-lg-12").addClass("col-lg-8"); // 체크박스 변경 시 선택된 문서 기반으로 재답변 $("#rag_wiki_list, #rag_doc_list") .off("change", ".rag-doc-checkbox") .on("change", ".rag-doc-checkbox", function() { reSendWithSelectedDocs(); }); } function getSelectedDocs() { var wikiDocs = []; var ragDocs = []; $("#rag_wiki_list .rag-doc-item").each(function() { if ($(this).find(".rag-doc-checkbox").prop("checked")) { wikiDocs.push($(this).data("doc")); } }); $("#rag_doc_list .rag-doc-item").each(function() { if ($(this).find(".rag-doc-checkbox").prop("checked")) { ragDocs.push($(this).data("doc")); } }); return { wikiDocs: wikiDocs, ragDocs: ragDocs }; } function stripHtml(html) { var tmp = document.createElement("div"); tmp.innerHTML = html; return (tmp.textContent || tmp.innerText || "").trim(); } function buildEnrichedMessage(query, wikiDocs, ragDocs) { var sb = query; if (wikiDocs && wikiDocs.length > 0) { sb += "\n\n[Wiki 문서]\n"; wikiDocs.forEach(function(doc) { var text = doc.text || doc.content || ""; if (text) sb += text.trim() + "\n"; }); } if (ragDocs && ragDocs.length > 0) { sb += "\n\n[RAG 문서]\n"; ragDocs.forEach(function(doc) { var text = doc.text || doc.content || ""; if (text) sb += text.trim() + "\n"; }); } return sb; } function getSelectedIndices() { var wikiIndices = []; var ragIndices = []; $("#rag_wiki_list .rag-doc-item").each(function(i) { if ($(this).find(".rag-doc-checkbox").prop("checked")) { wikiIndices.push(i); } }); $("#rag_doc_list .rag-doc-item").each(function(i) { if ($(this).find(".rag-doc-checkbox").prop("checked")) { ragIndices.push(i); } }); return { wikiIndices: wikiIndices, ragIndices: ragIndices }; } function reSendWithSelectedDocs() { if (!lastUserQuery || !currentRoomId) return; if (isSendingMessage) return; isSendingMessage = true; toggleButtonState(false); // 마지막 AI 메시지를 제자리에서 로딩 상태로 교체 // (remove+append 방식은 위치가 밀릴 수 있어 in-place 교체로 처리) if (lastAiMessage) { lastAiMessage.find(".text").html('loading...'); } else { lastAiMessage = createAiMessageContainer(); } var selected = getSelectedDocs(); var indices = getSelectedIndices(); var capturedRoomId = currentRoomId; var streamId = generateStreamId(); currentStreamId = streamId; var params = JSON.stringify({ queryText: buildEnrichedMessage(lastUserQuery, selected.wikiDocs, selected.ragDocs), sessionId: streamId, roomId: capturedRoomId, pdServiceId: selectedPdServiceId ? parseInt(selectedPdServiceId) : null, pdServiceVersionIds: selectedPdServiceVersionIds.length > 0 ? selectedPdServiceVersionIds.map(function(id) { return parseInt(id); }) : null }); handleStreamResponse("/ai-api/chat/execute-stream", params, lastAiMessage, function(accumulatedText) { isSendingMessage = false; toggleButtonState(true); trimChatMessages(); // 체크박스 상태와 함께 rag-docs 덮어쓰기 저장 var allWikiDocs = []; var allRagDocs = []; var savedKeyword = ""; $("#rag_wiki_list .rag-doc-item").each(function() { allWikiDocs.push($(this).data("doc")); }); $("#rag_doc_list .rag-doc-item").each(function() { allRagDocs.push($(this).data("doc")); }); var $keyword = $(".rag-wiki-keyword"); if ($keyword.length) { savedKeyword = $keyword.text().replace("keyword: ", "").trim(); } saveRagDocsToRoom(capturedRoomId, allWikiDocs, allRagDocs, savedKeyword, indices.wikiIndices, indices.ragIndices); // 재답변 AI 메시지 Redis 덮어쓰기 (append 아닌 replace) if (accumulatedText) { $.ajax({ url: "/auth-user/api/aichat/rooms/" + capturedRoomId + "/messages/last-assistant", type: "PUT", contentType: "application/json;charset=UTF-8", data: JSON.stringify({ role: "assistant", content: accumulatedText }) }).done(function() { loadLeftPanelHistory(); }); } }, null, false, function() { // 에러 시 isSendingMessage 복원 isSendingMessage = false; toggleButtonState(true); }); } function hideRagPanel() { $("#rag_side").removeClass("visible"); $("#chat_container").removeClass("col-lg-8").addClass("col-lg-12"); $("#rag_wiki_list").empty(); $("#rag_wiki_section").hide(); $("#rag_doc_list").empty(); }