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(
{ user.settings.nickname }
:
{ msg }
{ files &&
}
);
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(
{ user.settings.site_prefs.model || 'err' }
:
);
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( );
if( isbottom )
terminal.scrollTop( terminal[0].scrollHeight );
}
// inserts a new code div
function appendCode( lang?: string ) {
if( lang ) {
$( ".chat-line" ).last().append(
{ lang }
);
} else {
$( ".chat-line" ).last().append(
);
}
}
function ToolInfo( props: any ) {
return
}
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 = $( );
const ti = $( );
ti.find( ".tool-call-title" ).append( );
el.append( ti );
el.append( );
$( ".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 = $( );
const trimmed = tool.name.replace( /\s+/g, '' );
switch( trimmed.toLowerCase() ) {
case 'notes':
el.append( );
el.append( { tool.parameters.contents || '' }
);
break;
case 'web':
el.append( );
el.append( { tool.parameters.url || '' }
);
if( appendLine ) needs_append = true;
break;
case 'remind':
el.append( );
el.append( { tool.parameters.keywords ? tool.parameters.keywords.join( ' ' ) : '' }
);
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( { e }
);
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 = '';
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 = '';
if( buf == wanted_end || (buf.length > wanted_end.length && buf.startsWith( wanted_end )) ) {
$( ".chat-line" ).last().append( { '\n' }
);
$( ".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(
{ lang }
);
}
}
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( );
}).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( );
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( );
}
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
}
function ChatFiles( props: any ) {
if( !props.files.length )
return
const show_remove = props.noremove === undefined;
return
{ props.files.map( ( f: MsgFile, i: number ) => {
if( !show_remove )
return
return
} ) }
}
export function ChatCmdLine() {
return
}
export function ChatInput( props: any ) {
const task = async () => {
const chat_id = getChatId();
if( !chat_id ) {
return;
}
disableInput();
$( "#terminal-inner" ).empty();
const 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 = $( );
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( );
}
const popup = $(
Chat deleted.
);
addPopup( popup );
} catch( e: any ) {
const popup = $(
Error deleting chat: { e.message }
);
addPopup( popup );
}
}
function showDeletePopup( id: string, name: string ) {
const popup = $( { deleteChat( id ) } }>
Are you sure you want to delete {name}?
);
addPopup( popup );
}
function ChatListEntry( props: any ) {
let title = props.title;
if( title.length > 20 ) {
title = title.substring( 0, 20 ) + '...';
}
return
JSX.navigateParams( "/terminal", props.id != '0' ? { id: props.id } : {} ) }>
{ title }
{
(() => {
if( props.id != 0 )
return
})()
}
}
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
} );
for( let e of entries )
$( "#chat-selection-sidebar" ).append( e );
} );
return
}