From e993ddac71b85725ab582ef04a4b521f3c3ad8f6 Mon Sep 17 00:00:00 2001 From: haesookim Date: Tue, 26 Aug 2025 13:19:18 +0900 Subject: [PATCH 01/14] fix: Remove debug logs from ChatParserMarkdown component Clean up the processInlineMarkdownWithCitations function by removing all console.log statements used for debugging citation parsing and processing. This reduces console noise and improves performance during runtime without affecting functionality. --- .../_common/components/ChatParserMarkdown.tsx | 43 ------------------- 1 file changed, 43 deletions(-) diff --git a/src/app/_common/components/ChatParserMarkdown.tsx b/src/app/_common/components/ChatParserMarkdown.tsx index 314af862..da9c546d 100644 --- a/src/app/_common/components/ChatParserMarkdown.tsx +++ b/src/app/_common/components/ChatParserMarkdown.tsx @@ -111,10 +111,6 @@ export const processInlineMarkdownWithCitations = ( // Citation을 찾기 위한 더 안전한 접근법 - 수동으로 파싱 const findCitations = (inputText: string): Array<{ start: number, end: number, content: string }> => { - console.log('🔍 [findCitations] Input text:', inputText); - console.log('🔍 [findCitations] Text length:', inputText.length); - console.log('🔍 [findCitations] Text preview (first 200 chars):', inputText.substring(0, 200)); - // 먼저 전체 텍스트에 대해 기본적인 전처리 수행 let preprocessedText = inputText; // 이중 중괄호를 단일 중괄호로 변환 @@ -136,9 +132,6 @@ export const processInlineMarkdownWithCitations = ( // [Cite. 패턴 찾기 const citeStart = preprocessedText.indexOf('[Cite.', i); if (citeStart === -1) break; - - console.log('🔍 [findCitations] Found [Cite. at position:', citeStart); - console.log('🔍 [findCitations] Context around [Cite.:', preprocessedText.substring(Math.max(0, citeStart - 20), citeStart + 100)); // { 또는 {{ 찾기 let braceStart = -1; @@ -155,8 +148,6 @@ export const processInlineMarkdownWithCitations = ( console.log('🔍 [findCitations] Brace start found at:', braceStart); if (braceStart === -1) { - console.log('⚠️ [findCitations] No opening brace found after [Cite. at position:', citeStart); - console.log('⚠️ [findCitations] Raw text after [Cite.:', preprocessedText.substring(citeStart + 6, citeStart + 50)); i = citeStart + 6; continue; } @@ -167,8 +158,6 @@ export const processInlineMarkdownWithCitations = ( let inString = false; let escaped = false; - console.log('🔍 [findCitations] Starting brace counting from position:', braceStart + 1); - for (let j = braceStart + 1; j < preprocessedText.length; j++) { const char = preprocessedText[j]; @@ -204,8 +193,6 @@ export const processInlineMarkdownWithCitations = ( } } - console.log('🔍 [findCitations] Final brace end:', braceEnd); - if (braceEnd !== -1) { // 닫는 ] 찾기 (선택적) - 백슬래시는 텍스트 끝까지 포함 let finalEnd = braceEnd + 1; @@ -226,9 +213,6 @@ export const processInlineMarkdownWithCitations = ( } const citationContent = preprocessedText.slice(citeStart, finalEnd); - console.log('🔍 [findCitations] Found citation from', citeStart, 'to', finalEnd); - console.log('🔍 [findCitations] Citation content:', citationContent); - console.log('🔍 [findCitations] Citation JSON-like part:', citationContent.substring(citationContent.indexOf('{'))); citations.push({ start: citeStart, @@ -245,14 +229,8 @@ export const processInlineMarkdownWithCitations = ( return citations; }; - console.log('🔍 [processInlineMarkdownWithCitations] Looking for citations in text:', text); - // 1. Citation 우선 처리 - 마크다운 파싱보다 먼저 수행 const citations = findCitations(text); - console.log('🔍 [processInlineMarkdownWithCitations] Found citations count:', citations.length); - citations.forEach((cite, idx) => { - console.log(`🔍 [processInlineMarkdownWithCitations] Citation ${idx}:`, cite); - }); if (citations.length === 0) { // Citation이 없는 경우 부분적인 citation 확인 @@ -305,20 +283,11 @@ export const processInlineMarkdownWithCitations = ( if (citation.content.trim().startsWith('Cite.')) { processedCitationContent = citation.content.replace(/\\"/g, '"'); } - - console.log('🔥 [processInlineMarkdownWithCitations] Raw citation content:', citation.content); - console.log('🔥 [processInlineMarkdownWithCitations] Processed citation content:', processedCitationContent); - console.log('🔥 [processInlineMarkdownWithCitations] About to parse citation...'); - const sourceInfo = parseCitation(processedCitationContent); - console.log('✅ [processInlineMarkdownWithCitations] Found citation:', citation.content); - console.log('✅ [processInlineMarkdownWithCitations] Parsing result:', sourceInfo); - devLog.log('🔍 [processInlineMarkdownWithCitations] Parsed sourceInfo:', sourceInfo); if (sourceInfo && onViewSource) { - console.log('🎯 [processInlineMarkdownWithCitations] Creating source button with:', sourceInfo); devLog.log('✅ [processInlineMarkdownWithCitations] Creating SourceButton'); elements.push( ); } else { - // 파싱 실패 시 원본 텍스트 표시 (마크다운 파싱 제외) - console.log('❌ [processInlineMarkdownWithCitations] Citation parsing failed or no sourceInfo'); - console.log('❌ [processInlineMarkdownWithCitations] Failed citation content:', citation.content); - console.log('❌ [processInlineMarkdownWithCitations] sourceInfo is null:', sourceInfo === null); - console.log('❌ [processInlineMarkdownWithCitations] onViewSource exists:', !!onViewSource); elements.push( @@ -345,9 +309,6 @@ export const processInlineMarkdownWithCitations = ( // Citation 처리 후 trailing 문자들 건너뛰기 let nextIndex = citation.end; - console.log('🧹 [processInlineMarkdownWithCitations] Cleaning up after citation at index:', nextIndex); - console.log('🧹 [processInlineMarkdownWithCitations] Text after citation:', text.slice(nextIndex, nextIndex + 20)); - // Citation 뒤에 남은 불완전한 JSON 구문이나 특수 문자들 정리 // }], \, 공백, 숫자, 콤마, 세미콜론 등 Citation 관련 잔여물 제거 while (nextIndex < text.length) { @@ -355,16 +316,12 @@ export const processInlineMarkdownWithCitations = ( // Citation 관련 잔여 문자들: }, ], \, 공백, 숫자, 특수문자 if (/[}\]\\.\s,;:]/.test(char) || /\d/.test(char)) { - console.log('🧹 [processInlineMarkdownWithCitations] Skipping character:', JSON.stringify(char)); nextIndex++; } else { // 일반 텍스트 문자가 나오면 정리 중단 break; } } - - console.log('🧹 [processInlineMarkdownWithCitations] Cleanup finished at index:', nextIndex); - console.log('🧹 [processInlineMarkdownWithCitations] Remaining text:', text.slice(nextIndex, nextIndex + 20)); currentIndex = nextIndex; } From 55552b640033a70f6fef6e4793835b249a933f80 Mon Sep 17 00:00:00 2001 From: haesookim Date: Tue, 26 Aug 2025 15:00:02 +0900 Subject: [PATCH 02/14] feat: Add LaTeX rendering support with KaTeX integration - Introduce `ChatParserLatex` component to detect and render inline and block LaTeX expressions using KaTeX, preserving escape characters and supporting streaming. - Update `ChatParser` and `ChatParserMarkdown` to handle LaTeX separately from markdown and citations, ensuring correct parsing and rendering order. - Add KaTeX and react-katex dependencies to package.json and package-lock.json. - Import KaTeX CSS globally for proper styling of math expressions. - Add placeholder components for partial LaTeX rendering during streaming. - Provide test pages (`test-latex/page.tsx` and `test-latex.tsx`) with various LaTe --- package-lock.json | 43 ++- package.json | 2 + src/app/_common/components/ChatParser.tsx | 10 +- .../_common/components/ChatParserLatex.tsx | 250 ++++++++++++++++++ .../_common/components/ChatParserMarkdown.tsx | 145 ++++++++-- src/app/globals.css | 1 + src/app/test-latex/page.tsx | 76 ++++++ test-latex.tsx | 56 ++++ 8 files changed, 548 insertions(+), 35 deletions(-) create mode 100644 src/app/_common/components/ChatParserLatex.tsx create mode 100644 src/app/test-latex/page.tsx create mode 100644 test-latex.tsx diff --git a/package-lock.json b/package-lock.json index 463ba83c..a9e24638 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "date-fns": "^4.1.0", "dotenv": "^17.2.0", "framer-motion": "^12.23.6", + "katex": "^0.16.22", "mammoth": "^1.10.0", "next": "15.3.2", "pdfjs-dist": "^5.4.54", @@ -25,6 +26,7 @@ "react-dom": "^19.0.0", "react-hot-toast": "^2.5.2", "react-icons": "^5.5.0", + "react-katex": "^3.1.0", "react-pdf": "^10.1.0", "react-syntax-highlighter": "^15.6.1", "sass": "^1.89.2", @@ -6522,6 +6524,31 @@ "setimmediate": "^1.0.5" } }, + "node_modules/katex": { + "version": "0.16.22", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.22.tgz", + "integrity": "sha512-XCHRdUw4lf3SKBaJe4EvgqIuWwkPSo9XoeO8GjQW94Bp7TWv9hNhzZjZ+OH9yf1UmLygb7DIT5GSFQiyt16zYg==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/katex/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -7362,7 +7389,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -7785,7 +7811,6 @@ "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dev": true, "license": "MIT", "dependencies": { "loose-envify": "^1.4.0", @@ -7896,9 +7921,21 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true, "license": "MIT" }, + "node_modules/react-katex": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/react-katex/-/react-katex-3.1.0.tgz", + "integrity": "sha512-At9uLOkC75gwn2N+ZXc5HD8TlATsB+3Hkp9OGs6uA8tM3dwZ3Wljn74Bk3JyHFPgSnesY/EMrIAB1WJwqZqejA==", + "license": "MIT", + "dependencies": { + "katex": "^0.16.0" + }, + "peerDependencies": { + "prop-types": "^15.8.1", + "react": ">=15.3.2 <20" + } + }, "node_modules/react-pdf": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/react-pdf/-/react-pdf-10.1.0.tgz", diff --git a/package.json b/package.json index 2eae9a52..bcbb295c 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "date-fns": "^4.1.0", "dotenv": "^17.2.0", "framer-motion": "^12.23.6", + "katex": "^0.16.22", "mammoth": "^1.10.0", "next": "15.3.2", "pdfjs-dist": "^5.4.54", @@ -41,6 +42,7 @@ "react-dom": "^19.0.0", "react-hot-toast": "^2.5.2", "react-icons": "^5.5.0", + "react-katex": "^3.1.0", "react-pdf": "^10.1.0", "react-syntax-highlighter": "^15.6.1", "sass": "^1.89.2", diff --git a/src/app/_common/components/ChatParser.tsx b/src/app/_common/components/ChatParser.tsx index d1fe4307..77584f9f 100644 --- a/src/app/_common/components/ChatParser.tsx +++ b/src/app/_common/components/ChatParser.tsx @@ -325,10 +325,12 @@ const parseContentToReactElements = (content: string, onViewSource?: (sourceInfo return partialCitationRegex.test(text.trim()); }; - // 이스케이프된 문자 처리 - processed = processed.replace(/\\n/g, '\n'); - processed = processed.replace(/\\t/g, '\t'); - processed = processed.replace(/\\r/g, '\r'); + // 이스케이프된 문자 처리 (LaTeX가 포함된 경우 건너뛰기) + if (!processed.includes('$$') && !processed.includes('$')) { + processed = processed.replace(/\\n/g, '\n'); + processed = processed.replace(/\\t/g, '\t'); + processed = processed.replace(/\\r/g, '\r'); + } // 불필요한 따옴표 제거 (문장 전체를 감싸는 따옴표) processed = processed.trim(); diff --git a/src/app/_common/components/ChatParserLatex.tsx b/src/app/_common/components/ChatParserLatex.tsx new file mode 100644 index 00000000..fb17c58d --- /dev/null +++ b/src/app/_common/components/ChatParserLatex.tsx @@ -0,0 +1,250 @@ +'use client'; + +import React from 'react'; +import katex from 'katex'; +import 'katex/dist/katex.min.css'; + +/** + * LaTeX 블록 정보를 나타내는 인터페이스 + */ +export interface LatexBlockInfo { + start: number; + end: number; + content: string; + isBlock: boolean; // true: $$ block math, false: $ inline math +} + +// 이스케이프 문자 처리는 단순화 - 원본 텍스트를 그대로 사용 + +/** + * 텍스트에서 LaTeX 수식 블록을 찾는 함수 + */ +export const findLatexBlocks = (text: string): LatexBlockInfo[] => { + const blocks: LatexBlockInfo[] = []; + let index = 0; + + while (index < text.length) { + // 블록 수식 ($$...$$) 먼저 찾기 + const blockStart = text.indexOf('$$', index); + const blockEnd = blockStart !== -1 ? text.indexOf('$$', blockStart + 2) : -1; + + // 인라인 수식 ($...$) 찾기 + const inlineStart = text.indexOf('$', index); + const inlineEnd = inlineStart !== -1 ? text.indexOf('$', inlineStart + 1) : -1; + + // 더 가까운 것부터 처리 + if (blockStart !== -1 && blockEnd !== -1 && + (inlineStart === -1 || inlineEnd === -1 || blockStart < inlineStart)) { + // 블록 수식 처리 + const content = text.slice(blockStart + 2, blockEnd); + console.log('Block content extracted:', { + original: text.slice(blockStart, blockEnd + 2), + content: content, + hasBackslash: content.includes('\\') + }); + if (content.trim()) { + blocks.push({ + start: blockStart, + end: blockEnd + 2, + content: content.trim(), + isBlock: true + }); + } + index = blockEnd + 2; + } else if (inlineStart !== -1 && inlineEnd !== -1 && inlineStart !== blockStart) { + // 인라인 수식 처리 (블록 수식의 시작이 아닌 경우) + const content = text.slice(inlineStart + 1, inlineEnd); + if (content.trim() && !content.includes('\n')) { // 인라인은 줄바꿈 없어야 함 + blocks.push({ + start: inlineStart, + end: inlineEnd + 1, + content: content.trim(), + isBlock: false + }); + } + index = inlineEnd + 1; + } else { + break; + } + } + + return blocks.sort((a, b) => a.start - b.start); +}; + +/** + * LaTeX 수식이 스트리밍 중인지 감지하는 함수 + */ +export const detectLatexStreaming = (text: string, textStartIndex: number, totalLength: number): boolean => { + // 텍스트가 전체 콘텐츠의 끝 부분인지 확인 + const isAtEnd = textStartIndex + text.length === totalLength; + if (!isAtEnd) return false; + + // 불완전한 LaTeX 패턴 확인 + const partialLatexRegex = /\$+[^$]*$/; + return partialLatexRegex.test(text.trim()); +}; + +/** + * LaTeX 렌더링 컴포넌트 + */ +interface LatexRendererProps { + content: string; + isBlock: boolean; + isStreaming?: boolean; +} + +export const LatexRenderer: React.FC = ({ + content, + isBlock +}) => { + try { + // KaTeX로 직접 렌더링 + const html = katex.renderToString(content, { + displayMode: isBlock, + throwOnError: false, + strict: false, + trust: true + }); + + if (isBlock) { + return ( +
+ ); + } else { + return ( + + ); + } + } catch (error) { + // LaTeX 파싱 에러 시 원본 텍스트 표시 + console.error('LaTeX rendering error:', error); + return ( + + {isBlock ? `$$${content}$$` : `$${content}$`} + + ); + } +}; + +/** + * LaTeX 플레이스홀더 컴포넌트 - 스트리밍 중 부분적인 LaTeX 표시 + */ +export const LatexPlaceholder: React.FC<{ isBlock?: boolean }> = ({ isBlock = false }) => { + return ( + + {isBlock ? '📐 수식 블록 로딩 중...' : '📐 수식 로딩 중...'} + + ); +}; + +/** + * 텍스트에서 LaTeX를 처리하고 React 요소로 변환 + */ +export const processLatexInText = ( + text: string, + key: string, + isStreaming: boolean = false +): React.ReactNode[] => { + // LaTeX 블록 찾기 (원본 텍스트에서) + const latexBlocks = findLatexBlocks(text); + + if (latexBlocks.length === 0) { + // LaTeX가 없는 경우 부분적인 LaTeX 확인 + const partialLatexRegex = /\$+[^$]*$/; + const partialMatch = partialLatexRegex.exec(text); + + if (partialMatch && isStreaming) { + // 부분적인 LaTeX 이전 텍스트 처리 + const beforeText = text.slice(0, partialMatch.index); + const elements: React.ReactNode[] = []; + + if (beforeText) { + elements.push( + {beforeText} + ); + } + + // 부분적인 LaTeX 감지 + const isBlockMath = partialMatch[0].startsWith('$$'); + elements.push( + + ); + + return elements; + } else { + // LaTeX가 전혀 없는 경우 원본 텍스트 반환 + return [{text}]; + } + } + + // LaTeX가 있는 경우 LaTeX와 텍스트를 분할하여 처리 + const elements: React.ReactNode[] = []; + let currentIndex = 0; + + for (let i = 0; i < latexBlocks.length; i++) { + const block = latexBlocks[i]; + + // LaTeX 블록 이전 텍스트 처리 + if (block.start > currentIndex) { + const beforeText = text.slice(currentIndex, block.start); + if (beforeText.trim()) { + elements.push( + {beforeText} + ); + } + } + + // LaTeX 블록 렌더링 + elements.push( + + ); + + currentIndex = block.end; + } + + // 남은 텍스트 처리 + if (currentIndex < text.length) { + const remainingText = text.slice(currentIndex); + if (remainingText.trim()) { + elements.push( + {remainingText} + ); + } + } + + return elements; +}; + +/** + * 텍스트에 LaTeX가 포함되어 있는지 확인하는 헬퍼 함수 + */ +export const hasLatex = (text: string): boolean => { + return /\$+.*?\$+/.test(text); +}; \ No newline at end of file diff --git a/src/app/_common/components/ChatParserMarkdown.tsx b/src/app/_common/components/ChatParserMarkdown.tsx index da9c546d..9c04d519 100644 --- a/src/app/_common/components/ChatParserMarkdown.tsx +++ b/src/app/_common/components/ChatParserMarkdown.tsx @@ -5,6 +5,7 @@ import SourceButton from '@/app/chat/components/SourceButton'; import { SourceInfo } from '@/app/chat/types/source'; import sourceStyles from '@/app/chat/assets/SourceButton.module.scss'; import { devLog } from '@/app/_common/utils/logger'; +import { processLatexInText, hasLatex } from './ChatParserLatex'; /** * Citation Placeholder 컴포넌트 - 스트리밍 중 부분적인 citation 표시 @@ -64,11 +65,16 @@ export const getLastLines = (text: string, n: number = 3): string => { }; /** - * 인라인 마크다운 처리 (볼드, 이탤릭, 링크 등) + * 인라인 마크다운 처리 (볼드, 이탤릭, 링크 등) - LaTeX 제외 */ -export const processInlineMarkdown = (text: string, isStreaming: boolean = false): string => { +export const processInlineMarkdown = (text: string): string => { let processed = cleanupJsonFragments(text); + // LaTeX가 있는 경우 처리하지 않고 원본 반환 (LaTeX는 별도 처리됨) + if (hasLatex(processed)) { + return processed; + } + // 인라인 코드 처리 (가장 먼저) processed = processed.replace(/`([^`\n]+)`/g, '$1'); @@ -91,8 +97,7 @@ export const processInlineMarkdown = (text: string, isStreaming: boolean = false }; /** - * Citation을 포함한 텍스트 처리 - Citation 파싱을 마크다운보다 먼저 수행 - * Cite.로 시작하는 텍스트는 마크다운 렌더링하지 말고 무조건 출처 버튼 처리만 함 + * Citation과 LaTeX를 포함한 텍스트 처리 - LaTeX, Citation, 마크다운 순서로 처리 */ export const processInlineMarkdownWithCitations = ( text: string, @@ -103,16 +108,76 @@ export const processInlineMarkdownWithCitations = ( ): React.ReactNode[] => { const elements: React.ReactNode[] = []; - // parseCitation이 없으면 Citation 처리 없이 마크다운만 처리 + // 1. LaTeX와 Citation 모두 체크하여 적절히 처리 + const hasLatexContent = hasLatex(text); + + // LaTeX만 있고 Citation이 없는 경우에만 LaTeX 처리로 바로 넘김 + if (hasLatexContent && !text.includes('[Cite.')) { + return processLatexInText(text, key, isStreaming); + } + + // Citation만 있고 LaTeX가 없는 경우는 기존 로직 사용 + // LaTeX와 Citation이 모두 있는 경우는 혼합 처리 (아래에서 구현) + + // 2. parseCitation이 없으면 Citation 처리 없이 처리 if (!parseCitation) { - const processedText = processInlineMarkdown(text, isStreaming); - return [
]; + if (hasLatexContent) { + return processLatexInText(text, key, isStreaming); + } else { + const processedText = processInlineMarkdown(text); + return [
]; + } } - // Citation을 찾기 위한 더 안전한 접근법 - 수동으로 파싱 + // Citation을 찾기 위한 더 안전한 접근법 - LaTeX 영역 보호 const findCitations = (inputText: string): Array<{ start: number, end: number, content: string }> => { - // 먼저 전체 텍스트에 대해 기본적인 전처리 수행 - let preprocessedText = inputText; + // LaTeX 수식 영역을 임시로 보호 + const latexBlocks: Array<{ start: number, end: number, placeholder: string }> = []; + let protectedText = inputText; + let latexIndex = 0; + + // LaTeX 블록 찾아서 보호 + const latexBlockRegex = /\$\$[\s\S]*?\$\$/g; + const latexInlineRegex = /\$[^$\n]+\$/g; + + let match; + const allMatches = []; + + // 블록 수식 찾기 + while ((match = latexBlockRegex.exec(inputText)) !== null) { + allMatches.push({ start: match.index, end: match.index + match[0].length, content: match[0] }); + } + + // 인라인 수식 찾기 + latexBlockRegex.lastIndex = 0; + while ((match = latexInlineRegex.exec(inputText)) !== null) { + // 블록 수식과 겹치지 않는지 확인 + const isOverlapping = allMatches.some(block => + match.index >= block.start && match.index < block.end + ); + if (!isOverlapping) { + allMatches.push({ start: match.index, end: match.index + match[0].length, content: match[0] }); + } + } + + // 시작 위치 순으로 정렬 + allMatches.sort((a, b) => a.start - b.start); + + // 뒤에서부터 치환 (인덱스가 변하지 않도록) + for (let i = allMatches.length - 1; i >= 0; i--) { + const placeholder = `__LATEX_PROTECTED_${latexIndex++}__`; + latexBlocks.unshift({ + start: allMatches[i].start, + end: allMatches[i].end, + placeholder: placeholder + }); + protectedText = protectedText.slice(0, allMatches[i].start) + + placeholder + + protectedText.slice(allMatches[i].end); + } + + // Citation 전처리 (LaTeX 보호된 텍스트에서) + let preprocessedText = protectedText; // 이중 중괄호를 단일 중괄호로 변환 preprocessedText = preprocessedText.replace(/\{\{/g, '{').replace(/\}\}/g, '}'); // }}}] 같은 패턴을 }}] 로 정리 @@ -123,6 +188,11 @@ export const processInlineMarkdownWithCitations = ( preprocessedText = preprocessedText.replace(/"""([^"]*?)"/g, '"$1"'); // 3개 따옴표 -> 1개 preprocessedText = preprocessedText.replace(/""([^"]*?)"/g, '"$1"'); // 2개 따옴표 -> 1개 + // LaTeX 보호 해제 + for (const block of latexBlocks) { + preprocessedText = preprocessedText.replace(block.placeholder, inputText.slice(block.start, block.end)); + } + console.log('🔍 [findCitations] After basic preprocessing:', preprocessedText); const citations: Array<{ start: number, end: number, content: string }> = []; @@ -238,13 +308,18 @@ export const processInlineMarkdownWithCitations = ( const partialMatch = partialCitationRegex.exec(text); if (partialMatch) { - // 부분적인 citation 이전 텍스트 처리 - 마크다운 파싱 적용 + // 부분적인 citation 이전 텍스트 처리 - LaTeX 먼저 확인 후 마크다운 파싱 적용 const beforeText = text.slice(0, partialMatch.index); if (beforeText) { - const processedText = processInlineMarkdown(beforeText, isStreaming); - elements.push( - - ); + if (hasLatex(beforeText)) { + const latexElements = processLatexInText(beforeText, `${key}-text-before`, isStreaming); + elements.push(...latexElements); + } else { + const processedText = processInlineMarkdown(beforeText); + elements.push( + + ); + } } // 부분적인 citation placeholder 추가 @@ -254,9 +329,13 @@ export const processInlineMarkdownWithCitations = ( return [
{elements}
]; } else { - // Citation이 전혀 없는 경우 마크다운 파싱 적용 - const processedText = processInlineMarkdown(text, isStreaming); - return [
]; + // Citation이 전혀 없는 경우 LaTeX 먼저 확인 후 마크다운 파싱 적용 + if (hasLatexContent) { + return processLatexInText(text, key, isStreaming); + } else { + const processedText = processInlineMarkdown(text); + return [
]; + } } } @@ -266,14 +345,19 @@ export const processInlineMarkdownWithCitations = ( for (let i = 0; i < citations.length; i++) { const citation = citations[i]; - // Citation 이전 텍스트 처리 - 마크다운 파싱 적용 + // Citation 이전 텍스트 처리 - LaTeX 먼저 확인 후 마크다운 파싱 적용 if (citation.start > currentIndex) { const beforeText = text.slice(currentIndex, citation.start); if (beforeText.trim()) { - const processedText = processInlineMarkdown(beforeText, isStreaming); - elements.push( - - ); + if (hasLatex(beforeText)) { + const latexElements = processLatexInText(beforeText, `${key}-text-${i}`, isStreaming); + elements.push(...latexElements); + } else { + const processedText = processInlineMarkdown(beforeText); + elements.push( + + ); + } } } @@ -326,14 +410,19 @@ export const processInlineMarkdownWithCitations = ( currentIndex = nextIndex; } - // 남은 텍스트 처리 - 마크다운 파싱 적용 + // 남은 텍스트 처리 - LaTeX 먼저 확인 후 마크다운 파싱 적용 if (currentIndex < text.length) { const remainingText = text.slice(currentIndex); if (remainingText.trim()) { - const processedText = processInlineMarkdown(remainingText, isStreaming); - elements.push( - - ); + if (hasLatex(remainingText)) { + const latexElements = processLatexInText(remainingText, `${key}-text-remaining`, isStreaming); + elements.push(...latexElements); + } else { + const processedText = processInlineMarkdown(remainingText); + elements.push( + + ); + } } } diff --git a/src/app/globals.css b/src/app/globals.css index f495d430..14cd3c65 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,4 +1,5 @@ @import "tailwindcss"; +@import "katex/dist/katex.min.css"; :root { --background: #ffffff; diff --git a/src/app/test-latex/page.tsx b/src/app/test-latex/page.tsx new file mode 100644 index 00000000..6f107c42 --- /dev/null +++ b/src/app/test-latex/page.tsx @@ -0,0 +1,76 @@ +'use client'; + +import React from 'react'; +import { MessageRenderer } from '../_common/components/ChatParser'; + +const TestLatexPage = () => { + const testCases = [ + // 사용자가 제시한 원본 예제 (문제 상황) + '$$ \\text{중도상환수수료} = \\text{상환금액} \\times \\text{수수료율(0.05%~0.65%)} \\times \\frac{ \\text{대출잔여일수}}{ \\text{대출기간}} $$', + + // 동일한 수식을 더 간단하게 + '$$ \\text{테스트} = \\frac{a}{b} $$', + + // 간단한 인라인 수식 + '이것은 $E = mc^2$ 공식입니다.', + + // 블록 수식 + '아래는 이차방정식의 해입니다:\n$$x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}$$', + + // 복잡한 LaTeX 명령어들 + '$$ \\sum_{i=1}^{n} \\alpha_i x_i + \\beta = \\gamma $$', + + // 분수와 루트가 섞인 수식 + '$$ \\sqrt{\\frac{\\alpha + \\beta}{\\gamma - \\delta}} $$', + + // 일반 텍스트와 LaTeX가 섞인 경우 + '투자 수익률은 $R = \\frac{P_1 - P_0}{P_0} \\times 100\\%$로 계산되며, 여기서 P₁은 현재가격, P₀는 초기가격입니다.', + + // 스트리밍 시뮬레이션 (불완전한 수식) + '계산 과정: $$\\text{결과} = \\frac{a + b' + ]; + + return ( +
+

LaTeX 렌더링 테스트

+ + {testCases.map((testCase, index) => ( +
+

테스트 케이스 {index + 1}

+
+ 원본: {testCase} +
+
+ +
+
+ ))} + +
+

테스트 정보

+

• KaTeX 라이브러리를 사용하여 LaTeX 수식을 렌더링합니다.

+

• 이스케이프 문자(백슬래시)가 제대로 보존되어야 합니다.

+

• 인라인 수식($...$)과 블록 수식($$...$$)을 모두 지원합니다.

+

• 스트리밍 중에는 부분적인 수식에 대해 플레이스홀더를 표시합니다.

+
+
+ ); +}; + +export default TestLatexPage; \ No newline at end of file diff --git a/test-latex.tsx b/test-latex.tsx new file mode 100644 index 00000000..9c092fa8 --- /dev/null +++ b/test-latex.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import { MessageRenderer } from './src/app/_common/components/ChatParser'; + +const TestLatex = () => { + const testCases = [ + // 사용자가 제시한 원본 예제 + '$$ \\text{중도상환수수료} = \\text{상환금액} \\times \\text{수수료율(0.05%~0.65%)} \\times \\frac{ \\text{대출잔여일수}}{ \\text{대출기간}} $$', + + // 간단한 인라인 수식 + '이것은 $E = mc^2$ 공식입니다.', + + // 블록 수식 + '아래는 이차방정식의 해입니다:\n$$x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}$$', + + // 복잡한 LaTeX 명령어들 + '$$ \\sum_{i=1}^{n} \\alpha_i x_i + \\beta = \\gamma $$', + + // 분수와 루트가 섞인 수식 + '$$ \\sqrt{\\frac{\\alpha + \\beta}{\\gamma - \\delta}} $$', + + // 일반 텍스트와 LaTeX가 섞인 경우 + '투자 수익률은 $R = \\frac{P_1 - P_0}{P_0} \\times 100\\%$로 계산되며, 여기서 P₁은 현재가격, P₀는 초기가격입니다.' + ]; + + return ( +
+

LaTeX 렌더링 테스트

+ + {testCases.map((testCase, index) => ( +
+

테스트 케이스 {index + 1}

+
+ 원본: {testCase} +
+
+ +
+
+ ))} +
+ ); +}; + +export default TestLatex; \ No newline at end of file From 96506d01e49a44931f601582070fd65dae2e696f Mon Sep 17 00:00:00 2001 From: haesookim Date: Tue, 26 Aug 2025 15:11:47 +0900 Subject: [PATCH 03/14] refactor: Improve LaTeX regex handling in ChatParserMarkdown - Add explicit typing for regex match and matches array to enhance type safety - Fix resetting lastIndex on inline regex instead of block regex to avoid incorrect matching - Ensure inline matches do not overlap with block matches by refining overlap check - Remove obsolete test-latex.tsx file used for LaTeX rendering tests --- .../_common/components/ChatParserMarkdown.tsx | 8 +-- test-latex.tsx | 56 ------------------- 2 files changed, 4 insertions(+), 60 deletions(-) delete mode 100644 test-latex.tsx diff --git a/src/app/_common/components/ChatParserMarkdown.tsx b/src/app/_common/components/ChatParserMarkdown.tsx index 9c04d519..a56bdbe2 100644 --- a/src/app/_common/components/ChatParserMarkdown.tsx +++ b/src/app/_common/components/ChatParserMarkdown.tsx @@ -140,8 +140,8 @@ export const processInlineMarkdownWithCitations = ( const latexBlockRegex = /\$\$[\s\S]*?\$\$/g; const latexInlineRegex = /\$[^$\n]+\$/g; - let match; - const allMatches = []; + let match: RegExpExecArray | null; + const allMatches: Array<{ start: number, end: number, content: string }> = []; // 블록 수식 찾기 while ((match = latexBlockRegex.exec(inputText)) !== null) { @@ -149,11 +149,11 @@ export const processInlineMarkdownWithCitations = ( } // 인라인 수식 찾기 - latexBlockRegex.lastIndex = 0; + latexInlineRegex.lastIndex = 0; while ((match = latexInlineRegex.exec(inputText)) !== null) { // 블록 수식과 겹치지 않는지 확인 const isOverlapping = allMatches.some(block => - match.index >= block.start && match.index < block.end + match!.index >= block.start && match!.index < block.end ); if (!isOverlapping) { allMatches.push({ start: match.index, end: match.index + match[0].length, content: match[0] }); diff --git a/test-latex.tsx b/test-latex.tsx deleted file mode 100644 index 9c092fa8..00000000 --- a/test-latex.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import React from 'react'; -import { MessageRenderer } from './src/app/_common/components/ChatParser'; - -const TestLatex = () => { - const testCases = [ - // 사용자가 제시한 원본 예제 - '$$ \\text{중도상환수수료} = \\text{상환금액} \\times \\text{수수료율(0.05%~0.65%)} \\times \\frac{ \\text{대출잔여일수}}{ \\text{대출기간}} $$', - - // 간단한 인라인 수식 - '이것은 $E = mc^2$ 공식입니다.', - - // 블록 수식 - '아래는 이차방정식의 해입니다:\n$$x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}$$', - - // 복잡한 LaTeX 명령어들 - '$$ \\sum_{i=1}^{n} \\alpha_i x_i + \\beta = \\gamma $$', - - // 분수와 루트가 섞인 수식 - '$$ \\sqrt{\\frac{\\alpha + \\beta}{\\gamma - \\delta}} $$', - - // 일반 텍스트와 LaTeX가 섞인 경우 - '투자 수익률은 $R = \\frac{P_1 - P_0}{P_0} \\times 100\\%$로 계산되며, 여기서 P₁은 현재가격, P₀는 초기가격입니다.' - ]; - - return ( -
-

LaTeX 렌더링 테스트

- - {testCases.map((testCase, index) => ( -
-

테스트 케이스 {index + 1}

-
- 원본: {testCase} -
-
- -
-
- ))} -
- ); -}; - -export default TestLatex; \ No newline at end of file From 879fb4bf69f76c21545929e33022e10bacfa742c Mon Sep 17 00:00:00 2001 From: haesookim Date: Tue, 26 Aug 2025 15:13:53 +0900 Subject: [PATCH 04/14] chore: Remove test-latex page and related LaTeX rendering tests Deleted the TestLatexPage component which contained various LaTeX rendering test cases using the MessageRenderer component. This cleanup removes unused test code and simplifies the codebase. --- src/app/test-latex/page.tsx | 76 ------------------------------------- 1 file changed, 76 deletions(-) delete mode 100644 src/app/test-latex/page.tsx diff --git a/src/app/test-latex/page.tsx b/src/app/test-latex/page.tsx deleted file mode 100644 index 6f107c42..00000000 --- a/src/app/test-latex/page.tsx +++ /dev/null @@ -1,76 +0,0 @@ -'use client'; - -import React from 'react'; -import { MessageRenderer } from '../_common/components/ChatParser'; - -const TestLatexPage = () => { - const testCases = [ - // 사용자가 제시한 원본 예제 (문제 상황) - '$$ \\text{중도상환수수료} = \\text{상환금액} \\times \\text{수수료율(0.05%~0.65%)} \\times \\frac{ \\text{대출잔여일수}}{ \\text{대출기간}} $$', - - // 동일한 수식을 더 간단하게 - '$$ \\text{테스트} = \\frac{a}{b} $$', - - // 간단한 인라인 수식 - '이것은 $E = mc^2$ 공식입니다.', - - // 블록 수식 - '아래는 이차방정식의 해입니다:\n$$x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}$$', - - // 복잡한 LaTeX 명령어들 - '$$ \\sum_{i=1}^{n} \\alpha_i x_i + \\beta = \\gamma $$', - - // 분수와 루트가 섞인 수식 - '$$ \\sqrt{\\frac{\\alpha + \\beta}{\\gamma - \\delta}} $$', - - // 일반 텍스트와 LaTeX가 섞인 경우 - '투자 수익률은 $R = \\frac{P_1 - P_0}{P_0} \\times 100\\%$로 계산되며, 여기서 P₁은 현재가격, P₀는 초기가격입니다.', - - // 스트리밍 시뮬레이션 (불완전한 수식) - '계산 과정: $$\\text{결과} = \\frac{a + b' - ]; - - return ( -
-

LaTeX 렌더링 테스트

- - {testCases.map((testCase, index) => ( -
-

테스트 케이스 {index + 1}

-
- 원본: {testCase} -
-
- -
-
- ))} - -
-

테스트 정보

-

• KaTeX 라이브러리를 사용하여 LaTeX 수식을 렌더링합니다.

-

• 이스케이프 문자(백슬래시)가 제대로 보존되어야 합니다.

-

• 인라인 수식($...$)과 블록 수식($$...$$)을 모두 지원합니다.

-

• 스트리밍 중에는 부분적인 수식에 대해 플레이스홀더를 표시합니다.

-
-
- ); -}; - -export default TestLatexPage; \ No newline at end of file From 410847514c1304da28f4b8b471c091a9273794f0 Mon Sep 17 00:00:00 2001 From: haesookim Date: Tue, 26 Aug 2025 16:15:09 +0900 Subject: [PATCH 05/14] feat: Improve LaTeX parsing and escaping in chat components - Enhance escape handling in ChatParser to skip LaTeX commands and syntax - Refactor findLatexBlocks with regex for accurate block and inline math detection - Add smart escaping of LaTeX special characters, especially % in ChatParserLatex - Simplify citation preprocessing in ChatParserMarkdown by skipping LaTeX areas - Add detailed debug logs for LaTeX block detection and rendering steps These changes improve robustness and correctness when processing LaTeX content and citations within chat messages, reducing parsing errors and rendering issues. --- src/app/_common/components/ChatParser.tsx | 6 +- .../_common/components/ChatParserLatex.tsx | 137 ++++++++++++------ .../_common/components/ChatParserMarkdown.tsx | 74 ++-------- 3 files changed, 109 insertions(+), 108 deletions(-) diff --git a/src/app/_common/components/ChatParser.tsx b/src/app/_common/components/ChatParser.tsx index 77584f9f..9675f7c6 100644 --- a/src/app/_common/components/ChatParser.tsx +++ b/src/app/_common/components/ChatParser.tsx @@ -326,7 +326,11 @@ const parseContentToReactElements = (content: string, onViewSource?: (sourceInfo }; // 이스케이프된 문자 처리 (LaTeX가 포함된 경우 건너뛰기) - if (!processed.includes('$$') && !processed.includes('$')) { + // LaTeX 수식이나 수학 명령어가 포함된 경우 이스케이프 처리 생략 + const hasLatexCommands = /\\(text|frac|sqrt|sum|times|alpha|beta|gamma|delta|int|left|right)\b/.test(processed); + const hasLatexSyntax = processed.includes('$$') || processed.includes('$'); + + if (!hasLatexCommands && !hasLatexSyntax) { processed = processed.replace(/\\n/g, '\n'); processed = processed.replace(/\\t/g, '\t'); processed = processed.replace(/\\r/g, '\r'); diff --git a/src/app/_common/components/ChatParserLatex.tsx b/src/app/_common/components/ChatParserLatex.tsx index fb17c58d..01aa4d4b 100644 --- a/src/app/_common/components/ChatParserLatex.tsx +++ b/src/app/_common/components/ChatParserLatex.tsx @@ -14,61 +14,93 @@ export interface LatexBlockInfo { isBlock: boolean; // true: $$ block math, false: $ inline math } -// 이스케이프 문자 처리는 단순화 - 원본 텍스트를 그대로 사용 +/** + * LaTeX 특수 문자 이스케이프 처리 (스마트 처리) + */ +const escapeLatexSpecialChars = (text: string): string => { + let processed = text; + + // 1. \text{} 블록 내부의 특수 문자만 이스케이프 처리 + processed = processed.replace(/\\text\{([^}]*)\}/g, (_match, textContent) => { + let escapedTextContent = textContent; + + // \text{} 내부에서만 특수 문자 이스케이프 + escapedTextContent = escapedTextContent.replace(/(? { - const blocks: LatexBlockInfo[] = []; - let index = 0; - - while (index < text.length) { - // 블록 수식 ($$...$$) 먼저 찾기 - const blockStart = text.indexOf('$$', index); - const blockEnd = blockStart !== -1 ? text.indexOf('$$', blockStart + 2) : -1; - - // 인라인 수식 ($...$) 찾기 - const inlineStart = text.indexOf('$', index); - const inlineEnd = inlineStart !== -1 ? text.indexOf('$', inlineStart + 1) : -1; - - // 더 가까운 것부터 처리 - if (blockStart !== -1 && blockEnd !== -1 && - (inlineStart === -1 || inlineEnd === -1 || blockStart < inlineStart)) { - // 블록 수식 처리 - const content = text.slice(blockStart + 2, blockEnd); - console.log('Block content extracted:', { - original: text.slice(blockStart, blockEnd + 2), - content: content, - hasBackslash: content.includes('\\') + console.log('🔍 [findLatexBlocks] Input text:', text); + + // 정규식을 사용한 더 정확한 LaTeX 블록 찾기 + const blockRegex = /\$\$([\s\S]*?)\$\$/g; + const inlineRegex = /(? = []; + + // 블록 수식 찾기 + while ((match = blockRegex.exec(text)) !== null) { + allMatches.push({ + start: match.index, + end: match.index + match[0].length, + content: match[1], + isBlock: true + }); + console.log('✅ Block math found:', { + start: match.index, + end: match.index + match[0].length, + content: match[1], + full: match[0] + }); + } + + // 인라인 수식 찾기 (블록 수식과 겹치지 않는 것만) + blockRegex.lastIndex = 0; // reset + while ((match = inlineRegex.exec(text)) !== null) { + // 블록 수식과 겹치는지 확인 + const isOverlapping = allMatches.some(block => + block.isBlock && match!.index >= block.start && match!.index < block.end + ); + + if (!isOverlapping) { + allMatches.push({ + start: match.index, + end: match.index + match[0].length, + content: match[1], + isBlock: false + }); + console.log('✅ Inline math found:', { + start: match.index, + end: match.index + match[0].length, + content: match[1], + full: match[0] }); - if (content.trim()) { - blocks.push({ - start: blockStart, - end: blockEnd + 2, - content: content.trim(), - isBlock: true - }); - } - index = blockEnd + 2; - } else if (inlineStart !== -1 && inlineEnd !== -1 && inlineStart !== blockStart) { - // 인라인 수식 처리 (블록 수식의 시작이 아닌 경우) - const content = text.slice(inlineStart + 1, inlineEnd); - if (content.trim() && !content.includes('\n')) { // 인라인은 줄바꿈 없어야 함 - blocks.push({ - start: inlineStart, - end: inlineEnd + 1, - content: content.trim(), - isBlock: false - }); - } - index = inlineEnd + 1; - } else { - break; } } - - return blocks.sort((a, b) => a.start - b.start); + + // 시작 위치 순으로 정렬하고 LatexBlockInfo 형태로 변환 + return allMatches + .sort((a, b) => a.start - b.start) + .map(match => ({ + start: match.start, + end: match.end, + content: match.content.trim(), + isBlock: match.isBlock + })); }; /** @@ -98,8 +130,17 @@ export const LatexRenderer: React.FC = ({ isBlock }) => { try { + // LaTeX 특수 문자 이스케이프 처리 + const escapedContent = escapeLatexSpecialChars(content); + + console.log('🔍 [LatexRenderer] Content processing:', { + original: content, + escaped: escapedContent, + hasPercent: content.includes('%') + }); + // KaTeX로 직접 렌더링 - const html = katex.renderToString(content, { + const html = katex.renderToString(escapedContent, { displayMode: isBlock, throwOnError: false, strict: false, diff --git a/src/app/_common/components/ChatParserMarkdown.tsx b/src/app/_common/components/ChatParserMarkdown.tsx index a56bdbe2..762d3cf0 100644 --- a/src/app/_common/components/ChatParserMarkdown.tsx +++ b/src/app/_common/components/ChatParserMarkdown.tsx @@ -129,68 +129,24 @@ export const processInlineMarkdownWithCitations = ( } } - // Citation을 찾기 위한 더 안전한 접근법 - LaTeX 영역 보호 + // Citation을 찾기 위한 더 안전한 접근법 - 단순화 const findCitations = (inputText: string): Array<{ start: number, end: number, content: string }> => { - // LaTeX 수식 영역을 임시로 보호 - const latexBlocks: Array<{ start: number, end: number, placeholder: string }> = []; - let protectedText = inputText; - let latexIndex = 0; + console.log('🔍 [findCitations] Input text:', JSON.stringify(inputText)); - // LaTeX 블록 찾아서 보호 - const latexBlockRegex = /\$\$[\s\S]*?\$\$/g; - const latexInlineRegex = /\$[^$\n]+\$/g; + // LaTeX가 포함된 텍스트에서는 Citation 전처리를 최소화 + let preprocessedText = inputText; - let match: RegExpExecArray | null; - const allMatches: Array<{ start: number, end: number, content: string }> = []; - - // 블록 수식 찾기 - while ((match = latexBlockRegex.exec(inputText)) !== null) { - allMatches.push({ start: match.index, end: match.index + match[0].length, content: match[0] }); - } - - // 인라인 수식 찾기 - latexInlineRegex.lastIndex = 0; - while ((match = latexInlineRegex.exec(inputText)) !== null) { - // 블록 수식과 겹치지 않는지 확인 - const isOverlapping = allMatches.some(block => - match!.index >= block.start && match!.index < block.end - ); - if (!isOverlapping) { - allMatches.push({ start: match.index, end: match.index + match[0].length, content: match[0] }); - } - } - - // 시작 위치 순으로 정렬 - allMatches.sort((a, b) => a.start - b.start); - - // 뒤에서부터 치환 (인덱스가 변하지 않도록) - for (let i = allMatches.length - 1; i >= 0; i--) { - const placeholder = `__LATEX_PROTECTED_${latexIndex++}__`; - latexBlocks.unshift({ - start: allMatches[i].start, - end: allMatches[i].end, - placeholder: placeholder - }); - protectedText = protectedText.slice(0, allMatches[i].start) + - placeholder + - protectedText.slice(allMatches[i].end); - } - - // Citation 전처리 (LaTeX 보호된 텍스트에서) - let preprocessedText = protectedText; - // 이중 중괄호를 단일 중괄호로 변환 - preprocessedText = preprocessedText.replace(/\{\{/g, '{').replace(/\}\}/g, '}'); - // }}}] 같은 패턴을 }}] 로 정리 - preprocessedText = preprocessedText.replace(/\}\}\}/g, '}}'); - // 숫자 필드 뒤의 잘못된 따옴표 제거 - preprocessedText = preprocessedText.replace(/(\d)"\s*([,}])/g, '$1$2'); - // 문자열 필드에서 중복 따옴표 정리 - preprocessedText = preprocessedText.replace(/"""([^"]*?)"/g, '"$1"'); // 3개 따옴표 -> 1개 - preprocessedText = preprocessedText.replace(/""([^"]*?)"/g, '"$1"'); // 2개 따옴표 -> 1개 - - // LaTeX 보호 해제 - for (const block of latexBlocks) { - preprocessedText = preprocessedText.replace(block.placeholder, inputText.slice(block.start, block.end)); + // LaTeX 영역이 아닌 곳에서만 전처리 수행 + if (!hasLatex(inputText)) { + // 이중 중괄호를 단일 중괄호로 변환 + preprocessedText = preprocessedText.replace(/\{\{/g, '{').replace(/\}\}/g, '}'); + // }}}] 같은 패턴을 }}] 로 정리 + preprocessedText = preprocessedText.replace(/\}\}\}/g, '}}'); + // 숫자 필드 뒤의 잘못된 따옴표 제거 + preprocessedText = preprocessedText.replace(/(\d)"\s*([,}])/g, '$1$2'); + // 문자열 필드에서 중복 따옴표 정리 + preprocessedText = preprocessedText.replace(/"""([^"]*?)"/g, '"$1"'); // 3개 따옴표 -> 1개 + preprocessedText = preprocessedText.replace(/""([^"]*?)"/g, '"$1"'); // 2개 따옴표 -> 1개 } console.log('🔍 [findCitations] After basic preprocessing:', preprocessedText); From b96c253740f9e629d38de17f9ec5ff83c7378854 Mon Sep 17 00:00:00 2001 From: haesookim Date: Tue, 26 Aug 2025 16:22:16 +0900 Subject: [PATCH 06/14] refactor: Move citation parsing logic to separate module - Extract parseCitation and related helper functions from ChatParser.tsx into a new ChatParserCite.tsx component for better modularity. - Remove deprecated parseCitation implementation and preprocessJsonString from ChatParser.tsx and import the new parseCitation function. - Maintain existing parsing behavior including balanced brace detection, escape sequence handling, and manual fallback parsing. - Improve code organization and separation of concerns in chat parsing components. --- src/app/_common/components/ChatParser.tsx | 223 +----------------- src/app/_common/components/ChatParserCite.tsx | 217 +++++++++++++++++ 2 files changed, 218 insertions(+), 222 deletions(-) create mode 100644 src/app/_common/components/ChatParserCite.tsx diff --git a/src/app/_common/components/ChatParser.tsx b/src/app/_common/components/ChatParser.tsx index 9675f7c6..c6b75a17 100644 --- a/src/app/_common/components/ChatParser.tsx +++ b/src/app/_common/components/ChatParser.tsx @@ -4,7 +4,6 @@ import React from 'react'; import styles from '@/app/chat/assets/chatParser.module.scss'; import { APP_CONFIG } from '@/app/config'; import { SourceInfo } from '@/app/chat/types/source'; -import { devLog } from '@/app/_common/utils/logger'; import { ThinkBlock, findThinkBlocks, type ThinkBlockInfo } from './ChatParserThink'; import { CodeBlock, findCodeBlocks, type CodeBlockInfo, detectCodeLanguage, truncateText } from '@/app/_common/components/ChatParserCode'; import { @@ -25,6 +24,7 @@ import { processInlineMarkdownWithCitations, parseSimpleMarkdown } from '@/app/_common/components/ChatParserMarkdown'; +import { parseCitation } from './ChatParserCite'; // Think 블록 표시 여부를 제어하는 상수 (환경변수에서 가져옴) const showThinkBlock = APP_CONFIG.SHOW_THINK_BLOCK; @@ -42,227 +42,6 @@ interface MessageRendererProps { className?: string; onViewSource?: (sourceInfo: SourceInfo) => void; } -const preprocessJsonString = (jsonString: string): string => { - console.log('🔍 [preprocessJsonString] Input:', jsonString); - - // 문자열 필드와 숫자 필드를 올바르게 처리 - let processed = jsonString; - - // 이중 중괄호 {{}} 를 단일 중괄호 {} 로 변경 - processed = processed.replace(/\{\{/g, '{').replace(/\}\}/g, '}'); - // }}}] 같은 패턴을 }}] 로 정리 - processed = processed.replace(/\}\}\}/g, '}}'); - console.log('🔍 [preprocessJsonString] After brace fix:', processed); - - // 문자열 필드에서 중복된 따옴표 제거 - processed = processed.replace(/"""([^"]*?)"/g, '"$1"'); // 3개 따옴표 -> 1개 - processed = processed.replace(/""([^"]*?)"/g, '"$1"'); // 2개 따옴표 -> 1개 - console.log('🔍 [preprocessJsonString] After quote dedup:', processed); - - // 숫자 필드들에 대해 따옴표가 있으면 제거하고, 없으면 그대로 유지 - const numericFields = ['page_number', 'line_start', 'line_end']; - - numericFields.forEach(field => { - // "field": "숫자" 형태를 "field": 숫자 로 변경 - const quotedNumberPattern = new RegExp(`"${field}"\\s*:\\s*"(\\d+)"`, 'g'); - processed = processed.replace(quotedNumberPattern, `"${field}": $1`); - - // "field": 숫자" 형태 (끝에 쌍따옴표가 남은 경우) 를 "field": 숫자 로 변경 - const malformedNumberPattern = new RegExp(`"${field}"\\s*:\\s*(\\d+)"`, 'g'); - processed = processed.replace(malformedNumberPattern, `"${field}": $1`); - }); - console.log('🔍 [preprocessJsonString] After numeric fix:', processed); - - console.log('🔍 [preprocessJsonString] Final output:', processed); - - return processed; -}; - -/** - * Citation 정보를 파싱하는 함수 - */ -const parseCitation = (citationText: string): SourceInfo | null => { - console.log('🔍 [parseCitation] Raw citation text:', JSON.stringify(citationText)); - console.log('🔍 [parseCitation] Citation text length:', citationText.length); - console.log('🔍 [parseCitation] Contains {{:', citationText.includes('{{')); - console.log('🔍 [parseCitation] Contains }}:', citationText.includes('}}')); - - try { - // 단계별로 다양한 패턴 시도 - let jsonString = ''; - - // 먼저 균형잡힌 중괄호 찾기 (단일 또는 이중) - const findBalancedBraces = (text: string, startPattern: string): string | null => { - const startIdx = text.indexOf(startPattern); - if (startIdx === -1) return null; - - let braceCount = 0; - let endIdx = -1; - let inString = false; - let escaped = false; - - for (let i = startIdx; i < text.length; i++) { - const char = text[i]; - - if (escaped) { - escaped = false; - continue; - } - - if (char === '\\') { - escaped = true; - continue; - } - - if (char === '"' && !escaped) { - inString = !inString; - continue; - } - - if (!inString) { - if (char === '{') { - braceCount++; - } else if (char === '}') { - braceCount--; - if (braceCount === 0) { - endIdx = i + 1; - break; - } - } - } - } - - return endIdx !== -1 ? text.slice(startIdx, endIdx) : null; - }; - - // 1. 이중 중괄호 패턴 시도 - const doubleBraceResult = findBalancedBraces(citationText, '{{'); - if (doubleBraceResult) { - jsonString = doubleBraceResult; - } else { - // 2. 단일 중괄호 패턴 시도 - const singleBraceResult = findBalancedBraces(citationText, '{'); - if (singleBraceResult) { - jsonString = singleBraceResult; - } - } - - if (!jsonString) { - return null; - } - - // JSON 문자열 정리 - jsonString = jsonString.trim(); - - // 이스케이프 처리를 더 신중하게 수행 - // 우선 임시 플레이스홀더로 변환하여 다른 처리와 충돌 방지 - const ESCAPED_QUOTE_PLACEHOLDER = '__ESCAPED_QUOTE__'; - const ESCAPED_NEWLINE_PLACEHOLDER = '__ESCAPED_NEWLINE__'; - const ESCAPED_TAB_PLACEHOLDER = '__ESCAPED_TAB__'; - const ESCAPED_RETURN_PLACEHOLDER = '__ESCAPED_RETURN__'; - - jsonString = jsonString.replace(/\\"/g, ESCAPED_QUOTE_PLACEHOLDER); - jsonString = jsonString.replace(/\\n/g, ESCAPED_NEWLINE_PLACEHOLDER); - jsonString = jsonString.replace(/\\t/g, ESCAPED_TAB_PLACEHOLDER); - jsonString = jsonString.replace(/\\r/g, ESCAPED_RETURN_PLACEHOLDER); - jsonString = jsonString.replace(/\\+/g, '\\'); - - // 플레이스홀더를 실제 값으로 복원 - \" 를 " 로 변환 - jsonString = jsonString.replace(new RegExp(ESCAPED_QUOTE_PLACEHOLDER, 'g'), '"'); - jsonString = jsonString.replace(new RegExp(ESCAPED_NEWLINE_PLACEHOLDER, 'g'), '\n'); - jsonString = jsonString.replace(new RegExp(ESCAPED_TAB_PLACEHOLDER, 'g'), '\t'); - jsonString = jsonString.replace(new RegExp(ESCAPED_RETURN_PLACEHOLDER, 'g'), '\r'); - - // JSON 문자열 전처리 - 데이터 타입 정규화 - jsonString = preprocessJsonString(jsonString); - console.log('🔍 [parseCitation] After preprocessing:', jsonString); - - // 한국어가 포함된 경우를 위한 UTF-8 처리 - try { - const sourceInfo = JSON.parse(jsonString); - - devLog.log('✅ [parseCitation] JSON parsed successfully:', sourceInfo); - - // 필수 필드 확인 - if (!sourceInfo.file_name && !sourceInfo.filename && !sourceInfo.fileName && - !sourceInfo.file_path && !sourceInfo.filepath && !sourceInfo.filePath) { - devLog.warn('Missing required fields in citation:', sourceInfo); - return null; - } - - const result = { - file_name: sourceInfo.file_name || sourceInfo.filename || sourceInfo.fileName || '', - file_path: sourceInfo.file_path || sourceInfo.filepath || sourceInfo.filePath || '', - page_number: sourceInfo.page_number || sourceInfo.pagenumber || sourceInfo.pageNumber || 1, - line_start: sourceInfo.line_start || sourceInfo.linestart || sourceInfo.lineStart || 1, - line_end: sourceInfo.line_end || sourceInfo.lineend || sourceInfo.lineEnd || 1 - }; - - console.log('✅ [parseCitation] Final result:', result); - - return result; - } catch (parseError) { - console.error('JSON.parse failed, trying manual parsing...'); - - - // 수동 파싱 시도 - const manualParsed = tryManualParsing(jsonString); - if (manualParsed) { - return manualParsed; - } - - throw parseError; - } - - } catch (error) { - return null; - } -}; - -/** - * 수동으로 JSON 파싱을 시도하는 헬퍼 함수 - */ -const tryManualParsing = (jsonString: string): SourceInfo | null => { - try { - // 기본적인 JSON 형태인지 확인 - if (!jsonString.startsWith('{') || !jsonString.endsWith('}')) { - return null; - } - - const result: Partial = {}; - - // 각 필드를 개별적으로 추출 - const fileNameMatch = jsonString.match(/"(?:file_name|filename|fileName)"\s*:\s*"([^"]+)"/); - if (fileNameMatch) result.file_name = fileNameMatch[1]; - - const filePathMatch = jsonString.match(/"(?:file_path|filepath|filePath)"\s*:\s*"([^"]+)"/); - if (filePathMatch) result.file_path = filePathMatch[1]; - - const pageNumberMatch = jsonString.match(/"(?:page_number|pagenumber|pageNumber)"\s*:\s*(\d+)/); - if (pageNumberMatch) result.page_number = parseInt(pageNumberMatch[1]); - - const lineStartMatch = jsonString.match(/"(?:line_start|linestart|lineStart)"\s*:\s*(\d+)/); - if (lineStartMatch) result.line_start = parseInt(lineStartMatch[1]); - - const lineEndMatch = jsonString.match(/"(?:line_end|lineend|lineEnd)"\s*:\s*(\d+)/); - if (lineEndMatch) result.line_end = parseInt(lineEndMatch[1]); - - // 최소한 file_name이나 file_path가 있어야 함 - if (result.file_name || result.file_path) { - return { - file_name: result.file_name || '', - file_path: result.file_path || '', - page_number: result.page_number || 1, - line_start: result.line_start || 1, - line_end: result.line_end || 1 - }; - } - - return null; - } catch (error) { - return null; - } -}; /** * 마크다운 메시지 렌더러 컴포넌트 diff --git a/src/app/_common/components/ChatParserCite.tsx b/src/app/_common/components/ChatParserCite.tsx new file mode 100644 index 00000000..647b179d --- /dev/null +++ b/src/app/_common/components/ChatParserCite.tsx @@ -0,0 +1,217 @@ +import { SourceInfo } from "@/app/chat/types/source"; +import { devLog } from "../utils/logger"; + +/** + * Citation 정보를 파싱하는 함수 + */ +export const parseCitation = (citationText: string): SourceInfo | null => { + + try { + // 단계별로 다양한 패턴 시도 + let jsonString = ''; + + // 먼저 균형잡힌 중괄호 찾기 (단일 또는 이중) + const findBalancedBraces = (text: string, startPattern: string): string | null => { + const startIdx = text.indexOf(startPattern); + if (startIdx === -1) return null; + + let braceCount = 0; + let endIdx = -1; + let inString = false; + let escaped = false; + + for (let i = startIdx; i < text.length; i++) { + const char = text[i]; + + if (escaped) { + escaped = false; + continue; + } + + if (char === '\\') { + escaped = true; + continue; + } + + if (char === '"' && !escaped) { + inString = !inString; + continue; + } + + if (!inString) { + if (char === '{') { + braceCount++; + } else if (char === '}') { + braceCount--; + if (braceCount === 0) { + endIdx = i + 1; + break; + } + } + } + } + + return endIdx !== -1 ? text.slice(startIdx, endIdx) : null; + }; + + // 1. 이중 중괄호 패턴 시도 + const doubleBraceResult = findBalancedBraces(citationText, '{{'); + if (doubleBraceResult) { + jsonString = doubleBraceResult; + } else { + // 2. 단일 중괄호 패턴 시도 + const singleBraceResult = findBalancedBraces(citationText, '{'); + if (singleBraceResult) { + jsonString = singleBraceResult; + } + } + + if (!jsonString) { + return null; + } + + // JSON 문자열 정리 + jsonString = jsonString.trim(); + + // 이스케이프 처리를 더 신중하게 수행 + // 우선 임시 플레이스홀더로 변환하여 다른 처리와 충돌 방지 + const ESCAPED_QUOTE_PLACEHOLDER = '__ESCAPED_QUOTE__'; + const ESCAPED_NEWLINE_PLACEHOLDER = '__ESCAPED_NEWLINE__'; + const ESCAPED_TAB_PLACEHOLDER = '__ESCAPED_TAB__'; + const ESCAPED_RETURN_PLACEHOLDER = '__ESCAPED_RETURN__'; + + jsonString = jsonString.replace(/\\"/g, ESCAPED_QUOTE_PLACEHOLDER); + jsonString = jsonString.replace(/\\n/g, ESCAPED_NEWLINE_PLACEHOLDER); + jsonString = jsonString.replace(/\\t/g, ESCAPED_TAB_PLACEHOLDER); + jsonString = jsonString.replace(/\\r/g, ESCAPED_RETURN_PLACEHOLDER); + jsonString = jsonString.replace(/\\+/g, '\\'); + + // 플레이스홀더를 실제 값으로 복원 - \" 를 " 로 변환 + jsonString = jsonString.replace(new RegExp(ESCAPED_QUOTE_PLACEHOLDER, 'g'), '"'); + jsonString = jsonString.replace(new RegExp(ESCAPED_NEWLINE_PLACEHOLDER, 'g'), '\n'); + jsonString = jsonString.replace(new RegExp(ESCAPED_TAB_PLACEHOLDER, 'g'), '\t'); + jsonString = jsonString.replace(new RegExp(ESCAPED_RETURN_PLACEHOLDER, 'g'), '\r'); + + // JSON 문자열 전처리 - 데이터 타입 정규화 + jsonString = preprocessJsonString(jsonString); + + // 한국어가 포함된 경우를 위한 UTF-8 처리 + try { + const sourceInfo = JSON.parse(jsonString); + + devLog.log('✅ [parseCitation] JSON parsed successfully:', sourceInfo); + + // 필수 필드 확인 + if (!sourceInfo.file_name && !sourceInfo.filename && !sourceInfo.fileName && + !sourceInfo.file_path && !sourceInfo.filepath && !sourceInfo.filePath) { + devLog.warn('Missing required fields in citation:', sourceInfo); + return null; + } + + const result = { + file_name: sourceInfo.file_name || sourceInfo.filename || sourceInfo.fileName || '', + file_path: sourceInfo.file_path || sourceInfo.filepath || sourceInfo.filePath || '', + page_number: sourceInfo.page_number || sourceInfo.pagenumber || sourceInfo.pageNumber || 1, + line_start: sourceInfo.line_start || sourceInfo.linestart || sourceInfo.lineStart || 1, + line_end: sourceInfo.line_end || sourceInfo.lineend || sourceInfo.lineEnd || 1 + }; + + + return result; + } catch (parseError) { + console.error('JSON.parse failed, trying manual parsing...'); + + // 수동 파싱 시도 + const manualParsed = tryManualParsing(jsonString); + if (manualParsed) { + return manualParsed; + } + + throw parseError; + } + + } catch (error) { + return null; + } +}; + +/** + * 수동으로 JSON 파싱을 시도하는 헬퍼 함수 + */ +const tryManualParsing = (jsonString: string): SourceInfo | null => { + try { + // 기본적인 JSON 형태인지 확인 + if (!jsonString.startsWith('{') || !jsonString.endsWith('}')) { + return null; + } + + const result: Partial = {}; + + // 각 필드를 개별적으로 추출 + const fileNameMatch = jsonString.match(/"(?:file_name|filename|fileName)"\s*:\s*"([^"]+)"/); + if (fileNameMatch) result.file_name = fileNameMatch[1]; + + const filePathMatch = jsonString.match(/"(?:file_path|filepath|filePath)"\s*:\s*"([^"]+)"/); + if (filePathMatch) result.file_path = filePathMatch[1]; + + const pageNumberMatch = jsonString.match(/"(?:page_number|pagenumber|pageNumber)"\s*:\s*(\d+)/); + if (pageNumberMatch) result.page_number = parseInt(pageNumberMatch[1]); + + const lineStartMatch = jsonString.match(/"(?:line_start|linestart|lineStart)"\s*:\s*(\d+)/); + if (lineStartMatch) result.line_start = parseInt(lineStartMatch[1]); + + const lineEndMatch = jsonString.match(/"(?:line_end|lineend|lineEnd)"\s*:\s*(\d+)/); + if (lineEndMatch) result.line_end = parseInt(lineEndMatch[1]); + + // 최소한 file_name이나 file_path가 있어야 함 + if (result.file_name || result.file_path) { + return { + file_name: result.file_name || '', + file_path: result.file_path || '', + page_number: result.page_number || 1, + line_start: result.line_start || 1, + line_end: result.line_end || 1 + }; + } + + return null; + } catch (error) { + return null; + } +}; + +const preprocessJsonString = (jsonString: string): string => { + console.log('🔍 [preprocessJsonString] Input:', jsonString); + + // 문자열 필드와 숫자 필드를 올바르게 처리 + let processed = jsonString; + + // 이중 중괄호 {{}} 를 단일 중괄호 {} 로 변경 + processed = processed.replace(/\{\{/g, '{').replace(/\}\}/g, '}'); + // }}}] 같은 패턴을 }}] 로 정리 + processed = processed.replace(/\}\}\}/g, '}}'); + console.log('🔍 [preprocessJsonString] After brace fix:', processed); + + // 문자열 필드에서 중복된 따옴표 제거 + processed = processed.replace(/"""([^"]*?)"/g, '"$1"'); // 3개 따옴표 -> 1개 + processed = processed.replace(/""([^"]*?)"/g, '"$1"'); // 2개 따옴표 -> 1개 + console.log('🔍 [preprocessJsonString] After quote dedup:', processed); + + // 숫자 필드들에 대해 따옴표가 있으면 제거하고, 없으면 그대로 유지 + const numericFields = ['page_number', 'line_start', 'line_end']; + + numericFields.forEach(field => { + // "field": "숫자" 형태를 "field": 숫자 로 변경 + const quotedNumberPattern = new RegExp(`"${field}"\\s*:\\s*"(\\d+)"`, 'g'); + processed = processed.replace(quotedNumberPattern, `"${field}": $1`); + + // "field": 숫자" 형태 (끝에 쌍따옴표가 남은 경우) 를 "field": 숫자 로 변경 + const malformedNumberPattern = new RegExp(`"${field}"\\s*:\\s*(\\d+)"`, 'g'); + processed = processed.replace(malformedNumberPattern, `"${field}": $1`); + }); + console.log('🔍 [preprocessJsonString] After numeric fix:', processed); + + console.log('🔍 [preprocessJsonString] Final output:', processed); + + return processed; +}; \ No newline at end of file From 0fe3fe3d03ff6eb27f642bd4af14495b824df5e1 Mon Sep 17 00:00:00 2001 From: haesookim Date: Tue, 26 Aug 2025 16:27:51 +0900 Subject: [PATCH 07/14] feat: Add inline citation parsing with LaTeX and markdown support - Introduce processInlineMarkdownWithCitations to handle text containing citations, LaTeX, and markdown in a unified way. - Implement robust citation detection with balanced brace parsing and escape character handling. - Support partial citation placeholders during streaming rendering. - Render citations as interactive SourceButton components when source info is available. - Refactor ChatParserMarkdown to delegate citation processing to the new module, removing duplicated logic. - Clean up debug logs and improve preprocessing of malformed citation JSON. --- src/app/_common/components/ChatParserCite.tsx | 311 ++++++++++++++++- .../_common/components/ChatParserMarkdown.tsx | 317 +----------------- 2 files changed, 308 insertions(+), 320 deletions(-) diff --git a/src/app/_common/components/ChatParserCite.tsx b/src/app/_common/components/ChatParserCite.tsx index 647b179d..78c6768b 100644 --- a/src/app/_common/components/ChatParserCite.tsx +++ b/src/app/_common/components/ChatParserCite.tsx @@ -1,5 +1,311 @@ import { SourceInfo } from "@/app/chat/types/source"; import { devLog } from "../utils/logger"; +import { hasLatex, processLatexInText } from "./ChatParserLatex"; +import { processInlineMarkdown } from "./ChatParserMarkdown"; +import SourceButton from "@/app/chat/components/SourceButton"; +import sourceStyles from '@/app/chat/assets/SourceButton.module.scss'; + +/** + * Citation Placeholder 컴포넌트 - 스트리밍 중 부분적인 citation 표시 + */ +export const CitationPlaceholder: React.FC = () => { + return ( + + 📑 출처 정보 로딩 중... + + ); +}; + +/** + * Citation과 LaTeX를 포함한 텍스트 처리 - LaTeX, Citation, 마크다운 순서로 처리 + */ +export const processInlineMarkdownWithCitations = ( + text: string, + key: string, + onViewSource?: (sourceInfo: SourceInfo) => void, + parseCitation?: (citationText: string) => SourceInfo | null, + isStreaming: boolean = false +): React.ReactNode[] => { + const elements: React.ReactNode[] = []; + + // 1. LaTeX와 Citation 모두 체크하여 적절히 처리 + const hasLatexContent = hasLatex(text); + + // LaTeX만 있고 Citation이 없는 경우에만 LaTeX 처리로 바로 넘김 + if (hasLatexContent && !text.includes('[Cite.')) { + return processLatexInText(text, key, isStreaming); + } + + // 2. parseCitation이 없으면 Citation 처리 없이 처리 + if (!parseCitation) { + if (hasLatexContent) { + return processLatexInText(text, key, isStreaming); + } else { + const processedText = processInlineMarkdown(text); + return [
]; + } + } + + // Citation을 찾기 위한 더 안전한 접근법 - 단순화 + const findCitations = (inputText: string): Array<{ start: number, end: number, content: string }> => { + console.log('🔍 [findCitations] Input text:', JSON.stringify(inputText)); + + // LaTeX가 포함된 텍스트에서는 Citation 전처리를 최소화 + let preprocessedText = inputText; + + // LaTeX 영역이 아닌 곳에서만 전처리 수행 + if (!hasLatex(inputText)) { + // 이중 중괄호를 단일 중괄호로 변환 + preprocessedText = preprocessedText.replace(/\{\{/g, '{').replace(/\}\}/g, '}'); + // }}}] 같은 패턴을 }}] 로 정리 + preprocessedText = preprocessedText.replace(/\}\}\}/g, '}}'); + // 숫자 필드 뒤의 잘못된 따옴표 제거 + preprocessedText = preprocessedText.replace(/(\d)"\s*([,}])/g, '$1$2'); + // 문자열 필드에서 중복 따옴표 정리 + preprocessedText = preprocessedText.replace(/"""([^"]*?)"/g, '"$1"'); // 3개 따옴표 -> 1개 + preprocessedText = preprocessedText.replace(/""([^"]*?)"/g, '"$1"'); // 2개 따옴표 -> 1개 + } + + const citations: Array<{ start: number, end: number, content: string }> = []; + let i = 0; + + while (i < preprocessedText.length) { + // [Cite. 패턴 찾기 + const citeStart = preprocessedText.indexOf('[Cite.', i); + if (citeStart === -1) break; + + // { 또는 {{ 찾기 + let braceStart = -1; + for (let j = citeStart + 6; j < preprocessedText.length; j++) { + if (preprocessedText[j] === '{') { + braceStart = j; + break; + } else if (preprocessedText[j] !== ' ' && preprocessedText[j] !== '\t') { + // 공백이 아닌 다른 문자가 나오면 유효하지 않은 citation + break; + } + } + + if (braceStart === -1) { + i = citeStart + 6; + continue; + } + + // 균형잡힌 괄호 찾기 - 이스케이프 문자 처리 개선 + let braceCount = 1; + let braceEnd = -1; + let inString = false; + let escaped = false; + + for (let j = braceStart + 1; j < preprocessedText.length; j++) { + const char = preprocessedText[j]; + + // 이전 문자가 백슬래시인 경우 현재 문자는 이스케이프됨 + if (escaped) { + escaped = false; + continue; + } + + // 백슬래시 처리 - 다음 문자를 이스케이프 + if (char === '\\') { + escaped = true; + continue; + } + + // 따옴표 처리 - 문자열 상태 토글 (전처리로 인해 더 간단해짐) + if (char === '"' && !escaped) { + inString = !inString; + continue; + } + + // 문자열 내부가 아닐 때만 중괄호 카운팅 + if (!inString) { + if (char === '{') { + braceCount++; + } else if (char === '}') { + braceCount--; + if (braceCount === 0) { + braceEnd = j; + break; + } + } + } + } + + if (braceEnd !== -1) { + // 닫는 ] 찾기 (선택적) - 백슬래시는 텍스트 끝까지 포함 + let finalEnd = braceEnd + 1; + while (finalEnd < preprocessedText.length && + (preprocessedText[finalEnd] === ' ' || preprocessedText[finalEnd] === '\t' || + preprocessedText[finalEnd] === ']' || preprocessedText[finalEnd] === '.' || + preprocessedText[finalEnd] === '\\')) { + if (preprocessedText[finalEnd] === ']') { + finalEnd++; + break; + } + finalEnd++; + } + + // 텍스트 끝에 백슬래시가 있는 경우 포함 + if (finalEnd === preprocessedText.length && preprocessedText.endsWith('\\')) { + // 백슬래시까지 포함 + } + + const citationContent = preprocessedText.slice(citeStart, finalEnd); + + citations.push({ + start: citeStart, + end: finalEnd, + content: citationContent + }); + + i = finalEnd; + } else { + i = citeStart + 6; + } + } + + return citations; + }; + + // 1. Citation 우선 처리 - 마크다운 파싱보다 먼저 수행 + const citations = findCitations(text); + + if (citations.length === 0) { + // Citation이 없는 경우 부분적인 citation 확인 + const partialCitationRegex = /\[Cite\.(?:\s*\{[^}]*)?$/; + const partialMatch = partialCitationRegex.exec(text); + + if (partialMatch) { + // 부분적인 citation 이전 텍스트 처리 - LaTeX 먼저 확인 후 마크다운 파싱 적용 + const beforeText = text.slice(0, partialMatch.index); + if (beforeText) { + if (hasLatex(beforeText)) { + const latexElements = processLatexInText(beforeText, `${key}-text-before`, isStreaming); + elements.push(...latexElements); + } else { + const processedText = processInlineMarkdown(beforeText); + elements.push( + + ); + } + } + + // 부분적인 citation placeholder 추가 + elements.push( + + ); + + return [
{elements}
]; + } else { + // Citation이 전혀 없는 경우 LaTeX 먼저 확인 후 마크다운 파싱 적용 + if (hasLatexContent) { + return processLatexInText(text, key, isStreaming); + } else { + const processedText = processInlineMarkdown(text); + return [
]; + } + } + } + + // 2. Citation이 있는 경우 Citation과 텍스트를 분할하여 처리 + let currentIndex = 0; + + for (let i = 0; i < citations.length; i++) { + const citation = citations[i]; + + // Citation 이전 텍스트 처리 - LaTeX 먼저 확인 후 마크다운 파싱 적용 + if (citation.start > currentIndex) { + const beforeText = text.slice(currentIndex, citation.start); + if (beforeText.trim()) { + if (hasLatex(beforeText)) { + const latexElements = processLatexInText(beforeText, `${key}-text-${i}`, isStreaming); + elements.push(...latexElements); + } else { + const processedText = processInlineMarkdown(beforeText); + elements.push( + + ); + } + } + } + + // Citation 처리 - 버튼으로 변환 (마크다운 파싱 제외) + // Cite.로 시작하면 이스케이프 문자 변환: \" → " + let processedCitationContent = citation.content; + if (citation.content.trim().startsWith('Cite.')) { + processedCitationContent = citation.content.replace(/\\"/g, '"'); + } + const sourceInfo = parseCitation(processedCitationContent); + + + if (sourceInfo && onViewSource) { + elements.push( + + ); + } else { + + elements.push( + + {processedCitationContent} + + ); + } + + // Citation 처리 후 trailing 문자들 건너뛰기 + let nextIndex = citation.end; + + // Citation 뒤에 남은 불완전한 JSON 구문이나 특수 문자들 정리 + // }], \, 공백, 숫자, 콤마, 세미콜론 등 Citation 관련 잔여물 제거 + while (nextIndex < text.length) { + const char = text[nextIndex]; + + // Citation 관련 잔여 문자들: }, ], \, 공백, 숫자, 특수문자 + if (/[}\]\\.\s,;:]/.test(char) || /\d/.test(char)) { + nextIndex++; + } else { + // 일반 텍스트 문자가 나오면 정리 중단 + break; + } + } + + currentIndex = nextIndex; + } + + // 남은 텍스트 처리 - LaTeX 먼저 확인 후 마크다운 파싱 적용 + if (currentIndex < text.length) { + const remainingText = text.slice(currentIndex); + if (remainingText.trim()) { + if (hasLatex(remainingText)) { + const latexElements = processLatexInText(remainingText, `${key}-text-remaining`, isStreaming); + elements.push(...latexElements); + } else { + const processedText = processInlineMarkdown(remainingText); + elements.push( + + ); + } + } + } + + // Citation이 있는 경우 div로 감싸기 + return [
{elements}
]; +}; /** * Citation 정보를 파싱하는 함수 @@ -119,7 +425,6 @@ export const parseCitation = (citationText: string): SourceInfo | null => { return result; } catch (parseError) { - console.error('JSON.parse failed, trying manual parsing...'); // 수동 파싱 시도 const manualParsed = tryManualParsing(jsonString); @@ -195,7 +500,6 @@ const preprocessJsonString = (jsonString: string): string => { // 문자열 필드에서 중복된 따옴표 제거 processed = processed.replace(/"""([^"]*?)"/g, '"$1"'); // 3개 따옴표 -> 1개 processed = processed.replace(/""([^"]*?)"/g, '"$1"'); // 2개 따옴표 -> 1개 - console.log('🔍 [preprocessJsonString] After quote dedup:', processed); // 숫자 필드들에 대해 따옴표가 있으면 제거하고, 없으면 그대로 유지 const numericFields = ['page_number', 'line_start', 'line_end']; @@ -209,9 +513,6 @@ const preprocessJsonString = (jsonString: string): string => { const malformedNumberPattern = new RegExp(`"${field}"\\s*:\\s*(\\d+)"`, 'g'); processed = processed.replace(malformedNumberPattern, `"${field}": $1`); }); - console.log('🔍 [preprocessJsonString] After numeric fix:', processed); - - console.log('🔍 [preprocessJsonString] Final output:', processed); return processed; }; \ No newline at end of file diff --git a/src/app/_common/components/ChatParserMarkdown.tsx b/src/app/_common/components/ChatParserMarkdown.tsx index 762d3cf0..24959c42 100644 --- a/src/app/_common/components/ChatParserMarkdown.tsx +++ b/src/app/_common/components/ChatParserMarkdown.tsx @@ -1,32 +1,9 @@ 'use client'; import React from 'react'; -import SourceButton from '@/app/chat/components/SourceButton'; import { SourceInfo } from '@/app/chat/types/source'; -import sourceStyles from '@/app/chat/assets/SourceButton.module.scss'; -import { devLog } from '@/app/_common/utils/logger'; -import { processLatexInText, hasLatex } from './ChatParserLatex'; - -/** - * Citation Placeholder 컴포넌트 - 스트리밍 중 부분적인 citation 표시 - */ -export const CitationPlaceholder: React.FC = () => { - return ( - - 📑 출처 정보 로딩 중... - - ); -}; +import { hasLatex } from './ChatParserLatex'; +import { processInlineMarkdownWithCitations } from './ChatParserCite'; /** * 테이블 구분자 라인인지 확인하는 헬퍼 함수 @@ -96,296 +73,6 @@ export const processInlineMarkdown = (text: string): string => { return processed; }; -/** - * Citation과 LaTeX를 포함한 텍스트 처리 - LaTeX, Citation, 마크다운 순서로 처리 - */ -export const processInlineMarkdownWithCitations = ( - text: string, - key: string, - onViewSource?: (sourceInfo: SourceInfo) => void, - parseCitation?: (citationText: string) => SourceInfo | null, - isStreaming: boolean = false -): React.ReactNode[] => { - const elements: React.ReactNode[] = []; - - // 1. LaTeX와 Citation 모두 체크하여 적절히 처리 - const hasLatexContent = hasLatex(text); - - // LaTeX만 있고 Citation이 없는 경우에만 LaTeX 처리로 바로 넘김 - if (hasLatexContent && !text.includes('[Cite.')) { - return processLatexInText(text, key, isStreaming); - } - - // Citation만 있고 LaTeX가 없는 경우는 기존 로직 사용 - // LaTeX와 Citation이 모두 있는 경우는 혼합 처리 (아래에서 구현) - - // 2. parseCitation이 없으면 Citation 처리 없이 처리 - if (!parseCitation) { - if (hasLatexContent) { - return processLatexInText(text, key, isStreaming); - } else { - const processedText = processInlineMarkdown(text); - return [
]; - } - } - - // Citation을 찾기 위한 더 안전한 접근법 - 단순화 - const findCitations = (inputText: string): Array<{ start: number, end: number, content: string }> => { - console.log('🔍 [findCitations] Input text:', JSON.stringify(inputText)); - - // LaTeX가 포함된 텍스트에서는 Citation 전처리를 최소화 - let preprocessedText = inputText; - - // LaTeX 영역이 아닌 곳에서만 전처리 수행 - if (!hasLatex(inputText)) { - // 이중 중괄호를 단일 중괄호로 변환 - preprocessedText = preprocessedText.replace(/\{\{/g, '{').replace(/\}\}/g, '}'); - // }}}] 같은 패턴을 }}] 로 정리 - preprocessedText = preprocessedText.replace(/\}\}\}/g, '}}'); - // 숫자 필드 뒤의 잘못된 따옴표 제거 - preprocessedText = preprocessedText.replace(/(\d)"\s*([,}])/g, '$1$2'); - // 문자열 필드에서 중복 따옴표 정리 - preprocessedText = preprocessedText.replace(/"""([^"]*?)"/g, '"$1"'); // 3개 따옴표 -> 1개 - preprocessedText = preprocessedText.replace(/""([^"]*?)"/g, '"$1"'); // 2개 따옴표 -> 1개 - } - - console.log('🔍 [findCitations] After basic preprocessing:', preprocessedText); - - const citations: Array<{ start: number, end: number, content: string }> = []; - let i = 0; - - while (i < preprocessedText.length) { - // [Cite. 패턴 찾기 - const citeStart = preprocessedText.indexOf('[Cite.', i); - if (citeStart === -1) break; - - // { 또는 {{ 찾기 - let braceStart = -1; - for (let j = citeStart + 6; j < preprocessedText.length; j++) { - if (preprocessedText[j] === '{') { - braceStart = j; - break; - } else if (preprocessedText[j] !== ' ' && preprocessedText[j] !== '\t') { - // 공백이 아닌 다른 문자가 나오면 유효하지 않은 citation - break; - } - } - - console.log('🔍 [findCitations] Brace start found at:', braceStart); - - if (braceStart === -1) { - i = citeStart + 6; - continue; - } - - // 균형잡힌 괄호 찾기 - 이스케이프 문자 처리 개선 - let braceCount = 1; - let braceEnd = -1; - let inString = false; - let escaped = false; - - for (let j = braceStart + 1; j < preprocessedText.length; j++) { - const char = preprocessedText[j]; - - // 이전 문자가 백슬래시인 경우 현재 문자는 이스케이프됨 - if (escaped) { - escaped = false; - continue; - } - - // 백슬래시 처리 - 다음 문자를 이스케이프 - if (char === '\\') { - escaped = true; - continue; - } - - // 따옴표 처리 - 문자열 상태 토글 (전처리로 인해 더 간단해짐) - if (char === '"' && !escaped) { - inString = !inString; - continue; - } - - // 문자열 내부가 아닐 때만 중괄호 카운팅 - if (!inString) { - if (char === '{') { - braceCount++; - } else if (char === '}') { - braceCount--; - if (braceCount === 0) { - braceEnd = j; - break; - } - } - } - } - - if (braceEnd !== -1) { - // 닫는 ] 찾기 (선택적) - 백슬래시는 텍스트 끝까지 포함 - let finalEnd = braceEnd + 1; - while (finalEnd < preprocessedText.length && - (preprocessedText[finalEnd] === ' ' || preprocessedText[finalEnd] === '\t' || - preprocessedText[finalEnd] === ']' || preprocessedText[finalEnd] === '.' || - preprocessedText[finalEnd] === '\\')) { - if (preprocessedText[finalEnd] === ']') { - finalEnd++; - break; - } - finalEnd++; - } - - // 텍스트 끝에 백슬래시가 있는 경우 포함 - if (finalEnd === preprocessedText.length && preprocessedText.endsWith('\\')) { - // 백슬래시까지 포함 - } - - const citationContent = preprocessedText.slice(citeStart, finalEnd); - - citations.push({ - start: citeStart, - end: finalEnd, - content: citationContent - }); - - i = finalEnd; - } else { - i = citeStart + 6; - } - } - - return citations; - }; - - // 1. Citation 우선 처리 - 마크다운 파싱보다 먼저 수행 - const citations = findCitations(text); - - if (citations.length === 0) { - // Citation이 없는 경우 부분적인 citation 확인 - const partialCitationRegex = /\[Cite\.(?:\s*\{[^}]*)?$/; - const partialMatch = partialCitationRegex.exec(text); - - if (partialMatch) { - // 부분적인 citation 이전 텍스트 처리 - LaTeX 먼저 확인 후 마크다운 파싱 적용 - const beforeText = text.slice(0, partialMatch.index); - if (beforeText) { - if (hasLatex(beforeText)) { - const latexElements = processLatexInText(beforeText, `${key}-text-before`, isStreaming); - elements.push(...latexElements); - } else { - const processedText = processInlineMarkdown(beforeText); - elements.push( - - ); - } - } - - // 부분적인 citation placeholder 추가 - elements.push( - - ); - - return [
{elements}
]; - } else { - // Citation이 전혀 없는 경우 LaTeX 먼저 확인 후 마크다운 파싱 적용 - if (hasLatexContent) { - return processLatexInText(text, key, isStreaming); - } else { - const processedText = processInlineMarkdown(text); - return [
]; - } - } - } - - // 2. Citation이 있는 경우 Citation과 텍스트를 분할하여 처리 - let currentIndex = 0; - - for (let i = 0; i < citations.length; i++) { - const citation = citations[i]; - - // Citation 이전 텍스트 처리 - LaTeX 먼저 확인 후 마크다운 파싱 적용 - if (citation.start > currentIndex) { - const beforeText = text.slice(currentIndex, citation.start); - if (beforeText.trim()) { - if (hasLatex(beforeText)) { - const latexElements = processLatexInText(beforeText, `${key}-text-${i}`, isStreaming); - elements.push(...latexElements); - } else { - const processedText = processInlineMarkdown(beforeText); - elements.push( - - ); - } - } - } - - // Citation 처리 - 버튼으로 변환 (마크다운 파싱 제외) - // Cite.로 시작하면 이스케이프 문자 변환: \" → " - let processedCitationContent = citation.content; - if (citation.content.trim().startsWith('Cite.')) { - processedCitationContent = citation.content.replace(/\\"/g, '"'); - } - const sourceInfo = parseCitation(processedCitationContent); - - devLog.log('🔍 [processInlineMarkdownWithCitations] Parsed sourceInfo:', sourceInfo); - - if (sourceInfo && onViewSource) { - devLog.log('✅ [processInlineMarkdownWithCitations] Creating SourceButton'); - elements.push( - - ); - } else { - - elements.push( - - {processedCitationContent} - - ); - } - - // Citation 처리 후 trailing 문자들 건너뛰기 - let nextIndex = citation.end; - - // Citation 뒤에 남은 불완전한 JSON 구문이나 특수 문자들 정리 - // }], \, 공백, 숫자, 콤마, 세미콜론 등 Citation 관련 잔여물 제거 - while (nextIndex < text.length) { - const char = text[nextIndex]; - - // Citation 관련 잔여 문자들: }, ], \, 공백, 숫자, 특수문자 - if (/[}\]\\.\s,;:]/.test(char) || /\d/.test(char)) { - nextIndex++; - } else { - // 일반 텍스트 문자가 나오면 정리 중단 - break; - } - } - - currentIndex = nextIndex; - } - - // 남은 텍스트 처리 - LaTeX 먼저 확인 후 마크다운 파싱 적용 - if (currentIndex < text.length) { - const remainingText = text.slice(currentIndex); - if (remainingText.trim()) { - if (hasLatex(remainingText)) { - const latexElements = processLatexInText(remainingText, `${key}-text-remaining`, isStreaming); - elements.push(...latexElements); - } else { - const processedText = processInlineMarkdown(remainingText); - elements.push( - - ); - } - } - } - - // Citation이 있는 경우 div로 감싸기 - return [
{elements}
]; -}; - /** * 간단한 마크다운 파싱 (코드 블록 제외) */ From 62cbc933cebb85518dbe4303d14be17a6e9bcc9e Mon Sep 17 00:00:00 2001 From: haesookim Date: Tue, 26 Aug 2025 16:28:33 +0900 Subject: [PATCH 08/14] refactor: remove unused imports from ChatParser component --- src/app/_common/components/ChatParser.tsx | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/app/_common/components/ChatParser.tsx b/src/app/_common/components/ChatParser.tsx index c6b75a17..95ff075a 100644 --- a/src/app/_common/components/ChatParser.tsx +++ b/src/app/_common/components/ChatParser.tsx @@ -16,12 +16,6 @@ import { type ToolOutputLogInfo } from '@/app/_common/components/ChatParserToolResponse'; import { - CitationPlaceholder, - isSeparatorLine, - cleanupJsonFragments, - getLastLines, - processInlineMarkdown, - processInlineMarkdownWithCitations, parseSimpleMarkdown } from '@/app/_common/components/ChatParserMarkdown'; import { parseCitation } from './ChatParserCite'; From c9fd1490a854f37e7633445a48e073868f91338c Mon Sep 17 00:00:00 2001 From: haesookim Date: Tue, 26 Aug 2025 16:47:08 +0900 Subject: [PATCH 09/14] feat: Add auto-resizing textarea to chat input - Replace single-line input with a textarea that auto-adjusts height - Limit textarea height between 45px and 200px, show scrollbar if exceeded - Hide vertical scrollbar when content fits within max height - Adjust textarea height dynamically on input changes for better UX - Update styles to support scrollable state and consistent line height --- src/app/chat/assets/ChatInterface.module.scss | 9 ++++ src/app/chat/components/ChatInterface.tsx | 46 ++++++++++++++++--- 2 files changed, 48 insertions(+), 7 deletions(-) diff --git a/src/app/chat/assets/ChatInterface.module.scss b/src/app/chat/assets/ChatInterface.module.scss index d32fe819..4b01b514 100644 --- a/src/app/chat/assets/ChatInterface.module.scss +++ b/src/app/chat/assets/ChatInterface.module.scss @@ -542,6 +542,10 @@ outline: none; transition: all 0.2s ease; background: $white; + overflow-y: hidden; // 높이가 자동 조절될 때 스크롤 숨김 + line-height: 1.4; + min-height: 45px; // 폰트 크기에 맞는 최소 높이 (15.2px * 1.4 + 24px padding) + max-height: 200px; &:focus { border-color: $primary-blue; @@ -556,6 +560,11 @@ &::placeholder { color: $gray-400; } + + // 최대 높이에 도달했을 때만 스크롤 표시 + &.scrollable { + overflow-y: auto; + } } .sendButton { diff --git a/src/app/chat/components/ChatInterface.tsx b/src/app/chat/components/ChatInterface.tsx index 9413a74a..42a678fe 100644 --- a/src/app/chat/components/ChatInterface.tsx +++ b/src/app/chat/components/ChatInterface.tsx @@ -84,6 +84,7 @@ const ChatInterface: React.FC = ( const [isUserScrolling, setIsUserScrolling] = useState(false); const [isResizing, setIsResizing] = useState(false); const scrollTimeoutRef = useRef(null); + const textareaRef = useRef(null); // showPDFViewer가 false일 때 패널 크기를 100%로 설정 useEffect(() => { @@ -648,7 +649,35 @@ const ChatInterface: React.FC = ( onStartNewChat(messageToSend); }, [inputMessage, onStartNewChat]); - const handleKeyPress = useCallback((e: React.KeyboardEvent) => { + // Textarea 높이 자동 조절 함수 + const adjustTextareaHeight = useCallback(() => { + const textarea = textareaRef.current; + if (textarea) { + // 먼저 높이를 auto로 설정하여 scrollHeight를 정확히 계산 + textarea.style.height = 'auto'; + + const scrollHeight = textarea.scrollHeight; + const minHeight = 45; // 최소 높이 + const maxHeight = 200; // 최대 높이 + + if (scrollHeight <= maxHeight) { + // 최대 높이보다 작으면 스크롤 숨김하고 내용에 맞게 높이 설정 + textarea.style.height = `${Math.max(scrollHeight, minHeight)}px`; + textarea.classList.remove(styles.scrollable); + } else { + // 최대 높이에 도달하면 스크롤 표시 + textarea.style.height = `${maxHeight}px`; + textarea.classList.add(styles.scrollable); + } + } + }, []); + + // 입력 메시지 변경 시 높이 조절 + useEffect(() => { + adjustTextareaHeight(); + }, [inputMessage, adjustTextareaHeight]); + + const handleKeyPress = useCallback((e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey && !executing) { e.preventDefault(); if (mode === 'new-default' || mode === 'new-workflow') { @@ -661,6 +690,7 @@ const ChatInterface: React.FC = ( executeWorkflow(); } } + // Shift+Enter는 줄바꿈을 허용 (기본 동작) }, [executing, mode, handleStartNewChatFlow, executeWorkflow]); const handleAttachmentClick = () => { @@ -824,14 +854,15 @@ const ChatInterface: React.FC = ( {/* Input Area */}
- setInputMessage(e.target.value)} - onKeyPress={handleKeyPress} + onKeyDown={handleKeyPress} disabled={executing} className={styles.messageInput} + rows={1} />
@@ -1034,14 +1065,15 @@ const ChatInterface: React.FC = ( {/* Input Area */}
- setInputMessage(e.target.value)} - onKeyPress={handleKeyPress} + onKeyDown={handleKeyPress} disabled={executing} className={styles.messageInput} + rows={1} />
From 16433f31c319103b48611e26b94200d0aa95f03c Mon Sep 17 00:00:00 2001 From: haesookim Date: Tue, 26 Aug 2025 17:06:20 +0900 Subject: [PATCH 10/14] feat: Support long initial messages via localStorage in chat - Add URL length check when setting initial_message param to avoid exceeding URL length limits by storing long messages in localStorage with an ID. - Update chat components to retrieve initial messages from localStorage if ID param is present, falling back to direct URL param otherwise. - Automatically execute the initial message after setting it and clean up URL parameters to improve user experience and maintain URL cleanliness. --- src/app/chat/components/ChatContent.tsx | 14 ++++++++++++- src/app/chat/components/ChatInterface.tsx | 6 ++++++ .../chat/components/CurrentChatInterface.tsx | 20 ++++++++++++++++++- 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/src/app/chat/components/ChatContent.tsx b/src/app/chat/components/ChatContent.tsx index a6243339..92abbdc2 100644 --- a/src/app/chat/components/ChatContent.tsx +++ b/src/app/chat/components/ChatContent.tsx @@ -89,7 +89,19 @@ const ChatContentInner: React.FC = ({ onChatStarted}) => { params.set('mode', 'current-chat'); params.set('workflowId', selectedWorkflow.id); params.set('workflowName', normalizeWorkflowName(selectedWorkflow.name)); - params.set('initial_message', message); + + // URL 길이 제한 체크 (일반적으로 2000자 이하 권장) + const maxUrlLength = 1500; // 안전 마진 포함 + const baseUrl = `/chat?mode=current-chat&workflowId=${selectedWorkflow.id}&workflowName=${normalizeWorkflowName(selectedWorkflow.name)}&initial_message=`; + + if (baseUrl.length + encodeURIComponent(message).length > maxUrlLength) { + // 메시지가 너무 길면 localStorage에 저장하고 ID만 전달 + const messageId = `initial_msg_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`; + localStorage.setItem(messageId, message); + params.set('initial_message_id', messageId); + } else { + params.set('initial_message', message); + } router.replace(`/chat?${params.toString()}`); diff --git a/src/app/chat/components/ChatInterface.tsx b/src/app/chat/components/ChatInterface.tsx index 42a678fe..a526e703 100644 --- a/src/app/chat/components/ChatInterface.tsx +++ b/src/app/chat/components/ChatInterface.tsx @@ -500,9 +500,15 @@ const ChatInterface: React.FC = ( hasExecutedInitialMessage.current = true; setInputMessage(initialMessageToExecute); + + // 메시지 설정 후 즉시 실행 + setTimeout(() => { + executeWorkflow(initialMessageToExecute); + }, 0); const newSearchParams = new URLSearchParams(window.location.search); newSearchParams.delete('initial_message'); + newSearchParams.delete('initial_message_id'); router.replace(`${window.location.pathname}?${newSearchParams.toString()}`, { scroll: false }); } }, [initialMessageToExecute, executeWorkflow, router]); diff --git a/src/app/chat/components/CurrentChatInterface.tsx b/src/app/chat/components/CurrentChatInterface.tsx index 3e5d24ea..8f180b1b 100644 --- a/src/app/chat/components/CurrentChatInterface.tsx +++ b/src/app/chat/components/CurrentChatInterface.tsx @@ -16,7 +16,25 @@ const CurrentChatInterface: React.FC = ({ onBack }) = const [loading, setLoading] = useState(true); const searchParams = useSearchParams(); - const initialMessageToExecute = useMemo(() => searchParams.get('initial_message'), [searchParams]); + const initialMessageToExecute = useMemo(() => { + const directMessage = searchParams.get('initial_message'); + if (directMessage) { + return directMessage; + } + + // URL 파라미터에 직접 메시지가 없으면 localStorage에서 찾기 + const messageId = searchParams.get('initial_message_id'); + if (messageId) { + const storedMessage = localStorage.getItem(messageId); + if (storedMessage) { + // 사용 후 정리 + localStorage.removeItem(messageId); + return storedMessage; + } + } + + return null; + }, [searchParams]); useEffect(() => { setLoading(true); From 36c451d942ae9c19d87746185c0fa06d4e61f86d Mon Sep 17 00:00:00 2001 From: CocoRoF Date: Tue, 26 Aug 2025 08:06:36 +0000 Subject: [PATCH 11/14] feat: Add delete user functionality to user management API feat: Add user ID display in user management table feat: Implement chat monitoring section in admin panel feat: Create AdminConfigViewer component for configuration management style: Add styles for AdminConfigViewer component --- src/app/admin/api/users.js | 33 + .../assets/AdminConfigViewer.module.scss | 567 ++++++++++++++ .../admin/assets/AdminUserContent.module.scss | 7 + .../admin/components/AdminConfigViewer.tsx | 693 ++++++++++++++++++ src/app/admin/components/AdminPageContent.tsx | 16 +- src/app/admin/components/AdminUserContent.tsx | 14 +- .../admin/components/adminSidebarConfig.ts | 9 +- 7 files changed, 1334 insertions(+), 5 deletions(-) create mode 100644 src/app/admin/assets/AdminConfigViewer.module.scss create mode 100644 src/app/admin/components/AdminConfigViewer.tsx diff --git a/src/app/admin/api/users.js b/src/app/admin/api/users.js index e274830d..4c229afa 100644 --- a/src/app/admin/api/users.js +++ b/src/app/admin/api/users.js @@ -24,3 +24,36 @@ export const getAllUsers = async () => { throw error; } }; + +/** + * 사용자와 관련된 모든 데이터를 삭제하는 함수 (슈퍼유저 권한 필요) + * @param {Object} userData - 삭제할 사용자 정보 + * @param {number} userData.id - 사용자 ID + * @param {string} userData.username - 사용자명 + * @param {string} userData.email - 사용자 이메일 + * @returns {Promise} 삭제 결과 + */ +export const deleteUser = async (userData) => { + try { + const response = await apiClient(`${API_BASE_URL}/api/admin/user/user-account`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(userData) + }); + + const data = await response.json(); + devLog.log('Delete user result:', data); + + if (!response.ok) { + devLog.error('Failed to delete user:', data); + throw new Error(data.detail || 'Failed to delete user'); + } + + return data; + } catch (error) { + devLog.error('Failed to delete user:', error); + throw error; + } +}; diff --git a/src/app/admin/assets/AdminConfigViewer.module.scss b/src/app/admin/assets/AdminConfigViewer.module.scss new file mode 100644 index 00000000..cdfb5755 --- /dev/null +++ b/src/app/admin/assets/AdminConfigViewer.module.scss @@ -0,0 +1,567 @@ +@use "sass:color"; + +$primary-blue: #2563eb; +$primary-green: #059669; +$primary-red: #dc2626; +$gray-50: #f9fafb; +$gray-100: #f3f4f6; +$gray-200: #e5e7eb; +$gray-300: #d1d5db; +$gray-400: #9ca3af; +$gray-500: #6b7280; +$gray-600: #4b5563; +$gray-700: #374151; +$gray-900: #111827; +$white: #ffffff; + +.container { + max-width: 1200px; + margin: 0 auto; + padding: 2rem; + min-height: 100vh; +} + +.loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 4rem 2rem; + text-align: center; + + p { + color: $gray-600; + font-size: 1rem; + margin-top: 1rem; + } +} + +.spinner { + width: 2rem; + height: 2rem; + color: $primary-blue; + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } +} + +.error { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 4rem 2rem; + text-align: center; + + p { + color: $primary-red; + font-size: 1rem; + margin-bottom: 1.5rem; + } +} + +.retryButton { + padding: 0.75rem 1.5rem; + background-color: $primary-blue; + color: $white; + border: none; + border-radius: 0.5rem; + font-size: 0.925rem; + font-weight: 500; + cursor: pointer; + transition: background-color 0.2s ease-in-out; + + &:hover { + background-color: color.adjust($primary-blue, $lightness: -10%); + } +} + +// Header +.header { + display: flex; + justify-content: flex-end; + margin-bottom: 2rem; + padding-bottom: 1rem; + border-bottom: 1px solid $gray-200; +} + +.headerActions { + display: flex; + gap: 0.75rem; + align-items: center; +} + +.refreshButton, .settingsButton { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1rem; + background-color: $white; + color: $gray-700; + border: 1px solid $gray-300; + border-radius: 0.5rem; + font-size: 0.925rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease-in-out; + + &:hover { + background-color: $gray-50; + border-color: $gray-400; + } + + svg { + width: 1rem; + height: 1rem; + } +} + +.settingsButton { + background-color: $primary-blue; + color: $white; + border-color: $primary-blue; + + &:hover { + background-color: color.adjust($primary-blue, $lightness: -10%); + border-color: color.adjust($primary-blue, $lightness: -10%); + } +} + +// Stats +.stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 1.5rem; + margin-bottom: 2rem; +} + +.statCard { + background: $white; + border: 1px solid $gray-200; + border-radius: 0.75rem; + padding: 1.5rem; + text-align: center; + transition: all 0.2s ease-in-out; + + &:hover { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + border-color: rgba($primary-blue, 0.3); + } +} + +.statValue { + font-size: 2rem; + font-weight: 700; + color: $primary-blue; + margin-bottom: 0.5rem; +} + +.statLabel { + font-size: 0.925rem; + font-weight: 500; + color: $gray-600; +} + +// Filters +.filters { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin-bottom: 2rem; + padding: 1rem; + background: $gray-50; + border-radius: 0.75rem; +} + +.filterButton { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.625rem 1rem; + background: $white; + color: $gray-700; + border: 1px solid $gray-300; + border-radius: 0.5rem; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease-in-out; + + &:hover { + background: $gray-100; + border-color: $gray-400; + } + + &.active { + background: $primary-blue; + color: $white; + border-color: $primary-blue; + + .filterIcon { + color: $white !important; + } + } +} + +.filterIcon { + width: 1rem; + height: 1rem; +} + +// Config List +.configList { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.configItem { + background: $white; + border: 1px solid $gray-200; + border-radius: 0.75rem; + overflow: hidden; + transition: all 0.2s ease-in-out; + + &:hover { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + border-color: rgba($primary-blue, 0.3); + } +} + +.configHeader { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1.25rem 1.5rem; + border-bottom: 1px solid $gray-100; +} + +.configInfo { + display: flex; + align-items: center; + gap: 1rem; +} + +.categoryIcon { + width: 2.5rem; + height: 2.5rem; + display: flex; + align-items: center; + justify-content: center; + background: $gray-50; + border-radius: 0.5rem; + font-size: 1.25rem; + flex-shrink: 0; +} + +.configTitle { + h4 { + font-size: 1.125rem; + font-weight: 600; + color: $gray-900; + margin: 0 0 0.25rem 0; + } +} + +.configPath { + font-size: 0.825rem; + color: $gray-500; + font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace; +} + +.configStatus { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.statusBadge { + padding: 0.375rem 0.75rem; + border-radius: 0.375rem; + font-size: 0.8rem; + font-weight: 500; + + &.saved { + background: rgba($primary-green, 0.1); + color: $primary-green; + } + + &.default { + background: rgba($gray-500, 0.1); + color: $gray-600; + } +} + +.typeBadge { + padding: 0.500rem 0.75rem; + background: rgba($primary-blue, 0.1); + color: $primary-blue; + border-radius: 0.375rem; + font-size: 0.75rem; + font-weight: 600; + font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace; + text-transform: capitalize; + min-width: 3.5rem; + text-align: center; + display: inline-block; + line-height: 1.2; + letter-spacing: 0.025em; +} + +.configValue { + padding: 1.25rem 1.5rem; + background: $gray-50; +} + +.valueRow { + display: flex; + margin-bottom: 1rem; + align-items: center; + + &:last-child { + margin-bottom: 0; + } + + label { + width: 4.5rem; + font-size: 0.875rem; + font-weight: 600; + color: $gray-700; + flex-shrink: 0; + line-height: 1.5; + } + + span { + flex: 1; + font-size: 0.875rem; + line-height: 1.5; + word-break: break-all; + } +} + +.currentValue { + color: $gray-900; + font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace; + background: $white; + min-height: 2.5rem; + padding: 0.5rem 0.75rem; + border: 1px solid $gray-200; + border-radius: 0.375rem; + font-weight: 500; + line-height: 1.5; + display: block; +} + +.defaultValue { + color: $gray-600; + font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace; + background: $gray-100; + min-height: 2.5rem; + padding: 0.5rem 0.75rem; + border: 1px solid $gray-200; + border-radius: 0.375rem; + font-style: italic; + line-height: 1.5; + display: block; +} + +.emptyState { + text-align: center; + padding: 4rem 2rem; + + p { + color: $gray-500; + font-size: 1rem; + } +} + +// 편집 관련 스타일 +.valueWithEdit { + display: flex; + align-items: center; + gap: 0.75rem; + flex: 1; +} + +.editTrigger { + background: transparent; + border: 1px solid $gray-300; + color: $gray-600; + padding: 0.5rem; + border-radius: 0.25rem; + cursor: pointer; + transition: all 0.2s ease-in-out; + display: flex; + align-items: center; + justify-content: center; + min-width: 2.5rem; + height: 2.5rem; + flex-shrink: 0; + + &:hover { + background: $primary-blue; + border-color: $primary-blue; + color: $white; + transform: translateY(-1px); + box-shadow: 0 2px 4px rgba($primary-blue, 0.2); + } + + &:active { + transform: translateY(0); + } + + svg { + width: 1rem; + height: 1rem; + } +} + +.editButtons { + display: flex; + gap: 0.25rem; +} + +.editButton { + background: transparent; + border: 1px solid; + padding: 0.5rem; + border-radius: 0.25rem; + cursor: pointer; + transition: all 0.2s ease-in-out; + display: flex; + align-items: center; + justify-content: center; + min-width: 2.25rem; + height: 2.25rem; + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + svg { + width: 1rem; + height: 1rem; + } +} + +.saveButton { + border-color: $primary-green; + color: $primary-green; + + &:hover:not(:disabled) { + background: $primary-green; + color: $white; + } +} + +.cancelButton { + border-color: $gray-400; + color: $gray-600; + + &:hover:not(:disabled) { + background: $gray-100; + border-color: $gray-500; + } +} + +// 인라인 편집 스타일 (현재값 영역에서 사용) +.editInputInline, +.editSelectInline { + color: $gray-900; + font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace; + background: $white; + padding: 0.5rem 0.75rem; + border: 2px solid $primary-blue; + border-radius: 0.375rem; + font-weight: 500; + line-height: 1.5; + font-size: 0.875rem; + flex: 1; + transition: all 0.2s ease-in-out; + + &:focus { + outline: none; + border-color: $primary-blue; + box-shadow: 0 0 0 3px rgba($primary-blue, 0.1); + } + + &:disabled { + background: $gray-100; + color: $gray-500; + cursor: not-allowed; + } +} + +.editSelectInline { + min-width: 100px; +} + +.helpText { + font-size: 0.75rem; + color: $gray-500; + margin-top: 0.5rem; + font-style: italic; +} + +// Responsive +@media (max-width: 768px) { + .container { + padding: 1rem; + } + + .refreshButton { + align-self: flex-start; + } + + .stats { + grid-template-columns: 1fr; + } + + .filters { + flex-direction: column; + gap: 0.5rem; + } + + .filterButton { + justify-content: center; + } + + .configHeader { + flex-direction: column; + gap: 1rem; + align-items: flex-start; + } + + .configStatus { + align-self: flex-end; + } + + .valueRow { + flex-direction: column; + gap: 0.5rem; + + label { + width: auto; + font-weight: 600; + } + } + + .valueWithEdit { + flex-direction: column; + align-items: stretch; + gap: 0.5rem; + + .editTrigger { + align-self: flex-start; + } + } +} diff --git a/src/app/admin/assets/AdminUserContent.module.scss b/src/app/admin/assets/AdminUserContent.module.scss index 8b390110..2755e8d4 100644 --- a/src/app/admin/assets/AdminUserContent.module.scss +++ b/src/app/admin/assets/AdminUserContent.module.scss @@ -192,6 +192,13 @@ color: #333; } +.userId { + font-weight: 600; + color: #666; + font-family: monospace; + font-size: 13px; +} + .username { color: #666; } diff --git a/src/app/admin/components/AdminConfigViewer.tsx b/src/app/admin/components/AdminConfigViewer.tsx new file mode 100644 index 00000000..04d790d2 --- /dev/null +++ b/src/app/admin/components/AdminConfigViewer.tsx @@ -0,0 +1,693 @@ +'use client'; + +import React, { useState, useEffect } from 'react'; +import { + FiRefreshCw, + FiDatabase, + FiSettings, + FiCpu, + FiLayers, + FiServer, + FiArrowLeft, + FiEdit3, + FiCheck, + FiX, +} from 'react-icons/fi'; +import { BsDatabaseUp } from 'react-icons/bs'; +import { SiOpenai } from 'react-icons/si'; +import { fetchAllConfigs, updateConfig } from '@/app/api/configAPI'; +import { devLog } from '@/app/_common/utils/logger'; +import styles from '@/app/admin/assets/AdminConfigViewer.module.scss'; + +interface ConfigItem { + env_name: string; + config_path: string; + current_value: any; + default_value: any; + is_saved: boolean; + type: string; +} + +type CategoryType = + | 'database' + | 'openai' + | 'app' + | 'workflow' + | 'node' + | 'vectordb' + | 'other'; + +interface AdminConfigViewerProps { + onNavigateToSettings?: () => void; +} + +const AdminConfigViewer: React.FC = ({ + onNavigateToSettings, +}) => { + const [configs, setConfigs] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [filter, setFilter] = useState('all'); + const [editingConfig, setEditingConfig] = useState(null); + const [editValue, setEditValue] = useState(''); + const [updating, setUpdating] = useState(false); + + const fetchConfigs = async () => { + setLoading(true); + setError(null); + try { + const data = await fetchAllConfigs(); + devLog.info('Fetched config data:', data); + + if ( + data && + (data as any).persistent_summary && + (data as any).persistent_summary.configs + ) { + const configArray: ConfigItem[] = ( + data as any + ).persistent_summary.configs.map((config: any) => { + const getValueType = (value: any): string => { + if (Array.isArray(value)) return 'Array'; + if (typeof value === 'boolean') return 'Bool'; + if (typeof value === 'number') return 'Num'; + if (typeof value === 'string') return 'Str'; + return 'Unknown'; + }; + + return { + env_name: config.env_name, + config_path: config.config_path, + current_value: config.current_value, + default_value: config.default_value, + is_saved: config.is_saved || false, + type: getValueType(config.current_value), + }; + }); + setConfigs(configArray); + devLog.info('Parsed config items:', configArray); + } else { + setConfigs([]); + devLog.warn('Unexpected data structure:', data); + } + } catch (err) { + const errorMessage = + err instanceof Error ? err.message : '알 수 없는 오류'; + setError(`설정 정보를 불러오는데 실패했습니다: ${errorMessage}`); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchConfigs(); + }, []); + + const getConfigCategory = (configPath: string): CategoryType => { + const path = configPath.toLowerCase(); + if (path.startsWith('database.')) return 'database'; + if (path.startsWith('openai.')) return 'openai'; + if (path.startsWith('app.')) return 'app'; + if (path.startsWith('workflow.')) return 'workflow'; + if (path.startsWith('node.')) return 'node'; + if (path.startsWith('vectordb.')) return 'vectordb'; + return 'other'; + }; + + const getCategoryIcon = (category: CategoryType) => { + switch (category) { + case 'database': + return ; + case 'openai': + return ; + case 'app': + return ; + case 'workflow': + return ; + case 'node': + return ; + case 'vectordb': + return ; + default: + return ; + } + }; + + const getCategoryColor = (category: CategoryType): string => { + switch (category) { + case 'database': + return '#336791'; + case 'openai': + return '#10a37f'; + case 'app': + return '#0078d4'; + case 'workflow': + return '#ff6b35'; + case 'node': + return '#6366f1'; + case 'vectordb': + return '#023196'; + default: + return '#6b7280'; + } + }; + + const getCategoryName = (category: CategoryType): string => { + switch (category) { + case 'database': + return '데이터베이스'; + case 'openai': + return 'OpenAI'; + case 'app': + return '애플리케이션'; + case 'workflow': + return '워크플로우'; + case 'node': + return '노드'; + case 'vectordb': + return '벡터 데이터베이스'; + default: + return '기타'; + } + }; + + const formatValue = ( + value: any, + type: string, + envName?: string, + ): string => { + if (value === null || value === undefined) return 'N/A'; + + // 민감한 정보 마스킹 (API 키, 패스워드 등) + const sensitiveFields = ['API_KEY', 'PASSWORD', 'SECRET', 'TOKEN']; + const isSensitive = + envName && sensitiveFields.some((field) => envName.includes(field)); + + if (isSensitive && typeof value === 'string' && value.length > 8) { + return ( + value.substring(0, 8) + + '*'.repeat(Math.min(value.length - 8, 20)) + + '...' + ); + } + + // 배열 타입 처리 + if (Array.isArray(value)) { + return value.join(', '); + } + + // 긴 문자열 처리 + if (typeof value === 'string' && value.length > 50) { + return value.substring(0, 47) + '...'; + } + + return String(value); + }; + + const formatTypeName = (type: string): string => { + if (!type) return 'Unknown'; + return type.charAt(0).toUpperCase() + type.slice(1).toLowerCase(); + }; + + const getFilteredConfigs = () => { + if (filter === 'all') return configs; + return configs.filter( + (config) => getConfigCategory(config.config_path) === filter, + ); + }; + + const getFilterStats = () => { + const stats: Record & { + saved: number; + unsaved: number; + total: number; + } = { + database: 0, + openai: 0, + app: 0, + workflow: 0, + node: 0, + vectordb: 0, + other: 0, + saved: 0, + unsaved: 0, + total: 0, + }; + + configs.forEach((config) => { + const category = getConfigCategory(config.config_path); + stats[category]++; + }); + + stats.saved = configs.filter( + (c) => c.is_saved && c.current_value != c.default_value, + ).length; + stats.unsaved = configs.length - stats.saved; + stats.total = configs.length; + + return stats; + }; + + const handleEditStart = (config: ConfigItem) => { + setEditingConfig(config.env_name); + setEditValue(String(config.current_value)); + }; + + const handleEditCancel = () => { + setEditingConfig(null); + setEditValue(''); + }; + + const handleKeyPress = (e: React.KeyboardEvent, config: ConfigItem) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleEditSave(config); + } else if (e.key === 'Escape') { + e.preventDefault(); + handleEditCancel(); + } + }; + + const validateValue = ( + value: string, + type: string, + ): { isValid: boolean; parsedValue: any; error?: string } => { + try { + switch (type.toLowerCase()) { + case 'boolean': { + const boolValue = value.toLowerCase().trim(); + if (boolValue === 'true') + return { isValid: true, parsedValue: true }; + if (boolValue === 'false') + return { isValid: true, parsedValue: false }; + return { + isValid: false, + parsedValue: null, + error: 'Boolean values must be "true" or "false"', + }; + } + + case 'number': { + const numValue = Number(value); + if (isNaN(numValue)) + return { + isValid: false, + parsedValue: null, + error: 'Invalid number format', + }; + return { isValid: true, parsedValue: numValue }; + } + + case 'array': + try { + const arrayValue = JSON.parse(value); + if (!Array.isArray(arrayValue)) { + return { + isValid: false, + parsedValue: null, + error: 'Value must be a valid JSON array', + }; + } + return { isValid: true, parsedValue: arrayValue }; + } catch { + // 쉼표로 구분된 문자열을 배열로 변환 + const arrayValue = value + .split(',') + .map((item) => item.trim()) + .filter((item) => item.length > 0); + return { isValid: true, parsedValue: arrayValue }; + } + + case 'string': + default: + return { isValid: true, parsedValue: value }; + } + } catch (error) { + return { + isValid: false, + parsedValue: null, + error: 'Invalid value format', + }; + } + }; + + const handleEditSave = async (config: ConfigItem) => { + const validation = validateValue(editValue, config.type); + + if (!validation.isValid) { + alert(`유효하지 않은 값입니다: ${validation.error}`); + return; + } + + setUpdating(true); + try { + await updateConfig(config.env_name, validation.parsedValue); + + // 로컬 상태 업데이트 + setConfigs((prevConfigs) => + prevConfigs.map((c) => + c.env_name === config.env_name + ? { + ...c, + current_value: validation.parsedValue, + is_saved: true, + } + : c, + ), + ); + + setEditingConfig(null); + setEditValue(''); + + devLog.info(`Config ${config.env_name} updated successfully`); + } catch (error) { + devLog.error('Failed to update config:', error); + alert('설정 업데이트에 실패했습니다.'); + } finally { + setUpdating(false); + } + }; + + const stats = getFilterStats(); + const filteredConfigs = getFilteredConfigs(); + + if (loading) { + return ( +
+
+ +

설정 정보를 불러오는 중...

+
+
+ ); + } + + if (error) { + return ( +
+
+

{error}

+ +
+
+ ); + } + + return ( +
+ {/* Header - simplified for component use */} +
+
+ + {onNavigateToSettings && ( + + )} +
+
+ + {/* Stats */} +
+
+
{stats.total}
+
전체 설정
+
+
+
{stats.saved}
+
저장된 설정
+
+
+
{stats.unsaved}
+
기본값 사용
+
+
+ + {/* Filters */} +
+ + {( + [ + 'node', + 'workflow', + 'app', + 'database', + 'vectordb', + 'openai', + ] as CategoryType[] + ).map( + (category) => + stats[category] > 0 && ( + + ), + )} +
+ + {/* Config List */} +
+ {filteredConfigs.map((config, index) => { + const category = getConfigCategory(config.config_path); + return ( +
+
+
+
+ {getCategoryIcon(category)} +
+
+

{config.env_name}

+ + {config.config_path} + +
+
+
+ + {config.is_saved && + config.current_value != + config.default_value + ? '설정됨' + : '기본값'} + + + {formatTypeName(config.type)} + +
+
+
+ {editingConfig === config.env_name ? ( + <> +
+ +
+ {config.type.toLowerCase() === + 'boolean' ? ( + + ) : ( + + setEditValue( + e.target.value, + ) + } + className={ + styles.editInputInline + } + disabled={updating} + placeholder={`Enter ${config.type.toLowerCase()} value`} + onKeyDown={(e) => + handleKeyPress( + e, + config, + ) + } + autoFocus + /> + )} +
+ + +
+
+
+
+ + + {formatValue( + config.default_value, + config.type, + config.env_name, + )} + +
+ {config.type.toLowerCase() === + 'array' && ( +
+ 배열 값: JSON 형식 ["value1", + "value2"] 또는 쉼표로 구분된 값 +
+ )} + + ) : ( + <> +
+ +
+ + {formatValue( + config.current_value, + config.type, + config.env_name, + )} + + +
+
+
+ + + {formatValue( + config.default_value, + config.type, + config.env_name, + )} + +
+ + )} +
+
+ ); + })} +
+ + {filteredConfigs.length === 0 && ( +
+

해당 카테고리에 설정이 없습니다.

+
+ )} +
+ ); +}; + +export default AdminConfigViewer; diff --git a/src/app/admin/components/AdminPageContent.tsx b/src/app/admin/components/AdminPageContent.tsx index d33cd8cc..ca206eaa 100644 --- a/src/app/admin/components/AdminPageContent.tsx +++ b/src/app/admin/components/AdminPageContent.tsx @@ -7,6 +7,7 @@ import AdminSidebar from '@/app/admin/components/AdminSidebar'; import AdminContentArea from '@/app/admin/components/AdminContentArea'; import AdminIntroduction from '@/app/admin/components/AdminIntroduction'; import AdminUserContent from '@/app/admin/components/AdminUserContent'; +import AdminConfigViewer from '@/app/admin/components/AdminConfigViewer'; import { getUserSidebarItems, getSystemSidebarItems, @@ -56,7 +57,7 @@ const AdminPageContent: React.FC = () => { const validSections = [ 'dashboard', 'users', 'user-create', 'user-permissions', - 'system-config', 'system-monitor', 'system-health', + 'system-config', 'chat-monitoring', 'system-monitor', 'system-health', 'database', 'storage', 'backup', 'security-settings', 'audit-logs', 'error-logs', 'access-logs' ]; @@ -107,7 +108,16 @@ const AdminPageContent: React.FC = () => { title="시스템 설정" description="전역 시스템 설정 및 환경변수를 관리하세요." > -
시스템 설정 컴포넌트가 여기에 표시됩니다.
+ + + ); + case 'chat-monitoring': + return ( + +
채팅 모니터링 컴포넌트가 여기에 표시됩니다.
); case 'system-monitor': @@ -215,7 +225,7 @@ const AdminPageContent: React.FC = () => { activeItem={activeSection} onItemClick={(itemId: string) => setActiveSection(itemId)} initialUserExpanded={['users', 'user-create', 'user-permissions'].includes(activeSection)} - initialSystemExpanded={['system-config', 'system-monitor', 'system-health'].includes(activeSection)} + initialSystemExpanded={['system-config', 'chat-monitoring', 'system-monitor', 'system-health'].includes(activeSection)} initialDataExpanded={['database', 'storage', 'backup'].includes(activeSection)} initialSecurityExpanded={['security-settings', 'audit-logs', 'error-logs', 'access-logs'].includes(activeSection)} /> diff --git a/src/app/admin/components/AdminUserContent.tsx b/src/app/admin/components/AdminUserContent.tsx index aae0f726..6c61bc04 100644 --- a/src/app/admin/components/AdminUserContent.tsx +++ b/src/app/admin/components/AdminUserContent.tsx @@ -196,6 +196,17 @@ const AdminUserContent: React.FC = () => { + {sortedUsers.length === 0 ? ( - @@ -288,6 +299,7 @@ const AdminUserContent: React.FC = () => { const roleInfo = getUserRoleDisplay(user); return ( + diff --git a/src/app/admin/components/adminSidebarConfig.ts b/src/app/admin/components/adminSidebarConfig.ts index 0eef8048..c434fe06 100644 --- a/src/app/admin/components/adminSidebarConfig.ts +++ b/src/app/admin/components/adminSidebarConfig.ts @@ -14,6 +14,7 @@ import { FiEye, FiFileText, FiAlertTriangle, + FiMessageSquare, } from 'react-icons/fi'; import { AdminSidebarItem } from '@/app/admin/components/types'; @@ -40,7 +41,7 @@ export const getUserSidebarItems = (): AdminSidebarItem[] => [ }, ]; -export const getSystemItems = ['system-config', 'system-monitor', 'system-health']; +export const getSystemItems = ['system-config', 'chat-monitoring', 'system-monitor', 'system-health']; export const getSystemSidebarItems = (): AdminSidebarItem[] => [ { @@ -49,6 +50,12 @@ export const getSystemSidebarItems = (): AdminSidebarItem[] => [ description: '전역 시스템 설정 및 환경변수', icon: React.createElement(FiSettings), }, + { + id: 'chat-monitoring', + title: '채팅 모니터링', + description: '실시간 채팅 활동 및 상태 모니터링', + icon: React.createElement(FiMessageSquare), + }, { id: 'system-monitor', title: '시스템 모니터링', From 0885fb20d84d62e576a93ef29ea180839dc94d89 Mon Sep 17 00:00:00 2001 From: haesookim Date: Tue, 26 Aug 2025 18:00:08 +0900 Subject: [PATCH 12/14] style: Refine textarea height transition in chat interface Changed the textarea transition property to specifically animate height with an ease-in-out timing function for smoother resizing behavior. This improves the visual experience when the chat input area adjusts its size. --- src/app/chat/assets/ChatInterface.module.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/chat/assets/ChatInterface.module.scss b/src/app/chat/assets/ChatInterface.module.scss index 4b01b514..2bf26a2c 100644 --- a/src/app/chat/assets/ChatInterface.module.scss +++ b/src/app/chat/assets/ChatInterface.module.scss @@ -540,7 +540,7 @@ font-size: 0.95rem; resize: none; outline: none; - transition: all 0.2s ease; + transition: height 0.2s ease-in-out; background: $white; overflow-y: hidden; // 높이가 자동 조절될 때 스크롤 숨김 line-height: 1.4; From f878f7ac7ea6dc7864a698e214917161e0921356 Mon Sep 17 00:00:00 2001 From: haesookim Date: Tue, 26 Aug 2025 18:01:38 +0900 Subject: [PATCH 13/14] fix: Temporarily disable signup link on login page Comment out the signup link in the login page to prevent user navigation to the signup route, possibly due to ongoing updates or restrictions on new registrations. --- src/app/(auth)/login/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx index 9cd86f84..6dfffaf3 100644 --- a/src/app/(auth)/login/page.tsx +++ b/src/app/(auth)/login/page.tsx @@ -157,7 +157,7 @@ const LoginPage = () => {
비밀번호를 잊으셨나요? - 회원가입 + {/* 회원가입 */}
From 4659c43c977c9081ec29c4276efcc58d06a78fd2 Mon Sep 17 00:00:00 2001 From: CocoRoF Date: Tue, 26 Aug 2025 09:03:19 +0000 Subject: [PATCH 14/14] feat: Implement user deletion functionality and add workflow logs component --- src/app/admin/api/workflow.js | 26 ++ .../admin/assets/AdminUserContent.module.scss | 16 +- .../AdminWorkflowLogsContent.module.scss | 317 ++++++++++++++++++ src/app/admin/components/AdminPageContent.tsx | 3 +- src/app/admin/components/AdminUserContent.tsx | 41 ++- .../components/AdminWorkflowLogsContent.tsx | 313 +++++++++++++++++ 6 files changed, 710 insertions(+), 6 deletions(-) create mode 100644 src/app/admin/api/workflow.js create mode 100644 src/app/admin/assets/AdminWorkflowLogsContent.module.scss create mode 100644 src/app/admin/components/AdminWorkflowLogsContent.tsx diff --git a/src/app/admin/api/workflow.js b/src/app/admin/api/workflow.js new file mode 100644 index 00000000..20c3e8c9 --- /dev/null +++ b/src/app/admin/api/workflow.js @@ -0,0 +1,26 @@ +// Workflow 관리 API 호출 함수들을 관리하는 파일 +import { devLog } from '@/app/_common/utils/logger'; +import { API_BASE_URL } from '@/app/config.js'; +import { apiClient } from '@/app/api/apiClient'; + +/** + * 모든 IO 로그를 가져오는 함수 (슈퍼유저 권한 필요) + * @returns {Promise} IO 로그 목록 배열 + */ +export const getAllIOLogs = async () => { + try { + const response = await apiClient(`${API_BASE_URL}/api/admin/workflow/all-io-logs`); + const data = await response.json(); + devLog.log('Get all IO logs result:', data); + + if (!response.ok) { + devLog.error('Failed to get all IO logs:', data); + throw new Error(data.detail || 'Failed to get all IO logs'); + } + + return data.io_logs; + } catch (error) { + devLog.error('Failed to get all IO logs:', error); + throw error; + } +}; diff --git a/src/app/admin/assets/AdminUserContent.module.scss b/src/app/admin/assets/AdminUserContent.module.scss index 2755e8d4..3f0e6167 100644 --- a/src/app/admin/assets/AdminUserContent.module.scss +++ b/src/app/admin/assets/AdminUserContent.module.scss @@ -225,17 +225,29 @@ background: #6c757d; color: white; - &:hover { + &:hover:not(:disabled) { background: #5a6268; transform: translateY(-1px); } + &:disabled { + background: #adb5bd; + cursor: not-allowed; + transform: none; + opacity: 0.7; + } + &.dangerButton { background: #dc3545; - &:hover { + &:hover:not(:disabled) { background: #c82333; } + + &:disabled { + background: #f8d7da; + color: #721c24; + } } } diff --git a/src/app/admin/assets/AdminWorkflowLogsContent.module.scss b/src/app/admin/assets/AdminWorkflowLogsContent.module.scss new file mode 100644 index 00000000..d0291f8e --- /dev/null +++ b/src/app/admin/assets/AdminWorkflowLogsContent.module.scss @@ -0,0 +1,317 @@ +@use "@/app/_common/_variables" as *; + +.container { + padding: 0; + background: #ffffff; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +.controls { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px; + border-bottom: 1px solid #e9ecef; + gap: 20px; + + @media (max-width: 768px) { + flex-direction: column; + gap: 15px; + } +} + +.searchContainer { + flex: 1; + max-width: 500px; +} + +.searchInput { + width: 100%; + padding: 10px 15px; + border: 1px solid #ddd; + border-radius: 6px; + font-size: 14px; + transition: border-color 0.2s ease; + + &:focus { + outline: none; + border-color: #e74c3c; + box-shadow: 0 0 0 2px rgba(231, 76, 60, 0.1); + } + + &::placeholder { + color: #999; + } +} + +.stats { + display: flex; + gap: 10px; + color: #666; + font-size: 14px; + + span { + white-space: nowrap; + } +} + +.refreshButton { + padding: 10px 20px; + background: #e74c3c; + color: white; + border: none; + border-radius: 6px; + cursor: pointer; + font-size: 14px; + font-weight: 500; + transition: background 0.2s ease; + + &:hover { + background: #c0392b; + } + + &:active { + transform: translateY(1px); + } +} + +.tableContainer { + overflow-x: auto; + max-height: 600px; + overflow-y: auto; +} + +.table { + width: 100%; + border-collapse: collapse; + background: white; + min-width: 1200px; // 많은 컬럼으로 인한 최소 너비 설정 + + th { + background: #f8f9fa; + padding: 12px 8px; + text-align: left; + font-weight: 600; + color: #333; + border-bottom: 2px solid #e9ecef; + position: sticky; + top: 0; + z-index: 10; + white-space: nowrap; + font-size: 13px; + + &.sortable { + cursor: pointer; + user-select: none; + transition: background 0.2s ease; + + &:hover { + background: #e9ecef; + } + } + } + + td { + padding: 10px 8px; + border-bottom: 1px solid #e9ecef; + vertical-align: middle; + font-size: 13px; + } + + tr:hover { + background: #f8f9fa; + } +} + +.tableRow { + transition: background 0.2s ease; +} + +.sortIcon { + margin-left: 5px; + font-size: 12px; + color: #e74c3c; +} + +.badge { + padding: 3px 6px; + border-radius: 10px; + font-size: 11px; + font-weight: 500; + text-align: center; + min-width: 40px; + display: inline-block; + + &.badgeTest { + background: #fff3cd; + color: #856404; + border: 1px solid #ffeaa7; + } + + &.badgeProduction { + background: #d4edda; + color: #155724; + border: 1px solid #c3e6cb; + } +} + +.logId { + font-weight: 600; + color: #666; + font-family: monospace; + font-size: 12px; + min-width: 60px; +} + +.userId { + font-weight: 500; + color: #666; + font-family: monospace; + font-size: 12px; + min-width: 80px; +} + +.workflowName { + font-weight: 500; + color: #333; + max-width: 150px; + word-break: break-word; +} + +.interactionId { + font-family: monospace; + color: #666; + font-size: 11px; + max-width: 120px; + word-break: break-all; +} + +.dataCell { + max-width: 150px; + word-break: break-word; + color: #555; + font-size: 12px; + line-height: 1.4; +} + +.score { + text-align: center; + font-weight: 500; + font-family: monospace; + min-width: 70px; +} + +.loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 60px 20px; + color: #666; + + .spinner { + width: 40px; + height: 40px; + border: 3px solid #f3f3f3; + border-top: 3px solid #e74c3c; + border-radius: 50%; + animation: spin 1s linear infinite; + margin-bottom: 20px; + } + + p { + margin: 0; + font-size: 16px; + } +} + +.error { + text-align: center; + padding: 60px 20px; + color: #721c24; + + h3 { + color: #e74c3c; + margin-bottom: 10px; + } + + p { + margin-bottom: 20px; + color: #666; + } +} + +.retryButton { + padding: 12px 24px; + background: #e74c3c; + color: white; + border: none; + border-radius: 6px; + cursor: pointer; + font-size: 14px; + font-weight: 500; + transition: background 0.2s ease; + + &:hover { + background: #c0392b; + } +} + +.noData { + text-align: center; + padding: 40px 20px; + color: #999; + font-style: italic; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +// 반응형 처리 +@media (max-width: 1024px) { + .table { + font-size: 12px; + + th, td { + padding: 8px 6px; + } + } + + .dataCell { + max-width: 100px; + } + + .workflowName { + max-width: 100px; + } + + .interactionId { + max-width: 80px; + } +} + +@media (max-width: 768px) { + .container { + margin: 0 -10px; + border-radius: 0; + } + + .controls { + padding: 15px; + } + + .table { + font-size: 11px; + + th, td { + padding: 6px 4px; + } + } + + .dataCell, .workflowName, .interactionId { + max-width: 80px; + } +} diff --git a/src/app/admin/components/AdminPageContent.tsx b/src/app/admin/components/AdminPageContent.tsx index ca206eaa..a791b16c 100644 --- a/src/app/admin/components/AdminPageContent.tsx +++ b/src/app/admin/components/AdminPageContent.tsx @@ -8,6 +8,7 @@ import AdminContentArea from '@/app/admin/components/AdminContentArea'; import AdminIntroduction from '@/app/admin/components/AdminIntroduction'; import AdminUserContent from '@/app/admin/components/AdminUserContent'; import AdminConfigViewer from '@/app/admin/components/AdminConfigViewer'; +import AdminWorkflowLogsContent from '@/app/admin/components/AdminWorkflowLogsContent'; import { getUserSidebarItems, getSystemSidebarItems, @@ -117,7 +118,7 @@ const AdminPageContent: React.FC = () => { title="채팅 모니터링" description="실시간 채팅 활동 및 상태를 모니터링하세요." > -
채팅 모니터링 컴포넌트가 여기에 표시됩니다.
+ ); case 'system-monitor': diff --git a/src/app/admin/components/AdminUserContent.tsx b/src/app/admin/components/AdminUserContent.tsx index 6c61bc04..5db69faa 100644 --- a/src/app/admin/components/AdminUserContent.tsx +++ b/src/app/admin/components/AdminUserContent.tsx @@ -1,7 +1,7 @@ 'use client'; import React, { useState, useEffect } from 'react'; -import { getAllUsers } from '@/app/admin/api/users'; +import { getAllUsers, deleteUser } from '@/app/admin/api/users'; import { devLog } from '@/app/_common/utils/logger'; import styles from '@/app/admin/assets/AdminUserContent.module.scss'; @@ -28,6 +28,7 @@ const AdminUserContent: React.FC = () => { const [searchTerm, setSearchTerm] = useState(''); const [sortField, setSortField] = useState('created_at'); const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc'); + const [deleteLoading, setDeleteLoading] = useState(null); // 사용자 데이터 로드 const loadUsers = async () => { @@ -142,6 +143,39 @@ const AdminUserContent: React.FC = () => { } }; + // 사용자 삭제 핸들러 + const handleDeleteUser = async (user: User) => { + const confirmed = window.confirm( + `정말로 "${user.username}" (${user.email}) 사용자와 관련된 모든 데이터를 삭제하시겠습니까?\n\n이 작업은 되돌릴 수 없습니다.` + ); + + if (!confirmed) return; + + try { + setDeleteLoading(user.id); + + const userData = { + id: user.id, + username: user.username, + email: user.email + }; + + const result = await deleteUser(userData); + devLog.log('User deleted successfully:', result); + + // 삭제 성공 후 사용자 목록 새로고침 + await loadUsers(); + + alert('사용자와 관련된 모든 데이터가 성공적으로 삭제되었습니다.'); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : '사용자 삭제에 실패했습니다.'; + devLog.error('Failed to delete user:', err); + alert(`삭제 실패: ${errorMessage}`); + } finally { + setDeleteLoading(null); + } + }; + if (loading) { return (
@@ -320,9 +354,10 @@ const AdminUserContent: React.FC = () => {
diff --git a/src/app/admin/components/AdminWorkflowLogsContent.tsx b/src/app/admin/components/AdminWorkflowLogsContent.tsx new file mode 100644 index 00000000..681edb1b --- /dev/null +++ b/src/app/admin/components/AdminWorkflowLogsContent.tsx @@ -0,0 +1,313 @@ +'use client'; + +import React, { useState, useEffect } from 'react'; +import { getAllIOLogs } from '@/app/admin/api/workflow'; +import { devLog } from '@/app/_common/utils/logger'; +import styles from '@/app/admin/assets/AdminWorkflowLogsContent.module.scss'; + +interface WorkflowLog { + id: number; + user_id: number | null; + interaction_id: string; + workflow_id: string; + workflow_name: string; + input_data: string | null; + output_data: string | null; + expected_output: string | null; + llm_eval_score: number | null; + test_mode: boolean; + user_score: number; + created_at: string; + updated_at: string; +} + +const AdminWorkflowLogsContent: React.FC = () => { + const [logs, setLogs] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [searchTerm, setSearchTerm] = useState(''); + const [sortField, setSortField] = useState('created_at'); + const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc'); + + // 로그 데이터 로드 + const loadLogs = async () => { + try { + setLoading(true); + setError(null); + const logData = await getAllIOLogs(); + setLogs(logData || []); + } catch (err) { + setError(err instanceof Error ? err.message : 'IO 로그 목록을 불러오는데 실패했습니다.'); + devLog.error('Failed to load workflow logs:', err); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + loadLogs(); + }, []); + + // 검색 필터링 + const filteredLogs = logs.filter(log => { + const searchLower = searchTerm.toLowerCase().trim(); + if (!searchLower) return true; + + const workflowId = log.workflow_id?.toLowerCase() || ''; + const workflowName = log.workflow_name?.toLowerCase() || ''; + const interactionId = log.interaction_id?.toLowerCase() || ''; + const userId = log.user_id?.toString() || ''; + + return workflowId.includes(searchLower) || + workflowName.includes(searchLower) || + interactionId.includes(searchLower) || + userId.includes(searchLower); + }); + + // 정렬 + const sortedLogs = [...filteredLogs].sort((a, b) => { + const aValue = a[sortField]; + const bValue = b[sortField]; + + // undefined/null 값 처리 + if (aValue === undefined || aValue === null) { + if (bValue === undefined || bValue === null) return 0; + return 1; + } + if (bValue === undefined || bValue === null) return -1; + if (aValue === bValue) return 0; + + const comparison = aValue < bValue ? -1 : 1; + return sortDirection === 'asc' ? comparison : -comparison; + }); + + // 정렬 핸들러 + const handleSort = (field: keyof WorkflowLog) => { + if (sortField === field) { + setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc'); + } else { + setSortField(field); + setSortDirection('asc'); + } + }; + + // 날짜 포맷팅 + const formatDate = (dateString: string) => { + if (!dateString) return '-'; + return new Date(dateString).toLocaleDateString('ko-KR', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit' + }); + }; + + // 점수 표시 함수 + const formatScore = (score: number | null) => { + if (score === null || score === undefined) return '-'; + return score.toFixed(2); + }; + + // 테스트 모드 배지 렌더링 + const renderTestModeBadge = (testMode: boolean) => ( + + {testMode ? '테스트' : '운영'} + + ); + + // 데이터 미리보기 (긴 텍스트 줄이기) + const truncateText = (text: string | null, maxLength = 50) => { + if (!text) return '-'; + return text.length > maxLength ? `${text.substring(0, maxLength)}...` : text; + }; + + if (loading) { + return ( +
+
+
+

워크플로우 로그를 불러오는 중...

+
+
+ ); + } + + if (error) { + return ( +
+
+

오류 발생

+

{error}

+ +
+
+ ); + } + + return ( +
+ {/* 상단 컨트롤 */} +
+
+ setSearchTerm(e.target.value)} + className={styles.searchInput} + /> +
+
+ 총 {logs.length}개의 로그 + {searchTerm && ( + ({filteredLogs.length}개 검색됨) + )} +
+ +
+ + {/* 로그 테이블 */} +
+
handleSort('id')} + > + ID + {sortField === 'id' && ( + + {sortDirection === 'asc' ? '↑' : '↓'} + + )} + handleSort('email')} @@ -279,7 +290,7 @@ const AdminUserContent: React.FC = () => {
+ {searchTerm ? '검색 결과가 없습니다.' : '등록된 사용자가 없습니다.'}
{user.id} {user.email} {user.username} {user.full_name || '-'}
+ + + + + + + + + + + + + + + + {sortedLogs.length === 0 ? ( + + + + ) : ( + sortedLogs.map((log) => ( + + + + + + + + + + + + + )) + )} + +
handleSort('id')} + > + ID + {sortField === 'id' && ( + + {sortDirection === 'asc' ? '↑' : '↓'} + + )} + handleSort('user_id')} + > + 사용자 ID + {sortField === 'user_id' && ( + + {sortDirection === 'asc' ? '↑' : '↓'} + + )} + handleSort('workflow_name')} + > + 워크플로우명 + {sortField === 'workflow_name' && ( + + {sortDirection === 'asc' ? '↑' : '↓'} + + )} + handleSort('interaction_id')} + > + 상호작용 ID + {sortField === 'interaction_id' && ( + + {sortDirection === 'asc' ? '↑' : '↓'} + + )} + 입력 데이터출력 데이터 handleSort('llm_eval_score')} + > + LLM 평가점수 + {sortField === 'llm_eval_score' && ( + + {sortDirection === 'asc' ? '↑' : '↓'} + + )} + handleSort('user_score')} + > + 사용자 점수 + {sortField === 'user_score' && ( + + {sortDirection === 'asc' ? '↑' : '↓'} + + )} + handleSort('test_mode')} + > + 모드 + {sortField === 'test_mode' && ( + + {sortDirection === 'asc' ? '↑' : '↓'} + + )} + handleSort('created_at')} + > + 생성일 + {sortField === 'created_at' && ( + + {sortDirection === 'asc' ? '↑' : '↓'} + + )} +
+ {searchTerm ? '검색 결과가 없습니다.' : '등록된 로그가 없습니다.'} +
{log.id}{log.user_id || '-'} + {truncateText(log.workflow_name, 30)} + + {truncateText(log.interaction_id, 20)} + + {truncateText(log.input_data)} + + {truncateText(log.output_data)} + + {formatScore(log.llm_eval_score)} + + {log.user_score} + {renderTestModeBadge(log.test_mode)}{formatDate(log.created_at)}
+ + + ); +}; + +export default AdminWorkflowLogsContent;