diff options
Diffstat (limited to 'moneyjsx/src/chat.tsx')
| -rw-r--r-- | moneyjsx/src/chat.tsx | 1023 |
1 files changed, 1023 insertions, 0 deletions
diff --git a/moneyjsx/src/chat.tsx b/moneyjsx/src/chat.tsx new file mode 100644 index 0000000..56798ab --- /dev/null +++ b/moneyjsx/src/chat.tsx @@ -0,0 +1,1023 @@ +import $ from 'jquery'; +import * as JSX from './jsx'; +import * as api from './api'; +import * as user from './user'; +import * as util from './util'; + +import { Spinner, OkCancelPopup, addPopup, OkPopup } from './components'; + +let chat_id = ''; +let chat_name = 'new chat'; // todo: use or rm +export let msglog: Msg[] = []; +export let file_list: MsgFile[] = []; +let hljs = null; +// todo: send done msg from server when tool limit is reached and dont append line +// to prevent empty spinner +let needs_append = false; +let in_code_block = false; +let in_reason_block = false; +let was_in_reason = false; +let reason_buf = ''; +let code_block_buf = ''; +let tool_buffer = ''; + +export interface MsgFile { + name: string, + type: string, + content: string +}; + +export interface Msg { + content: string, + role: string, + timestamp: string, + toolCall?: string, + images?: string[], + files?: MsgFile[], +}; + +export interface ClientOptions { + seed?: number, + temperature?: number, +}; + +export interface ChatReq { + model: string, + messages: Msg[], + system: string, + chatfile?: string, + options?: ClientOptions, + generateTitle: boolean, + token: string +}; + +export interface ToolCall { + name: string, + parameters: any +}; + +interface ChatJsonRes { + done: boolean, + chunks?: string[], + msgs?: Msg[], + title?: string, + err?: string, +}; + +function getChatId() { + const url = new URL( window.location.href ); + const id = url.searchParams.get( 'id' ); + if( !id ) { + chat_id = ''; + msglog = []; + return null; + } + + if( id == '0' ) { + chat_id = ''; + msglog = []; + return null; + } + + chat_id = id.slice(); + return chat_id; +} + +export async function createChat() { + const res = await user.createChat(); + chat_id = res.chatId; + chat_name = 'new chat'; + + JSX.pushParams( { id: chat_id } ); + await user.updatePrefs(); // todo : error handle this w text box when component made + return getChatId(); +} + +function isToolStr( str: string ) : boolean { + const model = api.getModelFromName( user.settings.site_prefs.model || '' ); + if( !model ) + return false; + + const trimmed = str.replace( /\s+/g, '' ).toLowerCase(); + + for( let tool in model.capabilities ) { + const str = `{"name":"${tool.toLowerCase()}"`; + let matched = true; + + for( let i = 0; i < Math.min( trimmed.length, str.length ); ++i ) { + if( trimmed[i] !== str[i] ) { + matched = false; + break; + } + } + + if( matched ) + return true; + } + + return false; +} + + +let chunk_buf = ''; +function parseChatJson( rawjson: string, is_done: boolean ) : ChatJsonRes { + let msgs = rawjson.split( '\n' ); + let clean_done = false; + let ret_msgs: Msg[] = []; + let ret_chunks: string[] = []; + let title: string = ''; + + for( let raw of msgs ) { + if( !raw.length ) + continue; + + let json = null; + try { + json = JSON.parse( chunk_buf + raw ); + } catch( e: any ) { + chunk_buf += raw; + return { done: false }; + } + + chunk_buf = ''; + + if( json.done ) + clean_done = true; + + if( !clean_done && is_done ) + return { done: true, err: "the stream was closed abruptly. please try again." }; + else if( json.status == 'error' || json.status == 'busy' ) + return { done: true, err: json.msg }; + + if( json.status != 'ok' ) + return { done: true, err: json.msg || 'unknown error' }; + + let chunk: string = json.response; + if( json.finalMsg && json.finalMsg.length > 0 ) { + ret_msgs.push( { timestamp: (new Date()).toLocaleString(), role: 'assistant', content: json.finalMsg } ); + if( json.title && !title.length ) + title = json.title; + } else if( json.tool ) { + ret_msgs.push( { timestamp: (new Date()).toLocaleString(), role: 'tool', content: chunk } ); + } else { + ret_chunks.push( chunk ); + } + } + + return { + done: clean_done || is_done, + title: title.length > 0 ? title : null, + msgs: ret_msgs, + chunks: ret_chunks + }; +} + +function appendUserLine( msg: string, files?: MsgFile[] ) { + const terminal = $( "#terminal-inner" ); + const isbottom = terminal[0].scrollTop == (terminal[0] as any).scrollTopMax; + $( "#terminal-inner" ).append( + <div class="chat-line"> + <div class="chat-role-user"> + { user.settings.nickname } + <span style="color:#fff">: </span> + </div> + <div class="chat-content"> + { msg } + </div> + { files && <ChatFiles id={ msglog.length.toString() } files={ files } noremove /> } + </div> + ); + + if( isbottom ) + terminal.scrollTop( terminal[0].scrollHeight ); +} + +function appendModelLine() { + const terminal = $( "#terminal-inner" ); + const isbottom = terminal[0].scrollTop == (terminal[0] as any).scrollTopMax; + + $( "#terminal-inner" ).append( <div class="chat-line"> + <div class="chat-role-model"> + { user.settings.site_prefs.model || 'err' } + <span style="color:#fff">: </span> + </div> + <div class="chat-content"> + <Spinner style="display: inline" /> + </div> + </div> ); + + if( isbottom ) + terminal.scrollTop( terminal[0].scrollHeight ); +} + +// inserts a new text div +function appendText() { + const terminal = $( "#terminal-inner" ); + const isbottom = terminal[0].scrollTop == (terminal[0] as any).scrollTopMax; + + $( ".chat-line" ).last().append( <div class="chat-content" /> ); + + if( isbottom ) + terminal.scrollTop( terminal[0].scrollHeight ); +} + +// inserts a new code div +function appendCode( lang?: string ) { + if( lang ) { + $( ".chat-line" ).last().append( + <pre> + <div class="chat-code-line">{ lang }</div> + <code class={ `language-${ lang }` }></code> + </pre> + ); + } else { + $( ".chat-line" ).last().append( <pre><code></code></pre> ); + } +} + + +function ToolInfo( props: any ) { + return <div class="tool-call-title-wrapper"> + <div class="tool-call-title"> + { props.title } + </div> + <div class="tool-call-collapse"> + { props.collapsed ? '+' : '-' } + </div> + </div> +} + +function appendReason() { + const collapseToggle = () => { + let list = el.find( '.reason-output' ); + let btn = el.find( '.tool-call-collapse' ); + + const open = list.css( 'display' ) == 'block'; + if( !open ) { + list.css( 'display', 'block' ); + btn.text( '-' ); + } else { + list.css( 'display', 'none' ); + btn.text( '+' ); + } + }; + + const el = $( <div class="tool-call" onclick={ collapseToggle } collapsed /> ); + const ti = $( <ToolInfo title="Reasoning... " /> ); + ti.find( ".tool-call-title" ).append( <Spinner style="display: inline" /> ); + el.append( ti ); + el.append( <div class="reason-output" style="display: none"></div> ); + + $( ".chat-line" ).last().find( ".chat-content" ).replaceWith( el ); +} + +function outputReason( buf: string ) { + const el = $( ".reason-output" ).last(); + const txt = el.text(); + el.text( txt + buf ); +} + +function outputTool( tool: ToolCall, appendLine?: boolean ) { + const content = $( ".chat-line" ).last().find( ".chat-content" ); + if( needs_append || content.text().length > 1 ) + appendModelLine(); + + const collapseToggle = () => { + let list = el.find( '.tool-call-list' ); + let btn = el.find( '.tool-call-collapse' ); + + const open = list.css( 'display' ) == 'block'; + if( !open ) { + list.css( 'display', 'block' ); + btn.text( '-' ); + } else { + list.css( 'display', 'none' ); + btn.text( '+' ); + } + }; + + const el = $( <div class="tool-call" onclick={ collapseToggle } /> ); + const trimmed = tool.name.replace( /\s+/g, '' ); + switch( trimmed.toLowerCase() ) { + case 'notes': + el.append( <ToolInfo title="Note saved" /> ); + el.append( <div class="tool-call-list">{ tool.parameters.contents || '' }</div> ); + break; + case 'web': + el.append( <ToolInfo title="Searching the web..." /> ); + el.append( <div class="tool-call-list">{ tool.parameters.url || '' }</div> ); + if( appendLine ) needs_append = true; + break; + case 'remind': + el.append( <ToolInfo title="Searching the conversation..." /> ); + el.append( <div class="tool-call-list">{ tool.parameters.keywords ? tool.parameters.keywords.join( ' ' ) : '' }</div> ); + if( appendLine ) needs_append = true; + break; + } + if( in_reason_block ) { + $( ".chat-line" ).last().find( ".tool-call" ).find( ".spinner" ).remove(); + $( ".chat-line" ).last().append( el ); + appendModelLine(); + needs_append = false; + appendReason(); + } + else { + $( ".chat-line" ).last().find( ".chat-content" ).replaceWith( el ); + } +} + +function outputError( e: string ) { + const terminal = $( "#terminal-inner" ); + const isbottom = terminal[0].scrollTop == (terminal[0] as any).scrollTopMax; + + if( needs_append ) { + appendModelLine(); + needs_append = false; + } + + let el = $( ".chat-line" ).last(); + el.empty(); + el.append( <div class="chat-error">{ e }</div> ); + + if( isbottom ) + terminal.scrollTop( terminal[0].scrollHeight ); +} + +function outputChunk( chunk: string ) { + const terminal = $( "#terminal-inner" ); + const isbottom = terminal[0].scrollTop == (terminal[0] as any).scrollTopMax; + + if( needs_append ) { + appendModelLine(); + needs_append = false; + } + + let el = $( ".chat-line" ).last().find( ".chat-content" ).last(); + el.find( '.spinner' ).remove(); + el.text( el.text() + chunk ); + + if( isbottom ) + terminal.scrollTop( terminal[0].scrollHeight ); +} + +function outputCode( chunk: string ) { + const terminal = $( "#terminal-inner" ); + const isbottom = terminal[0].scrollTop == (terminal[0] as any).scrollTopMax; + + if( needs_append ) { + appendModelLine(); + needs_append = false; + } + + let el = $( ".chat-line" ).last().find( "code" ).last(); + el.find( '.spinner' ).remove(); + el.text( el.text() + chunk ); + + if( hljs ) + hljs.highlightElement( el[0] ); + + if( isbottom ) + terminal.scrollTop( terminal[0].scrollHeight ); +} + +function checkToolBuffer( chunk: string ) { + tool_buffer += chunk; + + let tool: ToolCall | null = null; + if( isToolStr( tool_buffer ) ) { + try { + tool = JSON.parse( tool_buffer ); + outputTool( tool, true ); + tool_buffer = ''; + } catch( e: any ) {} + + return true; + } + + let buf = tool_buffer.slice(); + tool_buffer = ''; + return buf; +} + + +/// returns 1 and sets in_reason_block if the msg starts with a reason token +/// returns -1 if the msg does not match a reason token +/// returns 0 if the reason token is partially completed +function checkReasonBuffer( chunk: string ) { + const buf = chunk.toLowerCase(); + const wanted_begin = '<think>'; + if( !in_reason_block ) { + if( buf == wanted_begin || (buf.length > wanted_begin.length && buf.startsWith( wanted_begin )) ) { + appendReason(); + in_reason_block = true; + return 1; + } + if( wanted_begin.startsWith( buf ) ) + return 0; + + return -1; + } + + const wanted_end = '</think>'; + if( buf == wanted_end || (buf.length > wanted_end.length && buf.startsWith( wanted_end )) ) { + $( ".chat-line" ).last().append( <div class="chat-content">{ '\n' }</div> ); + $( ".tool-call" ).last().find( ".spinner" ).remove(); + in_reason_block = false; + was_in_reason = true; + return 1; + } + if( wanted_end.endsWith( buf ) ) + return 0; + + return -1; +} + +// todo: clean, static return type. maybe nullable string +function checkCodeBuffer( chunk: string ) { + const backtick = chunk.search( '`' ); + if( backtick != -1 ) { + code_block_buf += chunk.slice( backtick ); + return true; + } + + let buf = chunk; + let parts = chunk.split( '\n' ); + if( code_block_buf.startsWith( '```' ) ) { + if( parts.length <= 1 ) + return true; + + buf = parts[1]; + if( !in_code_block ) { + appendCode(); + const lang = parts[0].substring( 3 ); + if( lang.length > 0 ) { + $( ".chat-line" ).last().find( "code" ).insertBefore( + <div class="chat-code-line"> + { lang } + </div> + ); + } + } + else + appendText(); + in_code_block = !in_code_block; + code_block_buf = ''; + } + + return buf; +} + +function resetBuffers() { + in_reason_block = false; + in_code_block = false; + was_in_reason = false; + needs_append = false; + reason_buf = ''; + tool_buffer = ''; + code_block_buf = ''; +} + +// returns true when stream completes +function handleChatJson( res: ChatJsonRes ) : boolean { + const model = api.getModelFromName( user.settings.site_prefs.model! ); + tool_buffer = ''; + + if( res.err ) { + outputError( res.err ); + } + else if( res.chunks ) { + for( let chunk of res.chunks ) { + let buf = checkToolBuffer( chunk ); + if( buf === true ) + continue; + + if( model.capabilities.thinker ) { + const reason = checkReasonBuffer( buf ); + if( reason > -1 ) + continue; + + if( in_reason_block ) { + outputReason( buf ); + continue; + } + } + + if( was_in_reason ) { + while( buf.startsWith( '\n' ) ) + buf = buf.slice( 1 ); + } + + // todo: fix, this is str8 fkn voodoo . + buf = checkCodeBuffer( buf ); + if( buf === true ) + continue; + if( in_code_block ) + outputCode( buf ); + else + outputChunk( buf ); + } + } + + if( res.msgs ) { + for( let msg of res.msgs ) { + msglog.push( msg ); + } + } + + if( res.title ) { + user.updatePrefs().then( () => { + $( "#chat-list" ).replaceWith( <ChatList /> ); + }).catch( ( e: any ) => { + outputError( e.msg ); + }); + + updateChatTitle( res.title ); + } + + return res.done; +} + +function makeChatReq() : ChatReq { + return { + model: user.settings.site_prefs.model!, + messages: msglog, + system: user.settings.prompt_data.system || '', + chatfile: getChatId(), + token: localStorage.getItem( 'session' ), + generateTitle: true + }; +} + +export async function sendChatReq( cur_chat: string ) { + let res = null; + try { + const req = makeChatReq(); + res = await fetch( `${api.url}/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify( req ) + } ); + } catch( e: any ) { + return outputError( e.message ); + } + + if( !res.ok ) { + let json = null; + try{ + json = await res.json(); + } catch { + return outputError( `error contacting server (${res.statusCode}). are you connected to the internet?` ); + } + + return outputError( json.msg ); + } + + if( chat_id != cur_chat ) + return; + + const reader = res.body?.getReader(); + const decoder = new TextDecoder(); + if( !reader ) + return outputError( "can't read response" ); + + resetBuffers(); + return new Promise( ( resolve ) => { + const readLoop = async() => { + if( chat_id != cur_chat ) + return resolve( true ); + + const { value, done } = await reader.read(); + const raw = decoder.decode( value, { stream: true } ); + const parsed = parseChatJson( raw, done ); + if( handleChatJson( parsed ) ) + return resolve( true ); + + setTimeout( readLoop, 50 ); + } + + setTimeout( readLoop, 50 ); + } ); +} + +function disableInput() { $( "#chat-input" ).remove(); } +function appendInput() { + const terminal = $( "#terminal-inner" ); + const isbottom = terminal[0].scrollTop == (terminal[0] as any).scrollTopMax; + + $( "#terminal-inner" ).append( <ChatInput noupdate /> ); + + if( isbottom ) + terminal.scrollTop( terminal[0].scrollHeight ); +} + +export async function onChat( msg: string ) { + disableInput(); + + appendUserLine( msg, file_list ); + appendModelLine(); + let id = getChatId(); + if( !id ) { + try { id = await createChat() } + catch( e: any ) { + return outputError( "error creating chat: " + e.message ); + } + } + + if( file_list.length ) { + msglog.push( { timestamp: (new Date()).toLocaleString(), content: msg, role: 'user', files: file_list } ); + } + else { + msglog.push( { timestamp: (new Date()).toLocaleString(), content: msg, role: 'user' } ); + } + file_list = []; + await sendChatReq( id ); + + if( getChatId() == id ) + appendInput(); +} + +async function getChatHistory() { + if( !getChatId() ) + throw new Error( 'chat_id not set' ); + + try { + const res = await api.post( 'get-chat', { token: localStorage.getItem( 'session' ), chatId: chat_id } ); + return res; + } catch( e: any ) {} // todo: handle error ? + + return null; +} + +function updateChatTitle( name: string ) { + $( "#chat-name" ).text( name.substring( 0, 500 ) ); + let cur_name = name; + if( cur_name.length > 25 ) { + cur_name = cur_name.substring( 0, 25 ) + "..."; + } + + $( "#chat-selection-current" ).text( cur_name ); +} + +function outputChatTextForHistory( msg: string ) { + let chunks = msg.split( "```" ); + + let in_code = false; + for( let chunk of chunks ) { + if( !in_code ) { + appendText(); + outputChunk( chunk ); + } else { + const newl = chunk.indexOf( "\n" ); + let lang = null; + + if( newl != -1 ) { + lang = chunk.substring( 0, newl ); + chunk = chunk.substring( newl + 1 ); + } + + appendCode( lang ); + outputCode( chunk ); + } + + in_code = !in_code; + } +} + +function outputToolForHistory( msg: Msg ) { + let cur_idx = 0; + let open = -1; + let close = msg.content.lastIndexOf( '}' ); + open = msg.content.indexOf( "{", cur_idx ); // why is this assigned here & not on def ? also, cur_idx will never not be 0 ??? + + if( open == -1 || close == -1 ) { + outputChatTextForHistory( msg.content ); + return; + } + + let slice = msg.content.substring( open, close + 1 ); + try { + const parsed = JSON.parse( slice ); + if( open != 0 ) + outputChatTextForHistory( msg.content.substring( 0, open ) ); + if( parsed.name ) + outputTool( parsed ); + else + outputChatTextForHistory( msg.content ); + } catch( e ) { + outputChatTextForHistory( msg.content ); + } +} + +function outputChatHistory( history: Msg[] ) { + for( let msg of history ) { + if( msg.role == 'assistant' ) { + appendModelLine(); + const content = $( ".chat-line" ).last().find( ".chat-content" ); + content.empty(); + + if( msg.toolCall ) { + outputToolForHistory( msg ); + } + else { + outputChatTextForHistory( msg.content ); + } + } + else if( msg.role == 'user' ) { + appendUserLine( msg.content, msg.files ); + } + } + + setTimeout( () => { + const inner = $( "#terminal-inner" ); + inner.scrollTop( inner.prop( "scrollTopMax" ) ); + + if( history.length > 0 ) { + $( "#suggested-prompts" ).remove(); + } + } ); +} + +// don't ask me what the fuck is happening here, this is some javascript voodoo +// - nave +async function onInputKeyPress( e: KeyboardEvent ) { + let self = $( e.target as HTMLElement ); + const text = self[0].innerText; + + $( "#suggested-prompts" ).remove(); + + if( e.key == 'Enter' ) { + if( e.shiftKey ) + return; + + e.preventDefault(); + e.stopPropagation(); + + if( text.length > 0 ) { + await onChat( text ); + } + } +} + +function handleAttachments() { + $( "#cmd-files" ).remove(); + $( "#cmd" ).append( <ChatFiles id="cmd-files" files={ file_list } /> ); +} + +function onFileReadEvent( e: Event, img: boolean, name: string ) { + let contents = (e.target as any).result; + let b64 = contents.split( 'base64,' )[1]; + + if( img ) { + return; + } + + let txt = atob( b64 ); + if( txt.length > 1024 * 20 ) // todo: arbitrary change prolly + return; + + file_list.push( { name, type: 'text', content: txt } ); + handleAttachments(); +} + +async function onPaste( e: ClipboardEvent ) { + const model = api.getModelFromName( user.settings.site_prefs.model ); + if( !model ) return; + + const vision = !!model.capabilities.vision; + const items = e.clipboardData.items; + + if( !items || !items.length ) + return; + + for( let it of items ) { + if( it.type == 'text/html' ) + continue; + + if( it.kind.startsWith( 'string' ) ) { + let s = e.clipboardData.getData( 'text' ); + if( s.length > 512 ) { + e.preventDefault(); + file_list.push( { + name: `file${file_list.length}.txt`, + content: s, + type: 'text' + } ); + handleAttachments(); + } + } + else { + let isImg = false; + if( it.type.startsWith( 'image' ) ) { + if( !vision ) + continue; + isImg = true; + } + + let blob = it.getAsFile(); + let reader = new FileReader(); + + reader.onload = ( e ) => onFileReadEvent( e, isImg, blob.name ); + reader.readAsDataURL( blob ); + e.preventDefault(); + } + } +} + +function removeFile( key: number ) { + file_list.splice( key, 1 ); + + handleAttachments(); +} + +function ChatFile( props: any ) { + return <div id={ props.id } class="cmd-file"> + <span class="cmd-file-name">{ props.name.toString() }</span> + <span class="cmd-file-size">{ util.sizeHumanReadable( props.size ) }</span> + { props.key !== undefined && + <a class="cmd-file-remove" + onclick={ () => removeFile( props.key ) } + href="#" + > + remove + </a> + } + </div> +} + +function ChatFiles( props: any ) { + if( !props.files.length ) + return <div /> + + const show_remove = props.noremove === undefined; + + return <div id={ props.id } class='cmd-files'> + <div class="cmd-files-header"> + Attachments: + </div> + { props.files.map( ( f: MsgFile, i: number ) => { + if( !show_remove ) + return <ChatFile id={ `${props.id}-${i}` } size={ f.content.length } name={ f.name } /> + return <ChatFile id={ `${props.id}-${i}` } size={ f.content.length } name={ f.name } key={i} /> + } ) } + </div> +} + +export function ChatCmdLine() { + return <div id="cmd-input" contenteditable spellcheck="false" + onkeydown={ onInputKeyPress } + onpaste={ onPaste } + > + <span id="input-content"><br /></span> + </div> +} + +export function ChatInput( props: any ) { + const task = async () => { + const chat_id = getChatId(); + if( !chat_id ) { + return; + } + + disableInput(); + $( "#terminal-inner" ).empty(); + const spinner = $( <Spinner class="chat-spinner" /> ); + $( "#terminal-inner" ).append( spinner ); + + const chat = await getChatHistory(); + if( !chat ) { + msglog = []; + spinner.remove(); + return appendInput(); + } + + try { + msglog = JSON.parse( chat.contents ); + outputChatHistory( JSON.parse( chat.contents ) ); + } catch( e: any ) { + msglog = []; + } + + updateChatTitle( chat.name ); + spinner.remove(); + setTimeout( appendInput ); + }; + + if( !props.noupdate ) + setTimeout( task ); + + const ret = $( <div class="chat-line" id="chat-input"> + <div id="cmd"> + <div class="chat-role-user" style="width: fit-content; float: left"> + { user.settings.nickname }<span style="color: #fff">:</span> + </div> + <ChatCmdLine /> + <div style="clear: both" /> + </div> + </div> ); + + return ret[0]; +} + +function onSidebarHover() { $( "#chat-selection-sidebar" ).show(); } +function onSidebarLeave() { $( "#chat-selection-sidebar" ).hide(); } + +async function deleteChat( id: string ) { + try { + await user.deleteChat( id ); + await user.updatePrefs(); + + if( id == getChatId() ) + JSX.navigateParams( "/terminal", {} ); + else { + $( "#chat-list" ).replaceWith( <ChatList /> ); + } + + const popup = $( <OkPopup> + Chat deleted. + </OkPopup> ); + + addPopup( popup ); + } catch( e: any ) { + const popup = $( <OkPopup> + Error deleting chat: { e.message } + </OkPopup> ); + + addPopup( popup ); + } +} + +function showDeletePopup( id: string, name: string ) { + const popup = $( <OkCancelPopup onclick={ () => { deleteChat( id ) } }> + <div> + Are you sure you want to delete {name}? + </div> + </OkCancelPopup> ); + + addPopup( popup ); +} + +function ChatListEntry( props: any ) { + let title = props.title; + if( title.length > 20 ) { + title = title.substring( 0, 20 ) + '...'; + } + + return <div class="chat-item" id={ 'chatbtn-' + props.id } style={ props.style }> + <div class="chat-name" onclick={ () => JSX.navigateParams( "/terminal", props.id != '0' ? { id: props.id } : {} ) }> + <a>{ title }</a> + </div> + { + (() => { + if( props.id != 0 ) + return <div class="chat-delete"> + <a onclick={ () => showDeletePopup( props.id, props.title ) }>🗑</a> + </div> + })() + } + </div> +} + +let hljs_imported = 0; +async function importHlJs() { + if( hljs ) return; + if( hljs_imported ) return; + + hljs_imported = 1; + hljs = ( await import( 'highlight.js' ) ).default; + + $( ".chat-line.code" ).each( ( e ) => { + hljs.highlightElement( e[0] ); + } ); +} + +export function ChatList() { + if( !hljs ) { + importHlJs(); + } + + setTimeout( () => { + if( !user.settings.chat_files || !user.settings.chat_files.files ) + return; + + const files = user.settings.chat_files.files; + const entries = files.reverse().map( ( file: user.ChatFile ) => { + return <ChatListEntry id={ file.id } title={ file.name } /> + } ); + for( let e of entries ) + $( "#chat-selection-sidebar" ).append( e ); + } ); + + return <div id="chat-list"> + <div id="chat-selection"> + current chat + <div id="chat-selection-inner" onmouseenter={ onSidebarHover } ontouchstart={ onSidebarHover }> + <div> + <span id="chat-selection-current">new chat</span> + </div> + </div> + </div> + <div id="chat-selection-sidebar" style="display: none" onmouseleave={ onSidebarLeave } ontouchend={ onSidebarLeave } ontouchcancel={ onSidebarLeave }> + <ChatListEntry id={ '0' } title={ "new chat [*]" } style="padding-top: 5px" /> + </div> + </div> +} |
