summaryrefslogtreecommitdiff
path: root/moneyjsx/src/chat.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'moneyjsx/src/chat.tsx')
-rw-r--r--moneyjsx/src/chat.tsx1023
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">:&nbsp;</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">:&nbsp;</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>&nbsp;
+ <span class="cmd-file-size">{ util.sizeHumanReadable( props.size ) }</span>&nbsp;
+ { 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>&nbsp;
+ </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>
+}