diff --git a/src/components/logs-viewer/index.tsx b/src/components/logs-viewer/index.tsx index 33e1f01d..b0737107 100644 --- a/src/components/logs-viewer/index.tsx +++ b/src/components/logs-viewer/index.tsx @@ -1,13 +1,11 @@ import useSetChunkFetch from '@/hooks/use-chunk-fetch'; import useOverlayScroller from '@/hooks/use-overlay-scroller'; -import { FitAddon } from '@xterm/addon-fit'; -import { Terminal } from '@xterm/xterm'; import '@xterm/xterm/css/xterm.css'; import classNames from 'classnames'; import _ from 'lodash'; import { memo, useCallback, useEffect, useRef, useState } from 'react'; import './index.less'; -import parseAnsi from './parse-ansi'; +import useParseAnsi from './parse-ansi'; interface LogsViewerProps { height: number; @@ -20,15 +18,14 @@ const LogsViewer: React.FC = (props) => { const { initialize, updateScrollerPosition } = useOverlayScroller({ theme: 'os-theme-light' }); + const { isClean, parseAnsi } = useParseAnsi(); const { setChunkFetch } = useSetChunkFetch(); const chunkRequedtRef = useRef(null); const scroller = useRef({}); - const termRef = useRef({}); - const termwrapRef = useRef({}); - const fitAddonRef = useRef({}); const cacheDataRef = useRef(''); const uidRef = useRef(0); const [logs, setLogs] = useState([]); + const autoScroll = useRef(true); const setId = () => { uidRef.current += 1; @@ -41,7 +38,11 @@ const LogsViewer: React.FC = (props) => { const updateContent = useCallback( (data: string) => { - cacheDataRef.current += data; + if (isClean(data)) { + cacheDataRef.current = data; + } else { + cacheDataRef.current += data; + } const res = parseAnsi(cacheDataRef.current, setId, clearScreen); setLogs(res); }, @@ -61,28 +62,6 @@ const LogsViewer: React.FC = (props) => { }); }; - const initTerm = useCallback(() => { - termRef.current?.clear?.(); - termRef.current?.dispose?.(); - termRef.current = new Terminal({ - lineHeight: 1.2, - fontSize: 13, - fontFamily: - "monospace,Menlo,Courier,'Courier New',Consolas,Monaco, 'Liberation Mono'", - disableStdin: true, - convertEol: true, - theme: { - background: '#1e1e1e', - foreground: 'rgba(255,255,255,0.8)' - }, - cursorInactiveStyle: 'none', - smoothScrollDuration: 0 - }); - fitAddonRef.current = new FitAddon(); - termRef.current.loadAddon(fitAddonRef.current); - termRef.current.open(termwrapRef.current); - }, [termwrapRef.current]); - useEffect(() => { createChunkConnection(); return () => { @@ -97,10 +76,10 @@ const LogsViewer: React.FC = (props) => { }, [scroller.current, initialize]); useEffect(() => { - if (logs) { - updateScrollerPosition(); + if (logs.length) { + updateScrollerPosition(0); } - }, [logs]); + }, [logs, autoScroll.current]); return (
diff --git a/src/components/logs-viewer/parse-ansi.ts b/src/components/logs-viewer/parse-ansi.ts index e8411e73..418c7b0c 100644 --- a/src/components/logs-viewer/parse-ansi.ts +++ b/src/components/logs-viewer/parse-ansi.ts @@ -1,130 +1,154 @@ -const removeBrackets = (str: string) => { - return str?.replace?.(/^\(.*?\)/, ''); -}; -const parseAnsi = ( - inputStr: string, - setId: () => number, - clearScreen: () => void -) => { - let cursorRow = 0; // current row - let cursorCol = 0; // current column - // screen content array - let screen = [['']]; - // replace carriage return and newline characters in the text - let input = inputStr.replace(/\r\n/g, '\n'); +import { useCallback, useRef } from 'react'; - // handle the \r and \n characters in the text - const handleText = (text: string) => { - let processed = ''; - for (let char of text) { - if (char === '\r') { - cursorCol = 0; // move to the beginning of the line - } else if (char === '\n') { - cursorRow++; // move to the next line - cursorCol = 0; // move to the beginning of the line - screen[cursorRow] = screen[cursorRow] || ['']; // create a new line if it does not exist - } else { - // add the character to the screen content array - screen[cursorRow][cursorCol] = char; - cursorCol++; +const controlSeqRegex = /\x1b\[(\d*);?(\d*)?([A-DJKHfm])/g; + +const useParseAnsi = () => { + const lastIndex = useRef(0); + + const removeBrackets = useCallback((str: string) => { + return str?.replace?.(/^\(.*?\)/, ''); + }, []); + + const isClean = useCallback((ansiStr: string) => { + let input = ansiStr.replace(/\r\n/g, '\n'); + let match = controlSeqRegex.exec(input) || []; + const command = match?.[3]; + const n = parseInt(match?.[1], 10) || 1; + return command === 'J' && n === 2; + }, []); + + const parseAnsi = useCallback( + (inputStr: string, setId: () => number, clearScreen: () => void) => { + let cursorRow = 0; // current row + let cursorCol = 0; // current column + // screen content array + let screen = [['']]; + // replace carriage return and newline characters in the text + let input = inputStr.replace(/\r\n/g, '\n'); + + // handle the \r and \n characters in the text + const handleText = (text: string) => { + let processed = ''; + for (let char of text) { + if (char === '\r') { + cursorCol = 0; // move to the beginning of the line + } else if (char === '\n') { + cursorRow++; // move to the next line + cursorCol = 0; // move to the beginning of the line + screen[cursorRow] = screen[cursorRow] || ['']; // create a new line if it does not exist + } else { + // add the character to the screen content array + screen[cursorRow][cursorCol] = char; + cursorCol++; + } + } + return processed; + }; + + let output = ''; // output text + + let match; + + // ANSI color map + const colorMap: Record = { + '30': 'black', + '31': 'red', + '32': 'green', + '33': 'yellow', + '34': 'blue', + '35': 'magenta', + '36': 'cyan', + '37': 'white' + }; + + let currentStyle = ''; // current text style + + // match ANSI control characters + while ((match = controlSeqRegex.exec(input)) !== null) { + // handle text before the control character + let textBeforeControl = input.slice(lastIndex.current, match.index); + output += handleText(textBeforeControl); // add the processed text to the output + lastIndex.current = controlSeqRegex.lastIndex; // update the last index + + const n = parseInt(match[1], 10) || 1; + const m = parseInt(match[2], 10) || 1; + const command = match[3]; + console.log('command', { + command, + cursorRow, + n + }); + + // handle ANSI control characters + switch (command) { + case 'A': // up + cursorRow = Math.max(0, cursorRow - n); + if (cursorRow === 0) { + // screen = [['']]; + // cursorCol = 0; + } + break; + case 'B': // down + cursorRow += n; + break; + case 'C': // right + cursorCol += n; + break; + case 'D': // left + cursorCol = Math.max(0, cursorCol - n); + break; + case 'H': // move the cursor to the specified position (n, m) + cursorRow = Math.max(0, n - 1); + cursorCol = Math.max(0, m - 1); + break; + case 'J': // clear the screen + if (n === 2) { + console.log('clear===='); + screen = [['']]; + cursorRow = 0; + cursorCol = 0; + } + break; + case 'm': // color + if (match[1] === '0') { + currentStyle = ''; + } else if (colorMap[match[1]]) { + currentStyle = `color: ${colorMap[match[1]]};`; + } + break; + } + + // check if the row and column are within the screen content array + while (screen.length <= cursorRow) { + screen.push(['']); + } + while (screen[cursorRow].length <= cursorCol) { + screen[cursorRow].push(''); + } } - } - return processed; - }; - // ANSI - const controlSeqRegex = /\x1b\[(\d*);?(\d*)?([A-DJKHfm])/g; - let output = ''; // output text + // handle the remaining text + output += handleText(input.slice(lastIndex.current)); - let match; - let lastIndex = 0; + let result = []; + for (let row = 0; row < screen.length; row++) { + let rowContent = screen[row].join(''); + result.push({ + content: removeBrackets(rowContent), + uid: setId() + }); + } + result.push({ + content: output, + uid: setId() + }); - // ANSI color map - const colorMap: Record = { - '30': 'black', - '31': 'red', - '32': 'green', - '33': 'yellow', - '34': 'blue', - '35': 'magenta', - '36': 'cyan', - '37': 'white' - }; + return result; + }, + [] + ); - let currentStyle = ''; // current text style - - // match ANSI control characters - while ((match = controlSeqRegex.exec(input)) !== null) { - // handle text before the control character - let textBeforeControl = input.slice(lastIndex, match.index); - output += handleText(textBeforeControl); // add the processed text to the output - lastIndex = controlSeqRegex.lastIndex; // update the last index - - const n = parseInt(match[1], 10) || 1; - const m = parseInt(match[2], 10) || 1; - const command = match[3]; - - // handle ANSI control characters - switch (command) { - case 'A': // move the cursor up - cursorRow = Math.max(0, cursorRow - n); - break; - case 'B': // move the cursor down - cursorRow += n; - break; - case 'C': // move the cursor right - cursorCol += n; - break; - case 'D': // move the cursor left - cursorCol = Math.max(0, cursorCol - n); - break; - case 'H': // move the cursor to the specified position (n, m) - cursorRow = Math.max(0, n - 1); - cursorCol = Math.max(0, m - 1); - break; - case 'J': // clear the screen - if (n === 2) { - screen = [['']]; - cursorRow = 0; - cursorCol = 0; - clearScreen?.(); - } - break; - case 'm': // color - if (match[1] === '0') { - currentStyle = ''; - } else if (colorMap[match[1]]) { - currentStyle = `color: ${colorMap[match[1]]};`; - } - break; - } - - // check if the row and column are within the screen content array - while (screen.length <= cursorRow) { - screen.push(['']); - } - while (screen[cursorRow].length <= cursorCol) { - screen[cursorRow].push(''); - } - } - - // handle the remaining text - output += handleText(input.slice(lastIndex)); - - let result = []; - for (let row = 0; row < screen.length; row++) { - let rowContent = screen[row].join(''); - result.push({ - content: removeBrackets(rowContent), - uid: setId() - }); - } - result.push({ - content: output, - uid: setId() - }); - - return result; + return { parseAnsi, isClean }; }; -export default parseAnsi; +export default useParseAnsi; diff --git a/src/components/logs-viewer/use-parse-byline.ts b/src/components/logs-viewer/use-parse-byline.ts new file mode 100644 index 00000000..28c7fada --- /dev/null +++ b/src/components/logs-viewer/use-parse-byline.ts @@ -0,0 +1,139 @@ +import { useRef, useState } from 'react'; + +const usePaseByLine = () => { + const [result, setResult] = useState([]); + const uidRef = useRef(0); + + const cursorRow = useRef(0); // current row + const cursorCol = useRef(0); // current column + // screen.current content array + const screen = useRef([['']]); + // replace carriage return and newline characters in the text + // let input = inputStr.replace(/\r\n/g, '\n') + + const removeBrackets = (str: string) => { + return str?.replace?.(/^\(\.*?\)/, ''); + }; + + const setId = () => { + uidRef.current += 1; + return uidRef.current; + }; + + // handle the \r and \n characters in the text + const handleText = (text: string) => { + for (let char of text) { + if (char === '\r') { + cursorCol.current = 0; // move to the beginning of the line + } else if (char === '\n') { + cursorRow.current += 1; // move to the next line + cursorCol.current = 0; // move to the beginning of the line + screen.current[cursorRow.current] = screen.current[ + cursorRow.current + ] || ['']; // create a new line if it does not exist + } else { + // add the character to the screen.current content array + screen.current[cursorRow.current][cursorCol.current] = char; + cursorCol.current += 1; + } + } + }; + + // ANSI + const controlSeqRegex = /\x1b\[(\d*);?(\d*)?([A-DJKHfm])/g; + + // ANSI color map + const colorMap: Record = { + '30': 'black', + '31': 'red', + '32': 'green', + '33': 'yellow', + '34': 'blue', + '35': 'magenta', + '36': 'cyan', + '37': 'white' + }; + + const updateResult = () => { + let res = []; + for (let row = 0; row < screen.current.length; row++) { + console.log('screen.current[row]', screen.current[row]); + let rowContent = screen.current[row].join(''); + res.push({ + content: removeBrackets(rowContent), + uid: setId() + }); + } + console.log('res', res); + setResult(res); + }; + + const handleLine = (line: string) => { + let match; + let lastIndex = 0; + let currentStyle = ''; // current text style + // match ANSI control characters + while ((match = controlSeqRegex.exec(line)) !== null) { + // handle text before the control character + let textBeforeControl = line.slice(lastIndex, match.index); + + handleText(textBeforeControl); // add the processed text to the output + lastIndex = controlSeqRegex.lastIndex; // update the last index + + const n = parseInt(match[1], 10) || 1; + const m = parseInt(match[2], 10) || 1; + const command = match[3]; + + // handle ANSI control characters + switch (command) { + case 'A': // up + cursorRow.current = Math.max(0, cursorRow.current - n); + break; + case 'B': // down + cursorRow.current += n; + break; + case 'C': // right + cursorCol.current += n; + break; + case 'D': // left + cursorCol.current = Math.max(0, cursorCol.current - n); + break; + case 'H': // move the cursor to the specified position (n, m) + cursorRow.current = Math.max(0, n - 1); + cursorCol.current = Math.max(0, m - 1); + break; + case 'J': // clear the screen.current + if (n === 2) { + screen.current = [['']]; + cursorRow.current = 0; + cursorCol.current = 0; + } + break; + case 'm': // color + if (match[1] === '0') { + currentStyle = ''; + } else if (colorMap[match[1]]) { + currentStyle = `color: ${colorMap[match[1]]};`; + } + break; + } + + // check if the row and column are within the screen.current content array + while (screen.current.length <= cursorRow.current) { + screen.current.push(['']); + } + while (screen.current[cursorRow.current].length <= cursorCol.current) { + screen.current[cursorRow.current].push(''); + } + } + handleText(line.slice(lastIndex)); + updateResult(); + }; + + return { + handleLine, + result + }; +}; + +export default usePaseByLine; diff --git a/src/hooks/use-chunk-fetch.ts b/src/hooks/use-chunk-fetch.ts index b60fb0af..f949a2c3 100644 --- a/src/hooks/use-chunk-fetch.ts +++ b/src/hooks/use-chunk-fetch.ts @@ -1,3 +1,4 @@ +import { split } from 'lodash'; import qs from 'query-string'; import { useEffect, useRef } from 'react'; @@ -6,27 +7,22 @@ interface RequestConfig { handler: (data: any) => any; beforeReconnect?: () => void; params?: object; + byLine?: boolean; contentType?: 'json' | 'text'; } const useSetChunkFetch = () => { const axiosToken = useRef(null); const requestConfig = useRef({}); - const completeData = useRef([]); const chunkDataRef = useRef([]); - const conentLengthRef = useRef(0); - const receivedLengthRef = useRef(0); + const bufferCacheRef = useRef(''); const readTextEventStreamData = async ( - reader: any, + reader: ReadableStreamDefaultReader, decoder: TextDecoder, callback: (data: any) => void ) => { const { done, value } = await reader.read(); - console.log('chunkDataRef.current===1', { - data: chunkDataRef.current, - done - }); if (done) { return; } @@ -39,47 +35,42 @@ const useSetChunkFetch = () => { await readTextEventStreamData(reader, decoder, callback); }; - const combineUint8Arrays = (arrays: Uint8Array[]) => { - // Calculate total length - const totalLength = arrays.reduce((acc, arr) => acc + arr.length, 0); - - // Create a new Uint8Array to hold the combined data - const combined = new Uint8Array(totalLength); - - // Copy each array into the combined array - let offset = 0; - for (const arr of arrays) { - combined.set(arr, offset); - offset += arr.length; - } - - return combined; - }; - - const readUint8ArrayStreamData = async ( + const readTextEventStreamDataByLine = async ( reader: ReadableStreamDefaultReader, - callback: (data: Uint8Array) => void + decoder: TextDecoder, + callback: (data: any) => void ) => { const { done, value } = await reader.read(); - while (true) { - if (done) { - callback(completeData.current); - break; - } - const tempData = new Uint8Array( - completeData.current.length + value.length - ); - tempData.set(completeData.current); - tempData.set(value, completeData.current.length); - completeData.current = tempData; + if (done) { + return; } - // await readUint8ArrayStreamData(reader, callback); + bufferCacheRef.current += decoder.decode(value, { stream: true }); + const lines = split(bufferCacheRef.current, /\r?\n/); + bufferCacheRef.current = lines.pop(); + for (const line of lines) { + callback(line); + } + + await readTextEventStreamDataByLine(reader, decoder, callback); + }; + + const readTextEventStreamDataByLineWithBuffer = async ( + reader: ReadableStreamDefaultReader, + decoder: TextDecoder, + callback: (data: any) => void + ) => { + await readTextEventStreamDataByLine(reader, decoder, callback); + + if (bufferCacheRef.current.length > 0) { + callback(bufferCacheRef.current); + } }; const fetchChunkRequest = async ({ url, handler, + byLine = false, params = {} }: RequestConfig) => { axiosToken.current?.abort?.(); @@ -104,13 +95,13 @@ const useSetChunkFetch = () => { return; } - chunkDataRef.current = ''; - conentLengthRef.current = response.headers.get('Content-Length'); - receivedLengthRef.current = 0; - console.log('conentLengthRef.current', conentLengthRef.current, response); - const reader = response?.body?.getReader(); + const reader = + response?.body?.getReader() as ReadableStreamDefaultReader; const decoder = new TextDecoder('utf-8'); + await readTextEventStreamData(reader, decoder, handler); + + console.log('chunkDataRef.current===1', chunkDataRef.current); } catch (error) { // handle error console.log('error============', error); diff --git a/src/hooks/use-overlay-scroller.ts b/src/hooks/use-overlay-scroller.ts index 7f00a918..4ac04138 100644 --- a/src/hooks/use-overlay-scroller.ts +++ b/src/hooks/use-overlay-scroller.ts @@ -59,9 +59,24 @@ export default function useOverlayScroller(options?: any) { [scrollEventElement, instanceRef] ); - const throttledUpdateScrollerPosition = React.useCallback(() => { - throttledScroll(); - }, [throttledScroll]); + const scrollauto = React.useCallback(() => { + scrollEventElement.current?.scrollTo?.({ + top: scrollEventElement.current.scrollHeight, + behavior: 'auto' + }); + instanceRef.current?.update?.(); + }, [scrollEventElement, instanceRef]); + + const throttledUpdateScrollerPosition = React.useCallback( + (delay?: number) => { + if (delay === 0) { + scrollauto(); + } else { + throttledScroll(); + } + }, + [throttledScroll, scrollauto] + ); // const createInstance = React.useCallback((el: any) => { // if (el) { diff --git a/src/pages/llmodels/components/table-list.tsx b/src/pages/llmodels/components/table-list.tsx index 37c3c147..4adac3be 100644 --- a/src/pages/llmodels/components/table-list.tsx +++ b/src/pages/llmodels/components/table-list.tsx @@ -39,7 +39,11 @@ import { queryModelInstancesList, updateModel } from '../apis'; -import { getSourceRepoConfigValue, modelSourceMap } from '../config'; +import { + InstanceStatusMap, + getSourceRepoConfigValue, + modelSourceMap +} from '../config'; import { FormData, ListItem, ModelInstanceListItem } from '../config/types'; import DeployModal from './deploy-modal'; import InstanceItem from './instance-item'; @@ -96,7 +100,13 @@ const Models: React.FC = ({ source: modelSourceMap.huggingface_value }); const [currentData, setCurrentData] = useState({} as ListItem); - const [currentInstanceUrl, setCurrentInstanceUrl] = useState(''); + const [currentInstance, setCurrentInstance] = useState<{ + url: string; + status: string; + }>({ + url: '', + status: '' + }); const modalRef = useRef(null); useHotkeys( @@ -333,7 +343,10 @@ const Models: React.FC = ({ const handleViewLogs = async (row: any) => { try { - setCurrentInstanceUrl(`${MODEL_INSTANCE_API}/${row.id}/logs`); + setCurrentInstance({ + url: `${MODEL_INSTANCE_API}/${row.id}/logs`, + status: row.status + }); setOpenLogModal(true); } catch (error) { console.log('error:', error); @@ -643,7 +656,8 @@ const Models: React.FC = ({ onOk={handleCreateModel} > diff --git a/src/pages/llmodels/components/view-logs-modal.tsx b/src/pages/llmodels/components/view-logs-modal.tsx index a59ae5a6..6eab5201 100644 --- a/src/pages/llmodels/components/view-logs-modal.tsx +++ b/src/pages/llmodels/components/view-logs-modal.tsx @@ -6,6 +6,7 @@ import React, { useEffect, useState } from 'react'; type ViewModalProps = { open: boolean; url: string; + autoScroll?: boolean; onCancel: () => void; }; @@ -65,6 +66,7 @@ const ViewCodeModal: React.FC = (props) => {