llama.cpp/tools/server/public_simplechat/simplechat.js

1160 lines
40 KiB
JavaScript

// @ts-check
// A simple completions and chat/completions test related web front end logic
// by Humans for All
import * as du from "./datautils.mjs";
import * as ui from "./ui.mjs"
import * as tools from "./tools.mjs"
class Roles {
static System = "system";
static User = "user";
static Assistant = "assistant";
static Tool = "tool";
}
class ApiEP {
static Type = {
Chat: "chat",
Completion: "completion",
}
/** @type {Object<string, string>} */
static UrlSuffix = {
'chat': `/chat/completions`,
'completion': `/completions`,
}
/**
* Build the url from given baseUrl and apiEp id.
* @param {string} baseUrl
* @param {string} apiEP
*/
static Url(baseUrl, apiEP) {
if (baseUrl.endsWith("/")) {
baseUrl = baseUrl.substring(0, baseUrl.length-1);
}
return `${baseUrl}${this.UrlSuffix[apiEP]}`;
}
}
/**
* @typedef {{id: string, type: string, function: {name: string, arguments: string}}} NSToolCalls
*/
/**
* @typedef {{role: string, content: string, tool_calls: Array<NSToolCalls>}} NSChatMessage
*/
class ChatMessageEx {
/**
* Represent a Message in the Chat
* @param {string} role
* @param {string} content
* @param {Array<any>} tool_calls
* @param {string} trimmedContent
*/
constructor(role = "", content="", tool_calls=[], trimmedContent="") {
/** @type {NSChatMessage} */
this.ns = { role: role, content: content, tool_calls: tool_calls }
this.trimmedContent = trimmedContent;
}
/**
* Create a new instance from an existing instance
* @param {ChatMessageEx} old
*/
static newFrom(old) {
return new ChatMessageEx(old.ns.role, old.ns.content, old.ns.tool_calls, old.trimmedContent)
}
clear() {
this.ns.role = "";
this.ns.content = "";
this.ns.tool_calls = [];
this.trimmedContent = "";
}
/**
* Create a all in one tool call result string
* @param {string} toolCallId
* @param {string} toolName
* @param {string} toolResult
*/
static createToolCallResultAllInOne(toolCallId, toolName, toolResult) {
return `<tool_response> <id>${toolCallId}</id> <name>${toolName}</name> <content>${toolResult}</content> </tool_response>`;
}
/**
* Extract the elements of the all in one tool call result string
* @param {string} allInOne
*/
static extractToolCallResultAllInOneSimpleMinded(allInOne) {
const regex = /<tool_response>\s*<id>(.*?)<\/id>\s*<name>(.*?)<\/name>\s*<content>([\s\S]*?)<\/content>\s*<\/tool_response>/si;
const caught = allInOne.match(regex)
let data = { tool_call_id: "Error", name: "Error", content: "Error" }
if (caught) {
data = {
tool_call_id: caught[1].trim(),
name: caught[2].trim(),
content: caught[3].trim()
}
}
return data
}
/**
* Extract the elements of the all in one tool call result string
* This should potentially account for content tag having xml content within to an extent.
* @param {string} allInOne
*/
static extractToolCallResultAllInOne(allInOne) {
const dParser = new DOMParser();
const got = dParser.parseFromString(allInOne, 'text/xml');
const parseErrors = got.querySelector('parseerror')
if (parseErrors) {
console.debug("WARN:ChatMessageEx:ExtractToolCallResultAllInOne:", parseErrors.textContent.trim())
}
const id = got.querySelector('id')?.textContent.trim();
const name = got.querySelector('name')?.textContent.trim();
const content = got.querySelector('content')?.textContent.trim();
let data = {
tool_call_id: id? id : "Error",
name: name? name : "Error",
content: content? content : "Error"
}
return data
}
/**
* Set extra members into the ns object
* @param {string | number} key
* @param {any} value
*/
ns_set_extra(key, value) {
// @ts-ignore
this.ns[key] = value
}
/**
* Remove specified key and its value from ns object
* @param {string | number} key
*/
ns_delete(key) {
// @ts-ignore
delete(this.ns[key])
}
/**
* Update based on the drip by drip data got from network in streaming mode.
* Tries to support both Chat and Completion endpoints
* @param {any} nwo
* @param {string} apiEP
*/
update_stream(nwo, apiEP) {
console.debug(nwo, apiEP)
if (apiEP == ApiEP.Type.Chat) {
if (nwo["choices"][0]["finish_reason"] === null) {
let content = nwo["choices"][0]["delta"]["content"];
if (content !== undefined) {
if (content !== null) {
this.ns.content += content;
} else {
this.ns.role = nwo["choices"][0]["delta"]["role"];
}
} else {
let toolCalls = nwo["choices"][0]["delta"]["tool_calls"];
if (toolCalls !== undefined) {
if (toolCalls[0]["function"]["name"] !== undefined) {
this.ns.tool_calls.push(toolCalls[0]);
/*
this.ns.tool_calls[0].function.name = toolCalls[0]["function"]["name"];
this.ns.tool_calls[0].id = toolCalls[0]["id"];
this.ns.tool_calls[0].type = toolCalls[0]["type"];
this.ns.tool_calls[0].function.arguments = toolCalls[0]["function"]["arguments"]
*/
} else {
if (toolCalls[0]["function"]["arguments"] !== undefined) {
this.ns.tool_calls[0].function.arguments += toolCalls[0]["function"]["arguments"];
}
}
}
}
}
} else {
try {
this.ns.content += nwo["choices"][0]["text"];
} catch {
this.ns.content += nwo["content"];
}
}
}
/**
* Update based on the data got from network in oneshot mode
* @param {any} nwo
* @param {string} apiEP
*/
update_oneshot(nwo, apiEP) {
if (apiEP == ApiEP.Type.Chat) {
let curContent = nwo["choices"][0]["message"]["content"];
if (curContent != undefined) {
if (curContent != null) {
this.ns.content = curContent;
}
}
let curTCs = nwo["choices"][0]["message"]["tool_calls"];
if (curTCs != undefined) {
this.ns.tool_calls = curTCs;
}
} else {
try {
this.ns.content = nwo["choices"][0]["text"];
} catch {
this.ns.content = nwo["content"];
}
}
}
has_toolcall() {
if (this.ns.tool_calls.length == 0) {
return false
}
return true
}
content_equiv() {
if (this.ns.content !== "") {
return this.ns.content;
} else if (this.has_toolcall()) {
return `<tool_call>\n<tool_name>${this.ns.tool_calls[0].function.name}</tool_name>\n<tool_args>${this.ns.tool_calls[0].function.arguments}</tool_args>\n</tool_call>`;
} else {
return ""
}
}
}
let gUsageMsg = `
<p class="role-system">Usage</p>
<ul class="ul1">
<li> System prompt above, to try control ai response characteristics.</li>
<ul class="ul2">
<li> Completion mode - no system prompt normally.</li>
</ul>
<li> Use shift+enter for inserting enter/newline.</li>
<li> Enter your query to ai assistant in textarea provided below.</li>
<li> If ai assistant requests a tool call, varify same before triggering it.</li>
<ul class="ul2">
<li> submit tool response placed into user query textarea</li>
</ul>
<li> Default ContextWindow = [System, Last9 Query+Resp, Cur Query].</li>
<ul class="ul2">
<li> ChatHistInCtxt, MaxTokens, ModelCtxt window to expand</li>
</ul>
</ul>
`;
/** @typedef {ChatMessageEx[]} ChatMessages */
/** @typedef {{iLastSys: number, xchat: ChatMessages}} SimpleChatODS */
class SimpleChat {
/**
* @param {string} chatId
*/
constructor(chatId) {
this.chatId = chatId;
/**
* Maintain in a form suitable for common LLM web service chat/completions' messages entry
* @type {ChatMessages}
*/
this.xchat = [];
this.iLastSys = -1;
this.latestResponse = new ChatMessageEx();
}
clear() {
this.xchat = [];
this.iLastSys = -1;
}
ods_key() {
return `SimpleChat-${this.chatId}`
}
save() {
/** @type {SimpleChatODS} */
let ods = {iLastSys: this.iLastSys, xchat: this.xchat};
localStorage.setItem(this.ods_key(), JSON.stringify(ods));
}
load() {
let sods = localStorage.getItem(this.ods_key());
if (sods == null) {
return;
}
/** @type {SimpleChatODS} */
let ods = JSON.parse(sods);
this.iLastSys = ods.iLastSys;
this.xchat = [];
for (const cur of ods.xchat) {
if (cur.ns == undefined) {
/** @typedef {{role: string, content: string}} OldChatMessage */
let tcur = /** @type {OldChatMessage} */(/** @type {unknown} */(cur));
this.xchat.push(new ChatMessageEx(tcur.role, tcur.content))
} else {
this.xchat.push(new ChatMessageEx(cur.ns.role, cur.ns.content, cur.ns.tool_calls, cur.trimmedContent))
}
}
}
/**
* Recent chat messages.
* If iRecentUserMsgCnt < 0
* Then return the full chat history
* Else
* Return chat messages from latest going back till the last/latest system prompt.
* While keeping track that the number of user queries/messages doesnt exceed iRecentUserMsgCnt.
* @param {number} iRecentUserMsgCnt
*/
recent_chat(iRecentUserMsgCnt) {
if (iRecentUserMsgCnt < 0) {
return this.xchat;
}
if (iRecentUserMsgCnt == 0) {
console.warn("WARN:SimpleChat:SC:RecentChat:iRecentUsermsgCnt of 0 means no user message/query sent");
}
/** @type{ChatMessages} */
let rchat = [];
let sysMsg = this.get_system_latest();
if (sysMsg.ns.content.length != 0) {
rchat.push(sysMsg)
}
let iUserCnt = 0;
let iStart = this.xchat.length;
for(let i=this.xchat.length-1; i > this.iLastSys; i--) {
if (iUserCnt >= iRecentUserMsgCnt) {
break;
}
let msg = this.xchat[i];
if (msg.ns.role == Roles.User) {
iStart = i;
iUserCnt += 1;
}
}
for(let i = iStart; i < this.xchat.length; i++) {
let msg = this.xchat[i];
if (msg.ns.role == Roles.System) {
continue;
}
rchat.push(msg)
}
return rchat;
}
/**
* Return recent chat messages in the format,
* which can be directly sent to the ai server.
* @param {number} iRecentUserMsgCnt - look at recent_chat for semantic
*/
recent_chat_ns(iRecentUserMsgCnt) {
let xchat = this.recent_chat(iRecentUserMsgCnt);
let chat = [];
for (const msg of xchat) {
let tmsg = ChatMessageEx.newFrom(msg);
if (!tmsg.has_toolcall()) {
tmsg.ns_delete("tool_calls")
}
if (tmsg.ns.role == Roles.Tool) {
let res = ChatMessageEx.extractToolCallResultAllInOne(tmsg.ns.content)
tmsg.ns.content = res.content
tmsg.ns_set_extra("tool_call_id", res.tool_call_id)
tmsg.ns_set_extra("name", res.name)
}
chat.push(tmsg.ns);
}
return chat
}
/**
* Add an entry into xchat.
* NOTE: A new copy is created and added into xchat.
* Also update iLastSys system prompt index tracker
* @param {ChatMessageEx} chatMsg
*/
add(chatMsg) {
this.xchat.push(ChatMessageEx.newFrom(chatMsg));
if (chatMsg.ns.role == Roles.System) {
this.iLastSys = this.xchat.length - 1;
}
this.save();
return true;
}
/**
* Show the chat contents in the specified div.
* If requested to clear prev stuff and inturn no chat content then show
* * usage info
* * option to load prev saved chat if any
* * as well as settings/info.
* @param {HTMLDivElement} div
* @param {boolean} bClear
* @param {boolean} bShowInfoAll
*/
show(div, bClear=true, bShowInfoAll=false) {
if (bClear) {
div.replaceChildren();
}
let last = undefined;
for(const x of this.recent_chat(gMe.iRecentUserMsgCnt)) {
let entry = ui.el_create_append_p(`${x.ns.role}: ${x.content_equiv()}`, div);
entry.className = `role-${x.ns.role}`;
last = entry;
}
if (last !== undefined) {
last.scrollIntoView(false);
} else {
if (bClear) {
div.innerHTML = gUsageMsg;
gMe.setup_load(div, this);
gMe.show_info(div, bShowInfoAll);
}
}
return last;
}
/**
* Setup the fetch headers.
* It picks the headers from gMe.headers.
* It inserts Authorization only if its non-empty.
* @param {string} apiEP
*/
fetch_headers(apiEP) {
let headers = new Headers();
for(let k in gMe.headers) {
let v = gMe.headers[k];
if ((k == "Authorization") && (v.trim() == "")) {
continue;
}
headers.append(k, v);
}
return headers;
}
/**
* Add needed fields wrt json object to be sent wrt LLM web services completions endpoint.
* The needed fields/options are picked from a global object.
* Add optional stream flag, if required.
* Convert the json into string.
* @param {Object<string, any>} obj
*/
request_jsonstr_extend(obj) {
for(let k in gMe.apiRequestOptions) {
obj[k] = gMe.apiRequestOptions[k];
}
if (gMe.bStream) {
obj["stream"] = true;
}
if (gMe.tools.enabled) {
obj["tools"] = tools.meta();
}
return JSON.stringify(obj);
}
/**
* Return a string form of json object suitable for chat/completions
*/
request_messages_jsonstr() {
let req = {
messages: this.recent_chat_ns(gMe.iRecentUserMsgCnt),
}
return this.request_jsonstr_extend(req);
}
/**
* Return a string form of json object suitable for /completions
* @param {boolean} bInsertStandardRolePrefix Insert "<THE_ROLE>: " as prefix wrt each role's message
*/
request_prompt_jsonstr(bInsertStandardRolePrefix) {
let prompt = "";
let iCnt = 0;
for(const msg of this.recent_chat(gMe.iRecentUserMsgCnt)) {
iCnt += 1;
if (iCnt > 1) {
prompt += "\n";
}
if (bInsertStandardRolePrefix) {
prompt += `${msg.ns.role}: `;
}
prompt += `${msg.ns.content}`;
}
let req = {
prompt: prompt,
}
return this.request_jsonstr_extend(req);
}
/**
* Return a string form of json object suitable for specified api endpoint.
* @param {string} apiEP
*/
request_jsonstr(apiEP) {
if (apiEP == ApiEP.Type.Chat) {
return this.request_messages_jsonstr();
} else {
return this.request_prompt_jsonstr(gMe.bCompletionInsertStandardRolePrefix);
}
}
/**
* Allow setting of system prompt, at any time.
* Updates the system prompt, if one was never set or if the newly passed is different from the last set system prompt.
* @param {string} sysPrompt
* @param {string} msgTag
*/
add_system_anytime(sysPrompt, msgTag) {
if (sysPrompt.length <= 0) {
return false;
}
if (this.iLastSys < 0) {
return this.add(new ChatMessageEx(Roles.System, sysPrompt));
}
let lastSys = this.xchat[this.iLastSys].ns.content;
if (lastSys !== sysPrompt) {
return this.add(new ChatMessageEx(Roles.System, sysPrompt));
}
return false;
}
/**
* Retrieve the latest system prompt related chat message entry.
*/
get_system_latest() {
if (this.iLastSys == -1) {
return new ChatMessageEx(Roles.System);
}
return this.xchat[this.iLastSys];
}
/**
* Handle the multipart response from server/ai-model
* @param {Response} resp
* @param {string} apiEP
* @param {HTMLDivElement} elDiv
*/
async handle_response_multipart(resp, apiEP, elDiv) {
let elP = ui.el_create_append_p("", elDiv);
if (!resp.body) {
throw Error("ERRR:SimpleChat:SC:HandleResponseMultiPart:No body...");
}
let tdUtf8 = new TextDecoder("utf-8");
let rr = resp.body.getReader();
this.latestResponse.clear()
this.latestResponse.ns.role = Roles.Assistant
let xLines = new du.NewLines();
while(true) {
let { value: cur, done: done } = await rr.read();
if (cur) {
let curBody = tdUtf8.decode(cur, {stream: true});
console.debug("DBUG:SC:PART:Str:", curBody);
xLines.add_append(curBody);
}
while(true) {
let curLine = xLines.shift(!done);
if (curLine == undefined) {
break;
}
if (curLine.trim() == "") {
continue;
}
if (curLine.startsWith("data:")) {
curLine = curLine.substring(5);
}
if (curLine.trim() === "[DONE]") {
break;
}
let curJson = JSON.parse(curLine);
console.debug("DBUG:SC:PART:Json:", curJson);
this.latestResponse.update_stream(curJson, apiEP);
}
elP.innerText = this.latestResponse.content_equiv()
elP.scrollIntoView(false);
if (done) {
break;
}
}
console.debug("DBUG:SC:PART:Full:", this.latestResponse.content_equiv());
return ChatMessageEx.newFrom(this.latestResponse);
}
/**
* Handle the oneshot response from server/ai-model
* @param {Response} resp
* @param {string} apiEP
*/
async handle_response_oneshot(resp, apiEP) {
let respBody = await resp.json();
console.debug(`DBUG:SimpleChat:SC:${this.chatId}:HandleUserSubmit:RespBody:${JSON.stringify(respBody)}`);
let cm = new ChatMessageEx(Roles.Assistant)
cm.update_oneshot(respBody, apiEP)
return cm
}
/**
* Handle the response from the server be it in oneshot or multipart/stream mode.
* Also take care of the optional garbage trimming.
* TODO: Need to handle tool calling and related flow, including how to show
* the assistant's request for tool calling and the response from tool.
* @param {Response} resp
* @param {string} apiEP
* @param {HTMLDivElement} elDiv
*/
async handle_response(resp, apiEP, elDiv) {
let theResp = null;
if (gMe.bStream) {
try {
theResp = await this.handle_response_multipart(resp, apiEP, elDiv);
this.latestResponse.clear();
} catch (error) {
theResp = this.latestResponse;
theResp.ns.role = Roles.Assistant;
this.add(theResp);
this.latestResponse.clear();
throw error;
}
} else {
theResp = await this.handle_response_oneshot(resp, apiEP);
}
if (gMe.bTrimGarbage) {
let origMsg = theResp.ns.content;
theResp.ns.content = du.trim_garbage_at_end(origMsg);
theResp.trimmedContent = origMsg.substring(theResp.ns.content.length);
}
theResp.ns.role = Roles.Assistant;
this.add(theResp);
return theResp;
}
/**
* Call the requested tool/function.
* Returns undefined, if the call was placed successfully
* Else some appropriate error message will be returned.
* @param {string} toolcallid
* @param {string} toolname
* @param {string} toolargs
*/
async handle_toolcall(toolcallid, toolname, toolargs) {
if (toolname === "") {
return "Tool/Function call name not specified"
}
try {
return await tools.tool_call(toolcallid, toolname, toolargs)
} catch (/** @type {any} */error) {
return `Tool/Function call raised an exception:${error.name}:${error.message}`
}
}
}
class MultiChatUI {
constructor() {
/** @type {Object<string, SimpleChat>} */
this.simpleChats = {};
/** @type {string} */
this.curChatId = "";
// the ui elements
this.elInSystem = /** @type{HTMLInputElement} */(document.getElementById("system-in"));
this.elDivChat = /** @type{HTMLDivElement} */(document.getElementById("chat-div"));
this.elBtnUser = /** @type{HTMLButtonElement} */(document.getElementById("user-btn"));
this.elInUser = /** @type{HTMLInputElement} */(document.getElementById("user-in"));
this.elDivHeading = /** @type{HTMLSelectElement} */(document.getElementById("heading"));
this.elDivSessions = /** @type{HTMLDivElement} */(document.getElementById("sessions-div"));
this.elBtnSettings = /** @type{HTMLButtonElement} */(document.getElementById("settings"));
this.elDivTool = /** @type{HTMLDivElement} */(document.getElementById("tool-div"));
this.elBtnTool = /** @type{HTMLButtonElement} */(document.getElementById("tool-btn"));
this.elInToolName = /** @type{HTMLInputElement} */(document.getElementById("toolname-in"));
this.elInToolArgs = /** @type{HTMLInputElement} */(document.getElementById("toolargs-in"));
this.validate_element(this.elInSystem, "system-in");
this.validate_element(this.elDivChat, "chat-div");
this.validate_element(this.elInUser, "user-in");
this.validate_element(this.elDivHeading, "heading");
this.validate_element(this.elDivChat, "sessions-div");
this.validate_element(this.elBtnSettings, "settings");
this.validate_element(this.elDivTool, "tool-div");
this.validate_element(this.elInToolName, "toolname-in");
this.validate_element(this.elInToolArgs, "toolargs-in");
this.validate_element(this.elBtnTool, "tool-btn");
}
/**
* Check if the element got
* @param {HTMLElement | null} el
* @param {string} msgTag
*/
validate_element(el, msgTag) {
if (el == null) {
throw Error(`ERRR:SimpleChat:MCUI:${msgTag} element missing in html...`);
} else {
// @ts-ignore
console.debug(`INFO:SimpleChat:MCUI:${msgTag} Id[${el.id}] Name[${el["name"]}]`);
}
}
/**
* Reset/Setup Tool Call UI parts as needed
* @param {ChatMessageEx} ar
*/
ui_reset_toolcall_as_needed(ar) {
if (ar.has_toolcall()) {
this.elDivTool.hidden = false
this.elInToolName.value = ar.ns.tool_calls[0].function.name
this.elInToolName.dataset.tool_call_id = ar.ns.tool_calls[0].id
this.elInToolArgs.value = ar.ns.tool_calls[0].function.arguments
this.elBtnTool.disabled = false
} else {
this.elDivTool.hidden = true
this.elInToolName.value = ""
this.elInToolName.dataset.tool_call_id = ""
this.elInToolArgs.value = ""
this.elBtnTool.disabled = true
}
}
/**
* Reset user input ui.
* * clear user input (if requested, default true)
* * enable user input
* * set focus to user input
* @param {boolean} [bClearElInUser=true]
*/
ui_reset_userinput(bClearElInUser=true) {
if (bClearElInUser) {
this.elInUser.value = "";
}
this.elInUser.disabled = false;
this.elInUser.focus();
}
/**
* Setup the needed callbacks wrt UI, curChatId to defaultChatId and
* optionally switch to specified defaultChatId.
* @param {string} defaultChatId
* @param {boolean} bSwitchSession
*/
setup_ui(defaultChatId, bSwitchSession=false) {
this.curChatId = defaultChatId;
if (bSwitchSession) {
this.handle_session_switch(this.curChatId);
}
this.ui_reset_toolcall_as_needed(new ChatMessageEx());
this.elBtnSettings.addEventListener("click", (ev)=>{
this.elDivChat.replaceChildren();
gMe.show_settings(this.elDivChat);
});
this.elBtnUser.addEventListener("click", (ev)=>{
if (this.elInUser.disabled) {
return;
}
this.handle_user_submit(this.curChatId, gMe.apiEP).catch((/** @type{Error} */reason)=>{
let msg = `ERRR:SimpleChat\nMCUI:HandleUserSubmit:${this.curChatId}\n${reason.name}:${reason.message}`;
console.error(msg.replace("\n", ":"));
alert(msg);
this.ui_reset_userinput();
});
});
this.elBtnTool.addEventListener("click", (ev)=>{
if (this.elDivTool.hidden) {
return;
}
this.handle_tool_run(this.curChatId);
})
tools.setup((id, name, data)=>{
clearTimeout(this.idTimeOut)
this.elInUser.value = ChatMessageEx.createToolCallResultAllInOne(id, name, data);
this.ui_reset_userinput(false)
})
this.elInUser.addEventListener("keyup", (ev)=> {
// allow user to insert enter into their message using shift+enter.
// while just pressing enter key will lead to submitting.
if ((ev.key === "Enter") && (!ev.shiftKey)) {
let value = this.elInUser.value;
this.elInUser.value = value.substring(0,value.length-1);
this.elBtnUser.click();
ev.preventDefault();
}
});
this.elInSystem.addEventListener("keyup", (ev)=> {
// allow user to insert enter into the system prompt using shift+enter.
// while just pressing enter key will lead to setting the system prompt.
if ((ev.key === "Enter") && (!ev.shiftKey)) {
let value = this.elInSystem.value;
this.elInSystem.value = value.substring(0,value.length-1);
let chat = this.simpleChats[this.curChatId];
chat.add_system_anytime(this.elInSystem.value, this.curChatId);
chat.show(this.elDivChat);
ev.preventDefault();
}
});
}
/**
* Setup a new chat session and optionally switch to it.
* @param {string} chatId
* @param {boolean} bSwitchSession
*/
new_chat_session(chatId, bSwitchSession=false) {
this.simpleChats[chatId] = new SimpleChat(chatId);
if (bSwitchSession) {
this.handle_session_switch(chatId);
}
}
/**
* Handle user query submit request, wrt specified chat session.
* NOTE: Currently the user query entry area is used for
* * showing and allowing edits by user wrt tool call results
* in a predfined simple xml format,
* ie before they submit tool result to ai engine on server
* * as well as for user to enter their own queries.
* Based on presence of the predefined xml format data at beginning
* the logic will treat it has a tool result and if not then as a
* normal user query.
* @param {string} chatId
* @param {string} apiEP
*/
async handle_user_submit(chatId, apiEP) {
let chat = this.simpleChats[chatId];
// In completion mode, if configured, clear any previous chat history.
// So if user wants to simulate a multi-chat based completion query,
// they will have to enter the full thing, as a suitable multiline
// user input/query.
if ((apiEP == ApiEP.Type.Completion) && (gMe.bCompletionFreshChatAlways)) {
chat.clear();
}
this.ui_reset_toolcall_as_needed(new ChatMessageEx());
chat.add_system_anytime(this.elInSystem.value, chatId);
let content = this.elInUser.value;
if (content.trim() == "") {
console.debug(`WARN:SimpleChat:MCUI:${chatId}:HandleUserSubmit:Ignoring empty user input...`);
return;
}
if (content.startsWith("<tool_response>")) {
chat.add(new ChatMessageEx(Roles.Tool, content))
} else {
chat.add(new ChatMessageEx(Roles.User, content))
}
chat.show(this.elDivChat);
let theUrl = ApiEP.Url(gMe.baseURL, apiEP);
let theBody = chat.request_jsonstr(apiEP);
this.elInUser.value = "working...";
this.elInUser.disabled = true;
console.debug(`DBUG:SimpleChat:MCUI:${chatId}:HandleUserSubmit:${theUrl}:ReqBody:${theBody}`);
let theHeaders = chat.fetch_headers(apiEP);
let resp = await fetch(theUrl, {
method: "POST",
headers: theHeaders,
body: theBody,
});
let theResp = await chat.handle_response(resp, apiEP, this.elDivChat);
if (chatId == this.curChatId) {
chat.show(this.elDivChat);
if (theResp.trimmedContent.length > 0) {
let p = ui.el_create_append_p(`TRIMMED:${theResp.trimmedContent}`, this.elDivChat);
p.className="role-trim";
}
} else {
console.debug(`DBUG:SimpleChat:MCUI:HandleUserSubmit:ChatId has changed:[${chatId}] [${this.curChatId}]`);
}
this.ui_reset_toolcall_as_needed(theResp);
this.ui_reset_userinput();
}
/**
* Handle running of specified tool call if any, for the specified chat session.
* Also sets up a timeout, so that user gets control back to interact with the ai model.
* @param {string} chatId
*/
async handle_tool_run(chatId) {
let chat = this.simpleChats[chatId];
this.elInUser.value = "toolcall in progress...";
this.elInUser.disabled = true;
let toolname = this.elInToolName.value.trim()
let toolCallId = this.elInToolName.dataset.tool_call_id;
if (toolCallId === undefined) {
toolCallId = "??? ToolCallId Missing ???"
}
let toolResult = await chat.handle_toolcall(toolCallId, toolname, this.elInToolArgs.value)
if (toolResult !== undefined) {
this.elInUser.value = ChatMessageEx.createToolCallResultAllInOne(toolCallId, toolname, toolResult);
this.ui_reset_userinput(false)
} else {
this.idTimeOut = setTimeout(() => {
this.elInUser.value = ChatMessageEx.createToolCallResultAllInOne(toolCallId, toolname, `Tool/Function call ${toolname} taking too much time, aborting...`);
this.ui_reset_userinput(false)
}, 10000)
}
}
/**
* Show buttons for NewChat and available chat sessions, in the passed elDiv.
* If elDiv is undefined/null, then use this.elDivSessions.
* Take care of highlighting the selected chat-session's btn.
* @param {HTMLDivElement | undefined} elDiv
*/
show_sessions(elDiv=undefined) {
if (!elDiv) {
elDiv = this.elDivSessions;
}
elDiv.replaceChildren();
// Btn for creating new chat session
let btnNew = ui.el_create_button("New CHAT", (ev)=> {
if (this.elInUser.disabled) {
console.error(`ERRR:SimpleChat:MCUI:NewChat:Current session [${this.curChatId}] awaiting response, ignoring request...`);
alert("ERRR:SimpleChat\nMCUI:NewChat\nWait for response to pending query, before starting new chat session");
return;
}
let chatId = `Chat${Object.keys(this.simpleChats).length}`;
let chatIdGot = prompt("INFO:SimpleChat\nMCUI:NewChat\nEnter id for new chat session", chatId);
if (!chatIdGot) {
console.error("ERRR:SimpleChat:MCUI:NewChat:Skipping based on user request...");
return;
}
this.new_chat_session(chatIdGot, true);
this.create_session_btn(elDiv, chatIdGot);
ui.el_children_config_class(elDiv, chatIdGot, "session-selected", "");
});
elDiv.appendChild(btnNew);
// Btns for existing chat sessions
let chatIds = Object.keys(this.simpleChats);
for(let cid of chatIds) {
let btn = this.create_session_btn(elDiv, cid);
if (cid == this.curChatId) {
btn.className = "session-selected";
}
}
}
/**
* Create session button and append to specified Div element.
* @param {HTMLDivElement} elDiv
* @param {string} cid
*/
create_session_btn(elDiv, cid) {
let btn = ui.el_create_button(cid, (ev)=>{
let target = /** @type{HTMLButtonElement} */(ev.target);
console.debug(`DBUG:SimpleChat:MCUI:SessionClick:${target.id}`);
if (this.elInUser.disabled) {
console.error(`ERRR:SimpleChat:MCUI:SessionClick:${target.id}:Current session [${this.curChatId}] awaiting response, ignoring switch...`);
alert("ERRR:SimpleChat\nMCUI:SessionClick\nWait for response to pending query, before switching");
return;
}
this.handle_session_switch(target.id);
ui.el_children_config_class(elDiv, target.id, "session-selected", "");
});
elDiv.appendChild(btn);
return btn;
}
/**
* Switch ui to the specified chatId and set curChatId to same.
* @param {string} chatId
*/
async handle_session_switch(chatId) {
let chat = this.simpleChats[chatId];
if (chat == undefined) {
console.error(`ERRR:SimpleChat:MCUI:HandleSessionSwitch:${chatId} missing...`);
return;
}
this.elInSystem.value = chat.get_system_latest().ns.content;
this.elInUser.value = "";
chat.show(this.elDivChat, true, true);
this.elInUser.focus();
this.curChatId = chatId;
console.log(`INFO:SimpleChat:MCUI:HandleSessionSwitch:${chatId} entered...`);
}
}
class Me {
constructor() {
this.baseURL = "http://127.0.0.1:8080";
this.defaultChatIds = [ "Default", "Other" ];
this.multiChat = new MultiChatUI();
this.bStream = true;
this.tools = {
enabled: false,
fetchProxyUrl: "http://127.0.0.1:3128"
};
this.bCompletionFreshChatAlways = true;
this.bCompletionInsertStandardRolePrefix = false;
this.bTrimGarbage = true;
this.iRecentUserMsgCnt = 10;
/** @type {Object<string, number>} */
this.sRecentUserMsgCnt = {
"Full": -1,
"Last0": 1,
"Last1": 2,
"Last2": 3,
"Last4": 5,
"Last9": 10,
};
this.apiEP = ApiEP.Type.Chat;
/** @type {Object<string, string>} */
this.headers = {
"Content-Type": "application/json",
"Authorization": "", // Authorization: Bearer OPENAI_API_KEY
}
/**
* Add needed fields wrt json object to be sent wrt LLM web services completions endpoint.
* @type {Object<string, any>}
*/
this.apiRequestOptions = {
"model": "gpt-3.5-turbo",
"temperature": 0.7,
"max_tokens": 2048,
"n_predict": 2048,
"cache_prompt": false,
//"frequency_penalty": 1.2,
//"presence_penalty": 1.2,
};
}
/**
* Disable console.debug by mapping it to a empty function.
*/
debug_disable() {
this.console_debug = console.debug;
console.debug = () => {
};
}
/**
* Setup the load saved chat ui.
* @param {HTMLDivElement} div
* @param {SimpleChat} chat
*/
setup_load(div, chat) {
if (!(chat.ods_key() in localStorage)) {
return;
}
div.innerHTML += `<p class="role-system">Restore</p>
<p>Load previously saved chat session, if available</p>`;
let btn = ui.el_create_button(chat.ods_key(), (ev)=>{
console.log("DBUG:SimpleChat:SC:Load", chat);
chat.load();
queueMicrotask(()=>{
chat.show(div, true, true);
this.multiChat.elInSystem.value = chat.get_system_latest().ns.content;
});
});
div.appendChild(btn);
}
/**
* Show the configurable parameters info in the passed Div element.
* @param {HTMLDivElement} elDiv
* @param {boolean} bAll
*/
show_info(elDiv, bAll=false) {
let props = ["baseURL", "modelInfo","headers", "bStream", "tools", "apiRequestOptions", "apiEP", "iRecentUserMsgCnt", "bTrimGarbage", "bCompletionFreshChatAlways", "bCompletionInsertStandardRolePrefix"];
if (!bAll) {
props = [ "baseURL", "modelInfo", "headers", "bStream", "tools", "apiRequestOptions", "apiEP", "iRecentUserMsgCnt" ];
}
fetch(`${this.baseURL}/props`).then(resp=>resp.json()).then(json=>{
this.modelInfo = {
modelPath: json["model_path"],
ctxSize: json["default_generation_settings"]["n_ctx"]
}
ui.ui_show_obj_props_info(elDiv, this, props, "Settings/Info (devel-tools-console document[gMe])", "", { legend: 'role-system' })
}).catch(err=>console.log(`WARN:ShowInfo:${err}`))
}
/**
* Show settings ui for configurable parameters, in the passed Div element.
* @param {HTMLDivElement} elDiv
*/
show_settings(elDiv) {
ui.ui_show_obj_props_edit(elDiv, this, ["baseURL", "headers", "bStream", "tools", "apiRequestOptions", "TRAPME-apiEP", "TRAPME-iRecentUserMsgCnt", "bTrimGarbage", "bCompletionFreshChatAlways", "bCompletionInsertStandardRolePrefix"], "Settings", (prop, elProp)=>{
if (prop == "headers:Authorization") {
// @ts-ignore
elProp.placeholder = "Bearer OPENAI_API_KEY";
}
}, "TRAPME-", (tag, elParent)=>{
if (tag == "TRAPME-apiEP") {
let sel = ui.el_creatediv_select("SetApiEP", "ApiEndPoint", ApiEP.Type, this.apiEP, (val)=>{
// @ts-ignore
this.apiEP = ApiEP.Type[val];
});
elParent.appendChild(sel.div);
}
if (tag == "TRAPME-iRecentUserMsgCnt") {
let sel = ui.el_creatediv_select("SetChatHistoryInCtxt", "ChatHistoryInCtxt", this.sRecentUserMsgCnt, this.iRecentUserMsgCnt, (val)=>{
this.iRecentUserMsgCnt = this.sRecentUserMsgCnt[val];
});
elParent.appendChild(sel.div);
}
})
}
}
/** @type {Me} */
let gMe;
function startme() {
console.log("INFO:SimpleChat:StartMe:Starting...");
gMe = new Me();
gMe.debug_disable();
// @ts-ignore
document["gMe"] = gMe;
// @ts-ignore
document["du"] = du;
// @ts-ignore
document["tools"] = tools;
tools.init()
for (let cid of gMe.defaultChatIds) {
gMe.multiChat.new_chat_session(cid);
}
gMe.multiChat.setup_ui(gMe.defaultChatIds[0], true);
gMe.multiChat.show_sessions();
}
document.addEventListener("DOMContentLoaded", startme);