2238 lines
80 KiB
JavaScript
2238 lines
80 KiB
JavaScript
// @ts-check
|
|
// Core classes which provide a simple implementation of handshake with ai server's completions and chat/completions endpoints
|
|
// as well as related web front end logic for basic usage and testing.
|
|
// by Humans for All
|
|
|
|
import * as du from "./datautils.mjs";
|
|
import * as ui from "./ui.mjs"
|
|
import * as mTools from "./tools.mjs"
|
|
import * as mIdb from "./idb.mjs"
|
|
|
|
|
|
const TEMP_MARKER = "-TEMP"
|
|
|
|
const DB_NAME = "SimpleChatTCRV"
|
|
const DB_STORE = "Sessions"
|
|
|
|
export const AI_TC_SESSIONNAME = `TCExternalAI`
|
|
|
|
const ROLES_TEMP_ENDSWITH = TEMP_MARKER
|
|
|
|
export class Roles {
|
|
static System = "system";
|
|
static User = "user";
|
|
static Assistant = "assistant";
|
|
static Tool = "tool";
|
|
|
|
// List of roles for messages that shouldnt be sent to the ai server.
|
|
// Ensure all these end with .TEMP
|
|
|
|
/** Used to identify tool call response, which has not yet been accepted and submitted by users */
|
|
static ToolTemp = `TOOL${ROLES_TEMP_ENDSWITH}`;
|
|
/**
|
|
* Used to maintain errors that wont be normally communicated back to ai server
|
|
* like error got during handshake with ai server or so.
|
|
*/
|
|
static ErrorTemp = `ERROR${ROLES_TEMP_ENDSWITH}`;
|
|
}
|
|
|
|
|
|
export 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]}`;
|
|
}
|
|
|
|
}
|
|
|
|
|
|
/**
|
|
* THis is created and used only in recent_chat_ns
|
|
* and inturn directly sent over the network, without any addiitional processing.
|
|
* @typedef {Array<Object<string, string|Object<string, string>>>} NSMixedContent
|
|
*/
|
|
|
|
/**
|
|
* @typedef {{id: string, type: string, function: {name: string, arguments: string}}} NSToolCall
|
|
*/
|
|
|
|
export class NSChatMessage {
|
|
/**
|
|
* Represents a Message as seen in the http server Chat handshake
|
|
* @param {string} role
|
|
* @param {string|undefined} content - used to store text content directly
|
|
* @param {string|undefined} reasoning_content
|
|
* @param {Array<NSToolCall>|undefined} tool_calls
|
|
* @param {string|undefined} tool_call_id - toolcall response - the tool / function call id
|
|
* @param {string|undefined} name - toolcall response - the tool / function call name
|
|
* @param {Array<string>|undefined} image_urls - a image url for vision models
|
|
*/
|
|
constructor(role = "", content=undefined, reasoning_content=undefined, tool_calls=undefined, tool_call_id=undefined, name=undefined, image_urls=undefined) {
|
|
this.role = role;
|
|
this.content = content;
|
|
this.reasoning_content = reasoning_content
|
|
this.tool_calls = structuredClone(tool_calls)
|
|
this.tool_call_id = tool_call_id
|
|
this.name = name
|
|
if (image_urls) {
|
|
if (image_urls.length <= 0) {
|
|
image_urls = undefined
|
|
}
|
|
}
|
|
this.image_urls = structuredClone(image_urls)
|
|
}
|
|
|
|
/**
|
|
* @param {string} role
|
|
* @param {string} tool_call_id
|
|
* @param {string} name
|
|
* @param {string} content
|
|
*/
|
|
static new_tool_response(role, tool_call_id, name, content) {
|
|
return new NSChatMessage(role, content, undefined, undefined, tool_call_id, name)
|
|
}
|
|
|
|
/**
|
|
* Return the direct text content, or else empty string.
|
|
*/
|
|
getContent() {
|
|
if (this.content) {
|
|
return this.content
|
|
}
|
|
return ""
|
|
}
|
|
|
|
/** Returns trimmed reasoning content, else empty string */
|
|
getReasoningContent() {
|
|
if (this.reasoning_content) {
|
|
return this.reasoning_content.trim()
|
|
}
|
|
return ""
|
|
}
|
|
|
|
/**
|
|
* Get the function wrt tool calls index
|
|
* @param {number} funcIndex
|
|
*/
|
|
getFunc(funcIndex) {
|
|
if (this.has_toolcalls()) {
|
|
if (this.tool_calls) {
|
|
return this.tool_calls[funcIndex]
|
|
}
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
/**
|
|
* Get the function name wrt tool calls index
|
|
* @param {number} funcIndex
|
|
*/
|
|
getFuncName(funcIndex) {
|
|
if (this.has_toolcalls()) {
|
|
if (this.tool_calls) {
|
|
return this.tool_calls[funcIndex].function.name
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
/**
|
|
* Get the function args wrt tool calls index
|
|
* @param {number} funcIndex
|
|
*/
|
|
getFuncArgs(funcIndex) {
|
|
if (this.has_toolcalls()) {
|
|
if (this.tool_calls) {
|
|
return this.tool_calls[funcIndex].function.arguments
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
/**
|
|
* Creates/Defines the content field if undefined.
|
|
* If already defined, based on bOverwrite either
|
|
* * appends to the existing content or
|
|
* * overwrite the existing content
|
|
* @param {string} content
|
|
* @param {boolean} bOverwrite
|
|
*/
|
|
content_adj(content="", bOverwrite=false) {
|
|
if (this.content == undefined) {
|
|
this.content = content
|
|
} else {
|
|
if (bOverwrite) {
|
|
this.content = content
|
|
} else {
|
|
this.content += content
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Append to the reasoningContent, if already exists
|
|
* Else create the reasoningContent.
|
|
* @param {string} reasoningContent
|
|
*/
|
|
reasoning_content_adj(reasoningContent="") {
|
|
if (this.reasoning_content == undefined) {
|
|
this.reasoning_content = reasoningContent
|
|
} else {
|
|
this.reasoning_content += reasoningContent
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Add/Push a new tool call.
|
|
* If the underlying tool_calls is undefined, create it first.
|
|
* @param {NSToolCall} toolCall
|
|
*/
|
|
tool_calls_push(toolCall) {
|
|
if (this.tool_calls == undefined) {
|
|
this.tool_calls = []
|
|
}
|
|
this.tool_calls.push(toolCall)
|
|
}
|
|
|
|
/**
|
|
* Check if this NSChatMessage has any content or not.
|
|
*/
|
|
has_content() {
|
|
if (this.content) {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
/**
|
|
* Check if this NSChatMessage has any non empty reasoning content or not.
|
|
*/
|
|
has_reasoning() {
|
|
if (this.reasoning_content) {
|
|
if (this.reasoning_content.trim().length == 0) {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
/**
|
|
* Check if this NSChatMessage has atleast one tool call or not.
|
|
*/
|
|
has_toolcalls() {
|
|
if (this.tool_calls) {
|
|
if (this.tool_calls.length == 0) {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
has_role_temp() {
|
|
if (this.role.endsWith(ROLES_TEMP_ENDSWITH)) {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
has_toolresponse() {
|
|
if (this.tool_call_id) {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
}
|
|
|
|
|
|
export class ChatMessageEx {
|
|
|
|
static uniqCounter = 0
|
|
|
|
/**
|
|
* Get a globally (ie across chat sessions) unique id wrt chat messages.
|
|
*/
|
|
static getUniqId() {
|
|
this.uniqCounter += 1
|
|
return this.uniqCounter
|
|
}
|
|
|
|
/**
|
|
* Represent a Message in the Chat.
|
|
* @param {NSChatMessage|undefined} nsChatMsg - will create a default NSChatMessage instance, if undefined
|
|
* @param {string|undefined} trimmedContent
|
|
*/
|
|
constructor(nsChatMsg=undefined, trimmedContent=undefined) {
|
|
if (nsChatMsg) {
|
|
this.ns = nsChatMsg
|
|
} else {
|
|
this.ns = new NSChatMessage()
|
|
}
|
|
this.uniqId = ChatMessageEx.getUniqId()
|
|
this.trimmedContent = trimmedContent;
|
|
}
|
|
|
|
/**
|
|
* Create a new instance from an existing instance
|
|
* @param {ChatMessageEx} old
|
|
*/
|
|
static newFrom(old) {
|
|
return new ChatMessageEx(new NSChatMessage(old.ns.role, old.ns.content, old.ns.reasoning_content, old.ns.tool_calls, old.ns.tool_call_id, old.ns.name, old.ns.image_urls), old.trimmedContent)
|
|
}
|
|
|
|
clear() {
|
|
this.ns = new NSChatMessage()
|
|
this.trimmedContent = undefined;
|
|
}
|
|
|
|
/**
|
|
* 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])
|
|
}
|
|
|
|
/**
|
|
* Cross check if the got packet has error.
|
|
* @param {any} nwo
|
|
*/
|
|
update_checkerror(nwo) {
|
|
if (nwo["error"]) {
|
|
throw new Error(`ChatMessageEx:UpdateCheckError:${nwo["error"]}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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)
|
|
this.update_checkerror(nwo)
|
|
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_adj(content);
|
|
} else {
|
|
this.ns.role = nwo["choices"][0]["delta"]["role"];
|
|
}
|
|
} else {
|
|
let toolCalls = nwo["choices"][0]["delta"]["tool_calls"];
|
|
let reasoningContent = nwo["choices"][0]["delta"]["reasoning_content"];
|
|
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 {
|
|
let toolCallArg = toolCalls[0]["function"]["arguments"];
|
|
if (toolCallArg !== undefined) {
|
|
if (this.ns.tool_calls) {
|
|
this.ns.tool_calls[0].function.arguments += toolCallArg;
|
|
} else {
|
|
console.error(`ERRR:ChatMessageEx:UpdateStream:ToolCallsMissing, but TC arg [${toolCallArg}] needs appending...`)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (reasoningContent !== undefined) {
|
|
this.ns.reasoning_content_adj(reasoningContent)
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
try {
|
|
this.ns.content_adj(nwo["choices"][0]["text"]);
|
|
} catch {
|
|
this.ns.content_adj(nwo["content"]);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update based on the data got from network in oneshot mode
|
|
* @param {any} nwo
|
|
* @param {string} apiEP
|
|
*/
|
|
update_oneshot(nwo, apiEP) {
|
|
this.update_checkerror(nwo)
|
|
if (apiEP == ApiEP.Type.Chat) {
|
|
let curContent = nwo["choices"][0]["message"]["content"];
|
|
if (curContent != undefined) {
|
|
if (curContent != null) {
|
|
this.ns.content_adj(curContent)
|
|
}
|
|
}
|
|
let curRC = nwo["choices"][0]["message"]["reasoning_content"];
|
|
if (curRC != undefined) {
|
|
this.ns.reasoning_content = curRC;
|
|
}
|
|
let curTCs = nwo["choices"][0]["message"]["tool_calls"];
|
|
if (curTCs != undefined) {
|
|
this.ns.tool_calls = curTCs;
|
|
}
|
|
} else {
|
|
try {
|
|
this.ns.content_adj(nwo["choices"][0]["text"]);
|
|
} catch {
|
|
this.ns.content_adj(nwo["content"]);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Collate all the different parts of a chat message into a single string object.
|
|
*
|
|
* This currently includes reasoning, content and toolcall parts.
|
|
*/
|
|
content_equiv() {
|
|
let reasoning = ""
|
|
let content = ""
|
|
let toolcall = ""
|
|
if (this.ns.has_reasoning()) {
|
|
reasoning = `!!!Reasoning: ${this.ns.getReasoningContent()} !!!\n\n`;
|
|
}
|
|
if (this.ns.has_content()) {
|
|
content = this.ns.getContent();
|
|
}
|
|
if (this.ns.has_toolcalls()) {
|
|
toolcall = `\n\n<tool_call>\n<tool_name>${this.ns.getFuncName(0)}</tool_name>\n<tool_args>${this.ns.getFuncArgs(0)}</tool_args>\n</tool_call>\n`;
|
|
}
|
|
return `${reasoning} ${content} ${toolcall}`;
|
|
}
|
|
|
|
}
|
|
|
|
|
|
/**
|
|
* @param {string} sRecentUserMsgCnt
|
|
*/
|
|
function usage_note(sRecentUserMsgCnt) {
|
|
let sUsageNote = `
|
|
<details id="DefaultUsage">
|
|
<summary class="role-system">Usage Note</summary>
|
|
<ul class="ul1">
|
|
<li> New button creates new chat session, with its own system prompt.</li>
|
|
<li> Prompt button toggles system prompt entry.</li>
|
|
<li> System prompt above, helps 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/response to ai assistant in text area provided below.</li>
|
|
<li> settings-tools-enabled should be true to enable tool calling.</li>
|
|
<ul class="ul2">
|
|
<li> If ai assistant requests a tool call, verify same before triggering.</li>
|
|
<li> submit tool response placed into user query/response text area</li>
|
|
</ul>
|
|
<li> Use image button for vision models, submitting or switching session clears same </li>
|
|
<li> ContextWindow = [System, ${sRecentUserMsgCnt} User Query/Resp, Cur Query].</li>
|
|
<ul class="ul2">
|
|
<li> ChatHistInCtxt, MaxTokens, ModelCtxt window to expand</li>
|
|
</ul>
|
|
</ul>
|
|
</details>`;
|
|
return sUsageNote;
|
|
}
|
|
|
|
|
|
class ELDivStream {
|
|
|
|
/**
|
|
* @param {string} chatId
|
|
*/
|
|
constructor(chatId) {
|
|
let elDiv = Object.assign(document.createElement('div'), {
|
|
id: `DivStream-${chatId}`,
|
|
className: 'chat-message',
|
|
});
|
|
this.div = elDiv
|
|
|
|
let elDivRole = Object.assign(document.createElement('div'), {
|
|
id: 'divStreamRole',
|
|
className: 'chat-message-role',
|
|
});
|
|
elDiv.appendChild(elDivRole)
|
|
this.divRole = elDivRole
|
|
|
|
let elDivData = Object.assign(document.createElement('div'), {
|
|
id: 'divStreamData',
|
|
className: 'chat-message-content-live',
|
|
});
|
|
elDiv.appendChild(elDivData)
|
|
this.divData = elDivData
|
|
}
|
|
|
|
show() {
|
|
this.div.hidden = false
|
|
this.div.style.visibility = "visible"
|
|
}
|
|
|
|
clear() {
|
|
this.divRole.replaceChildren()
|
|
this.divData.replaceChildren()
|
|
this.div.hidden = true
|
|
this.div.style.visibility = "collapse"
|
|
}
|
|
|
|
}
|
|
|
|
/** @typedef {Object<string, ELDivStream>} ELDivStreams */
|
|
|
|
/** @typedef {ChatMessageEx[]} ChatMessages */
|
|
|
|
/** @typedef {{iLastSys: number, xchat: ChatMessages}} SimpleChatODS */
|
|
|
|
export class SimpleChat {
|
|
|
|
/**
|
|
* Create an instance of SimpleChat
|
|
* * dont forget to call setup
|
|
* @param {string} chatId
|
|
* @param {Config} cfg
|
|
* @param {mTools.ToolsManager} toolsMgr
|
|
*/
|
|
constructor(chatId, cfg, toolsMgr) {
|
|
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();
|
|
this.cfg = Config.clone(cfg);
|
|
this.toolsMgr = toolsMgr
|
|
this.load_cfg()
|
|
}
|
|
|
|
clear() {
|
|
this.xchat = [];
|
|
this.iLastSys = -1;
|
|
this.latestResponse = new ChatMessageEx();
|
|
}
|
|
|
|
setup() {
|
|
return this.toolsMgr.setup(this.chatId)
|
|
}
|
|
|
|
/**
|
|
* Given a string, gives the equivalent simplified string which could be used as chatId.
|
|
* * Converts the very 1st char into upper case.
|
|
* * Removes everything other than alphabets and numbers.
|
|
* Also converts 1st character following such removed substrings into upper case.
|
|
* @param {string} chatId
|
|
*/
|
|
static ChatIdClean(chatId) {
|
|
let sTempId1 = chatId.trim()
|
|
let sTempId2 = sTempId1.replace(/[^a-zA-Z0-9]+(.)/g, (m, chr) => chr.toUpperCase());
|
|
return `${sTempId2[0].toUpperCase()}${sTempId2.slice(1)}`
|
|
}
|
|
|
|
ods_key() {
|
|
return `SimpleChat-${this.chatId}`
|
|
}
|
|
|
|
/**
|
|
* Save the config to local storage
|
|
*/
|
|
save_cfg() {
|
|
let tag = `SimpleChat:SaveCfg:${this.chatId}`;
|
|
let ods = JSON.stringify(this.cfg)
|
|
localStorage.setItem(this.ods_key(), ods)
|
|
console.log(`DBUG:${tag}:saved`)
|
|
}
|
|
|
|
/**
|
|
* Load the config from local storage
|
|
*/
|
|
load_cfg() {
|
|
let tag = `SimpleChat:LoadCfg:${this.chatId}`;
|
|
let odsStr = localStorage.getItem(this.ods_key())
|
|
if (odsStr) {
|
|
this.cfg = Object.assign(this.cfg, JSON.parse(odsStr))
|
|
console.log(`DBUG:${tag}:loaded`)
|
|
} else {
|
|
console.log(`DBUG:${tag}:nothing to load`)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Save into indexedDB
|
|
*/
|
|
save() {
|
|
let tag = `SimpleChat:Save:${this.chatId}`;
|
|
/** @type {SimpleChatODS} */
|
|
let ods = {iLastSys: this.iLastSys, xchat: this.xchat};
|
|
mIdb.db_put(DB_NAME, DB_STORE, this.ods_key(), JSON.stringify(ods), tag, (status, related)=>{
|
|
console.log(`DBUG:${tag}:${status}:${related}`)
|
|
if (!status) {
|
|
throw new Error(`ERRR:${tag}:${related}`)
|
|
}
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Load from indexedDB, get status through callback.
|
|
* @param {((loadStatus: boolean, dbStatus: boolean, related: IDBValidKey | DOMException | null) => void)} cb
|
|
*/
|
|
load(cb) {
|
|
let tag = `SimpleChat:Load:${this.chatId}`;
|
|
mIdb.db_get(DB_NAME, DB_STORE, this.ods_key(), tag, (status, related)=>{
|
|
if (!status) {
|
|
cb(false, status, `ERRR:${tag}:Db failure:${related}`);
|
|
return
|
|
}
|
|
if (!related) {
|
|
cb(false, status, `ERRR:${tag}:No data?`);
|
|
return
|
|
}
|
|
if (typeof(related) == "string") {
|
|
/** @type {SimpleChatODS} */
|
|
let ods = JSON.parse(related);
|
|
this.iLastSys = ods.iLastSys;
|
|
this.xchat = [];
|
|
for (const cur of ods.xchat) {
|
|
this.xchat.push(new ChatMessageEx(new NSChatMessage(cur.ns.role, cur.ns.content, cur.ns.reasoning_content, cur.ns.tool_calls, cur.ns.tool_call_id, cur.ns.name, cur.ns.image_urls), cur.trimmedContent))
|
|
}
|
|
cb(true, status, related)
|
|
} else {
|
|
cb(false, status, `ERRR:${tag}:DOMException?:${related}`);
|
|
}
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 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.getContent().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) {
|
|
if (msg.ns.has_role_temp()) {
|
|
// Skip Temp Role messages
|
|
// ex: tool response which has not yet been accepted by user
|
|
// In future need to check that non accepted tool response is the last message
|
|
// and not something in between, which shouldnt occur normally.
|
|
continue
|
|
}
|
|
let tmsg = ChatMessageEx.newFrom(msg);
|
|
if (!tmsg.ns.has_toolcalls()) {
|
|
tmsg.ns_delete("tool_calls")
|
|
}
|
|
if (tmsg.ns.getReasoningContent() === "") {
|
|
tmsg.ns_delete("reasoning_content")
|
|
}
|
|
if (tmsg.ns.image_urls) {
|
|
// Has I need to know if really there or if undefined, so direct access and not through getContent helper.
|
|
let tContent = tmsg.ns.content
|
|
/** @type{NSMixedContent} */
|
|
let tMixed = []
|
|
if (tContent) {
|
|
tMixed.push({"type": "text", "text": tContent})
|
|
}
|
|
for (const imgUrl of tmsg.ns.image_urls) {
|
|
tMixed.push({"type": "image_url", "image_url": {"url": imgUrl}})
|
|
//tMixed.push({"type": "image", "image": imgUrl})
|
|
}
|
|
// @ts-ignore
|
|
tmsg.ns.content = tMixed
|
|
tmsg.ns_delete("image_urls")
|
|
}
|
|
chat.push(tmsg.ns);
|
|
}
|
|
return chat
|
|
}
|
|
|
|
/**
|
|
* Add an entry into xchat.
|
|
* If the last message in chat history is a Temp message, discard it
|
|
* as the runtime logic is asking for adding new message instead of promoting the temp message.
|
|
*
|
|
* NOTE: A new copy is created and added into xchat.
|
|
* Also update iLastSys system prompt index tracker
|
|
*
|
|
* ALERT: Also triggers a save, which occurs assynchronously in the background, as of now,
|
|
* with no handle returned wrt same.
|
|
*
|
|
* @param {ChatMessageEx} chatMsg
|
|
* @param {Object<string,any>|undefined} extra - optional additional fieldName=Value pairs to be added, if any
|
|
*/
|
|
add(chatMsg, extra=undefined) {
|
|
if (this.xchat.length > 0) {
|
|
let lastIndex = this.xchat.length - 1;
|
|
if (this.xchat[lastIndex].ns.role.endsWith(ROLES_TEMP_ENDSWITH)) {
|
|
console.debug("DBUG:SimpleChat:Add:Discarding prev TEMP role message...")
|
|
this.xchat.pop()
|
|
}
|
|
}
|
|
this.xchat.push(ChatMessageEx.newFrom(chatMsg));
|
|
if (chatMsg.ns.role == Roles.System) {
|
|
this.iLastSys = this.xchat.length - 1;
|
|
}
|
|
if (extra) {
|
|
for (const key in extra) {
|
|
this.xchat[this.xchat.length-1].ns_set_extra(key, extra[key])
|
|
}
|
|
}
|
|
this.save();
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* If passed chatMsg has same role as existing last message, then replace last message,
|
|
* else add passed chatMsg using add.
|
|
*
|
|
* NOTE: If replacing, ensures that replaced chat message has same uniqId as message it replaced.
|
|
*
|
|
* @param {ChatMessageEx} chatMsg
|
|
*/
|
|
add_smart(chatMsg) {
|
|
let lastMsg = this.xchat[this.xchat.length-1];
|
|
if (lastMsg) {
|
|
if (lastMsg.ns.role == chatMsg.ns.role) {
|
|
console.debug(`DBUG:SC:AddSmart:Replacing:${lastMsg}:${chatMsg}`)
|
|
this.xchat[this.xchat.length-1] = ChatMessageEx.newFrom(chatMsg)
|
|
this.xchat[this.xchat.length-1].uniqId = lastMsg.uniqId
|
|
this.save()
|
|
return true
|
|
}
|
|
}
|
|
return this.add(chatMsg)
|
|
}
|
|
|
|
/**
|
|
* Get xchat index corresponding to given chat message uniqId.
|
|
* @param {number} uniqId
|
|
*/
|
|
get_chatmessage_index(uniqId) {
|
|
return this.xchat.findIndex((msg)=>{
|
|
if (msg.uniqId == uniqId) {
|
|
return true
|
|
}
|
|
return false
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Delete a chat message in place using chat message uniqId.
|
|
* Returns index of the chatmessage deleted wrt xchat.
|
|
* @param {number} uniqId
|
|
*/
|
|
delete(uniqId) {
|
|
let index = this.get_chatmessage_index(uniqId);
|
|
if (index >= 0) {
|
|
this.xchat.splice(index, 1)
|
|
}
|
|
return index
|
|
}
|
|
|
|
/**
|
|
* Check if the last message in the chat history is a ToolTemp role based one.
|
|
* If so, then
|
|
* * update that to a regular Tool role based message.
|
|
* * also update the content of that message to what is passed.
|
|
* @param {string} content
|
|
*/
|
|
promote_tooltemp(content) {
|
|
let lastIndex = this.xchat.length - 1;
|
|
if (lastIndex < 0) {
|
|
console.error("DBUG:SimpleChat:PromoteToolTemp:No chat messages including ToolTemp")
|
|
return false
|
|
}
|
|
if (this.xchat[lastIndex].ns.role != Roles.ToolTemp) {
|
|
console.error("DBUG:SimpleChat:PromoteToolTemp:LastChatMsg not ToolTemp")
|
|
return false
|
|
}
|
|
this.xchat[lastIndex].ns.role = Roles.Tool;
|
|
this.xchat[lastIndex].ns.content_adj(content, true);
|
|
return true
|
|
}
|
|
|
|
/**
|
|
* Setup the fetch headers.
|
|
* It picks the headers from this.me.headers.
|
|
* It inserts Authorization only if its non-empty.
|
|
* @param {string} apiEP
|
|
*/
|
|
fetch_headers(apiEP) {
|
|
let headers = new Headers();
|
|
for(let k in this.cfg.headers) {
|
|
let v = this.cfg.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 this.cfg.apiRequestOptions) {
|
|
obj[k] = this.cfg.apiRequestOptions[k];
|
|
}
|
|
if (this.cfg.chatProps.stream) {
|
|
obj["stream"] = true;
|
|
}
|
|
if (this.cfg.tools.enabled) {
|
|
obj["tools"] = this.toolsMgr.meta(this.chatId);
|
|
}
|
|
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(this.cfg.chatProps.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(this.cfg.chatProps.iRecentUserMsgCnt)) {
|
|
iCnt += 1;
|
|
if (iCnt > 1) {
|
|
prompt += "\n";
|
|
}
|
|
if (bInsertStandardRolePrefix) {
|
|
prompt += `${msg.ns.role}: `;
|
|
}
|
|
prompt += `${msg.ns.getContent()}`;
|
|
}
|
|
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(this.cfg.chatProps.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(new NSChatMessage(Roles.System, sysPrompt)));
|
|
}
|
|
|
|
let lastSys = this.xchat[this.iLastSys].ns.getContent();
|
|
if (lastSys !== sysPrompt) {
|
|
return this.add(new ChatMessageEx(new NSChatMessage(Roles.System, sysPrompt)));
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Retrieve the latest system prompt related chat message entry.
|
|
*/
|
|
get_system_latest() {
|
|
if (this.iLastSys == -1) {
|
|
return new ChatMessageEx(new NSChatMessage(Roles.System));
|
|
}
|
|
return this.xchat[this.iLastSys];
|
|
}
|
|
|
|
|
|
/**
|
|
* Handle the multipart response from server/ai-model
|
|
* @param {Response} resp
|
|
* @param {string} apiEP
|
|
* @param {ELDivStreams} elDivStreams
|
|
*/
|
|
async handle_response_multipart(resp, apiEP, elDivStreams) {
|
|
if (!resp.body) {
|
|
throw Error("ERRR:SimpleChat:SC:HandleResponseMultiPart:No body...");
|
|
}
|
|
let elDivStream = elDivStreams[this.chatId];
|
|
elDivStream.divRole.innerText = `Ai:${this.chatId.slice(0,8)}`
|
|
elDivStream.show()
|
|
try {
|
|
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);
|
|
}
|
|
elDivStream.divData.innerText = this.latestResponse.content_equiv()
|
|
elDivStream.div.scrollIntoView(false);
|
|
if (done) {
|
|
break;
|
|
}
|
|
}
|
|
} finally {
|
|
elDivStream.clear()
|
|
}
|
|
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(new NSChatMessage(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.
|
|
* @param {Response} resp
|
|
* @param {string} apiEP
|
|
* @param {ELDivStreams} elDivStreams - used to place ai server chat response as it is being generated/recieved in streaming mode
|
|
*/
|
|
async handle_response(resp, apiEP, elDivStreams) {
|
|
let theResp = null;
|
|
try {
|
|
if (this.cfg.chatProps.stream) {
|
|
theResp = await this.handle_response_multipart(resp, apiEP, elDivStreams);
|
|
this.latestResponse.clear();
|
|
elDivStreams[this.chatId].clear();
|
|
} else {
|
|
theResp = await this.handle_response_oneshot(resp, apiEP);
|
|
}
|
|
} catch (error) {
|
|
theResp = this.latestResponse;
|
|
theResp.ns.role = Roles.Assistant;
|
|
this.add(theResp);
|
|
this.latestResponse.clear();
|
|
elDivStreams[this.chatId].clear()
|
|
throw error;
|
|
}
|
|
if (this.cfg.chatProps.bTrimGarbage) {
|
|
let origMsg = theResp.ns.getContent();
|
|
if (origMsg) {
|
|
theResp.ns.content_adj(du.trim_garbage_at_end(origMsg), true);
|
|
theResp.trimmedContent = origMsg.substring(theResp.ns.getContent().length);
|
|
}
|
|
}
|
|
theResp.ns.role = Roles.Assistant;
|
|
this.add(theResp);
|
|
return theResp;
|
|
}
|
|
|
|
/**
|
|
* Handle the chat handshake with the ai server
|
|
* @param {string} baseURL
|
|
* @param {string} apiEP
|
|
* @param {ELDivStreams} elDivStreams - used to show chat response as it is being generated/recieved in streaming mode
|
|
*/
|
|
async handle_chat_hs(baseURL, apiEP, elDivStreams) {
|
|
class ChatHSError extends Error {
|
|
constructor(/** @type {string} */message) {
|
|
super(message);
|
|
this.name = 'ChatHSError'
|
|
}
|
|
}
|
|
|
|
let theUrl = ApiEP.Url(baseURL, apiEP);
|
|
let theBody = this.request_jsonstr(apiEP);
|
|
console.debug(`DBUG:SimpleChat:${this.chatId}:HandleChatHS:${theUrl}:ReqBody:${theBody}`);
|
|
|
|
let theHeaders = this.fetch_headers(apiEP);
|
|
let resp = await fetch(theUrl, {
|
|
method: "POST",
|
|
headers: theHeaders,
|
|
body: theBody,
|
|
});
|
|
|
|
if (resp.status >= 300) {
|
|
let respBody = await resp.text();
|
|
throw new ChatHSError(`HandleChatHS:GotResponse:NotOk:${resp.status}:${resp.statusText}:${respBody}`);
|
|
}
|
|
|
|
return this.handle_response(resp, apiEP, elDivStreams);
|
|
}
|
|
|
|
/**
|
|
* 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 this.toolsMgr.tool_call(this.chatId, toolcallid, toolname, toolargs)
|
|
} catch (/** @type {any} */error) {
|
|
this.toolsMgr.toolcallpending_found_cleared(this.chatId, toolcallid, 'SC:HandleToolCall:Exc')
|
|
return `Tool/Function call raised an exception:${error.name}:${error.message}`
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
|
|
class MultiChatUI {
|
|
|
|
/**
|
|
* @param {Me} me
|
|
*/
|
|
constructor(me) {
|
|
this.me = me
|
|
/** @type {Object<string, SimpleChat>} */
|
|
this.simpleChats = {};
|
|
/** @type {string} */
|
|
this.curChatId = "";
|
|
|
|
this.TimePeriods = {
|
|
ToolCallAutoSecsTimeUnit: 1000,
|
|
PopoverCloseTimeout: 4000,
|
|
}
|
|
|
|
this.timers = {
|
|
/**
|
|
* Used to identify Delay with getting response from a tool call.
|
|
* @type {number | undefined}
|
|
*/
|
|
toolcallResponseTimeout: undefined,
|
|
/**
|
|
* Used to auto trigger tool call, after a set time, if enabled.
|
|
* @type {number | undefined}
|
|
*/
|
|
toolcallTriggerClick: undefined,
|
|
/**
|
|
* Used to auto submit tool call response, after a set time, if enabled.
|
|
* @type {number | undefined}
|
|
*/
|
|
toolcallResponseSubmitClick: undefined,
|
|
/**
|
|
* Used to auto close popover menu, after a set time, if still open.
|
|
* @type {number | undefined}
|
|
*/
|
|
popoverClose: undefined,
|
|
}
|
|
|
|
/**
|
|
* Used for tracking presence of any chat message in show related logics
|
|
* @type {HTMLElement | null}
|
|
*/
|
|
this.elLastChatMessage = null
|
|
|
|
// 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.elDivUserInImgs = /** @type{HTMLSelectElement} */(document.getElementById("user-in-imgs"));
|
|
this.elDivUserIn = /** @type{HTMLSelectElement} */(document.getElementById("user-in-div"));
|
|
this.elDivHeading = /** @type{HTMLSelectElement} */(document.getElementById("heading"));
|
|
this.elDivSessions = /** @type{HTMLDivElement} */(document.getElementById("sessions-div"));
|
|
this.elBtnSettings = /** @type{HTMLButtonElement} */(document.getElementById("settings"));
|
|
this.elBtnClearChat = /** @type{HTMLButtonElement} */(document.getElementById("clearchat"));
|
|
this.elBtnSessionsPrompts = /** @type{HTMLButtonElement} */(document.getElementById("sessionsprompts"));
|
|
this.elDivSessionsPrompts = /** @type{HTMLDivElement} */(document.getElementById("sessionsprompts-div"));
|
|
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"));
|
|
|
|
// chat message popover menu related
|
|
this.elPopoverChatMsg = /** @type{HTMLElement} */(document.getElementById("popover-chatmsg"));
|
|
this.elPopoverChatMsgCopyBtn = /** @type{HTMLElement} */(document.getElementById("popover-chatmsg-copy"));
|
|
this.elPopoverChatMsgDelBtn = /** @type{HTMLElement} */(document.getElementById("popover-chatmsg-del"));
|
|
this.uniqIdChatMsgPO = -1;
|
|
|
|
// image popover menu
|
|
this.elPOImage = /** @type{HTMLElement} */(document.getElementById("popover-image"));
|
|
this.elPOImageImg = /** @type{HTMLImageElement} */(document.getElementById("poimage-img"));
|
|
this.elPOImageDel = /** @type{HTMLButtonElement} */(document.getElementById("poimage-del"));
|
|
this.elPOImageDel.addEventListener('click', (ev)=>{
|
|
if (this.uniqIdImagePO < 0) {
|
|
return
|
|
}
|
|
this.dataurl_plus_del(this.uniqIdImagePO)
|
|
this.uniqIdImagePO = -1;
|
|
this.elPOImage.hidePopover()
|
|
})
|
|
this.uniqIdImagePO = -1;
|
|
|
|
// Save any placeholder set by default like through html, to restore where needed
|
|
this.elInUser.dataset.placeholder = this.elInUser.placeholder
|
|
// Setup Image loading button and flow
|
|
this.elInFileX = ui.el_creatediv_inputfilebtn('image', '📷', '📷', '', ".jpg, .jpeg, .png", ()=>{
|
|
let f0 = this.elInFileX.el.files?.item(0);
|
|
if (!f0) {
|
|
return
|
|
}
|
|
console.log(`DBUG:InFileX:${f0?.name}`)
|
|
let fR = new FileReader()
|
|
fR.onload = () => {
|
|
if (fR.result) {
|
|
this.dataurl_plus_add(fR.result)
|
|
console.log(`INFO:InFileX:Loaded file ${f0.name}`)
|
|
}
|
|
}
|
|
fR.readAsDataURL(f0)
|
|
}, 'user-in')
|
|
this.elInFileX.elB.title = "Image"
|
|
this.elBtnUser.parentElement?.appendChild(this.elInFileX.elB)
|
|
|
|
// other ui elements
|
|
/** @type {ELDivStreams} */
|
|
this.elDivStreams = {}
|
|
|
|
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");
|
|
this.validate_element(this.elBtnSessionsPrompts, "sessionsprompts-btn");
|
|
this.validate_element(this.elDivSessionsPrompts, "sessionsprompts-div");
|
|
}
|
|
|
|
/**
|
|
* 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"]}]`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Scroll the given element into view.
|
|
* @param {Element|null} el
|
|
*/
|
|
scroll_el_into_view(el) {
|
|
if (!el) {
|
|
return
|
|
}
|
|
/** @type{HTMLElement} */(el).scrollIntoView(false); // Stupid ts-check js-doc intersection ???
|
|
}
|
|
|
|
/**
|
|
* Setup/Reset Validated Tool Call UI parts as needed
|
|
* @param {ChatMessageEx} ar
|
|
* @param {string} chatId
|
|
* @param {boolean} bAuto - allows caller to explicitly control whether auto triggering should be setup.
|
|
*/
|
|
ui_toolcallvalidated_as_needed(ar, chatId = "", bAuto = false) {
|
|
if (ar.ns.has_toolcalls()) {
|
|
this.elDivTool.hidden = false
|
|
this.elInToolName.value = ar.ns.getFuncName(0)
|
|
this.elInToolName.dataset.tool_call_id = ar.ns.getFunc(0)?.id
|
|
this.elInToolArgs.value = `${ar.ns.getFunc(0)?.function.arguments}`
|
|
this.elBtnTool.disabled = false
|
|
let chat = this.simpleChats[chatId]
|
|
if ((chat) && (chat.cfg.tools.autoSecs > 0) && (bAuto)) {
|
|
this.timers.toolcallTriggerClick = setTimeout(()=>{
|
|
this.elBtnTool.click()
|
|
}, chat.cfg.tools.autoSecs*this.TimePeriods.ToolCallAutoSecsTimeUnit)
|
|
}
|
|
} else {
|
|
this.elDivTool.hidden = true
|
|
this.elInToolName.value = ""
|
|
this.elInToolName.dataset.tool_call_id = ""
|
|
this.elInToolArgs.value = ""
|
|
this.elBtnTool.disabled = true
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Add a dataUrl, as well as show Image.
|
|
* @param {string | ArrayBuffer | null} dataUrl
|
|
*/
|
|
dataurl_plus_add(dataUrl) {
|
|
if (typeof(dataUrl) == 'string') {
|
|
this.me.dataURLs.push(dataUrl)
|
|
let elImg = document.createElement('img')
|
|
let imgIndex = this.me.dataURLs.length-1
|
|
elImg.id = `uiimg-${imgIndex}`
|
|
elImg.src = dataUrl
|
|
elImg.addEventListener('click', (ev)=>{
|
|
this.uniqIdImagePO = imgIndex;
|
|
this.elPOImageImg.src = /** @type{string} */(this.me.dataURLs[this.uniqIdImagePO]);
|
|
this.elPOImage.showPopover()
|
|
})
|
|
this.elDivUserInImgs.appendChild(elImg)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Remove the dataurl, as well as shown image.
|
|
* @param {number} imgIndex
|
|
*/
|
|
dataurl_plus_del(imgIndex) {
|
|
let id = `uiimg-${imgIndex}`
|
|
this.me.dataURLs[imgIndex] = null
|
|
let elImg = document.querySelector(`[id="${id}"]`)
|
|
elImg?.remove()
|
|
}
|
|
|
|
/**
|
|
* Get the stored dataUrl
|
|
* @param {number} index
|
|
*/
|
|
dataurl_get(index) {
|
|
return /** @type{string} */(this.me.dataURLs[index])
|
|
}
|
|
|
|
/**
|
|
* Clear dataUrls, as well as clear Image.
|
|
*/
|
|
dataurl_plus_clear() {
|
|
this.me.dataURLs.length = 0;
|
|
this.elDivUserInImgs.replaceChildren()
|
|
}
|
|
|
|
/**
|
|
* Reset user input ui.
|
|
* * clear user input (if requested, default true)
|
|
* * enable user input
|
|
* * set to Roles.User
|
|
* * set focus to user input
|
|
* @param {boolean} [bClearElInUser=true]
|
|
*/
|
|
ui_userinput_reset(bClearElInUser=true) {
|
|
if (bClearElInUser) {
|
|
this.elInUser.value = "";
|
|
this.dataurl_plus_clear()
|
|
}
|
|
this.elInUser.dataset.role = Roles.User;
|
|
this.elInUser.disabled = false;
|
|
this.elInUser.focus();
|
|
}
|
|
|
|
/**
|
|
* Show the passed function / tool call details in specified parent element.
|
|
* @param {HTMLElement} elParent
|
|
* @param {NSToolCall} tc
|
|
*/
|
|
show_message_toolcall(elParent, tc) {
|
|
let secTC = document.createElement('section')
|
|
secTC.classList.add('chat-message-toolcall')
|
|
elParent.append(secTC)
|
|
ui.el_create_append_p(`name: ${tc.function.name}`, secTC);
|
|
let entry = ui.el_create_append_p(`id: ${tc.id}`, secTC);
|
|
try {
|
|
let oArgs = JSON.parse(tc.function.arguments)
|
|
for (const k in oArgs) {
|
|
entry = ui.el_create_append_p(`arg: ${k}`, secTC);
|
|
let secArg = document.createElement('section')
|
|
secArg.classList.add('chat-message-toolcall-arg')
|
|
secTC.append(secArg)
|
|
secArg.innerText = oArgs[k]
|
|
}
|
|
} catch(exc) {
|
|
ui.el_create_append_p(`WARN:ShowMsgTCARGS: ${exc}`, secTC);
|
|
ui.el_create_append_p(`args: ${tc.function.arguments}`, secTC);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handles showing a chat message in UI.
|
|
*
|
|
* If handling message belonging to role
|
|
* * ToolTemp, updates user query input element, if its the last message.
|
|
* * Assistant which contains a tool req, shows tool call ui if needed. ie
|
|
* * if it is the last message OR
|
|
* * if it is the last but one message and there is a ToolTemp message next
|
|
* @param {HTMLElement | undefined} elParent
|
|
* @param {string} chatId
|
|
* @param {ChatMessageEx} msg
|
|
* @param {number} iFromLast
|
|
* @param {ChatMessageEx | undefined} nextMsg
|
|
*/
|
|
show_message(elParent, chatId, msg, iFromLast, nextMsg) {
|
|
// Handle ToolTemp
|
|
if (iFromLast == 0) {
|
|
if (msg.ns.role === Roles.ToolTemp) {
|
|
this.elInUser.value = msg.ns.getContent();
|
|
} else {
|
|
this.elInUser.value = "";
|
|
}
|
|
this.elInUser.dataset.role = (msg.ns.role == Roles.ToolTemp) ? Roles.ToolTemp : Roles.User
|
|
}
|
|
// Create main section
|
|
let secMain = document.createElement('section')
|
|
secMain.id = `cmuid${msg.uniqId}`
|
|
secMain.classList.add(`role-${msg.ns.role}`)
|
|
secMain.classList.add('chat-message')
|
|
secMain.addEventListener('mouseenter', (ev)=>{
|
|
console.debug(`DBUG:MCUI:ChatMessageMEnter:${msg.uniqId}`)
|
|
clearTimeout(this.timers.popoverClose)
|
|
this.timers.popoverClose = setTimeout(()=>{
|
|
this.elPopoverChatMsg.hidePopover()
|
|
}, this.TimePeriods.PopoverCloseTimeout);
|
|
if (this.uniqIdChatMsgPO != msg.uniqId) {
|
|
this.elPopoverChatMsg.hidePopover()
|
|
}
|
|
this.uniqIdChatMsgPO = msg.uniqId
|
|
// @ts-ignore
|
|
this.elPopoverChatMsg.showPopover({source: secMain})
|
|
// ALERT: helps account for firefox which doesnt support anchor based auto positioning currently
|
|
let trect = secMain.getBoundingClientRect();
|
|
let prect = this.elPopoverChatMsg.getBoundingClientRect();
|
|
this.elPopoverChatMsg.style.top = `${trect.top}px`
|
|
this.elPopoverChatMsg.style.left = `${trect.width - (prect.width*1.2)}px`
|
|
})
|
|
secMain.addEventListener('mouseleave', (ev)=>{
|
|
console.debug(`DBUG:MCUI:ChatMessageMLeave:${msg.uniqId}`)
|
|
})
|
|
elParent?.append(secMain)
|
|
secMain.setAttribute("CMUniqId", String(msg.uniqId))
|
|
this.elLastChatMessage = secMain;
|
|
// Create role para
|
|
let entry = ui.el_create_append_p(`${msg.ns.role}`, secMain);
|
|
entry.className = `chat-message-role`;
|
|
// Create content section
|
|
let secContents = document.createElement('section')
|
|
secContents.classList.add('chat-message-contents')
|
|
secMain.append(secContents)
|
|
// Add the content
|
|
let showList = []
|
|
if (msg.ns.has_reasoning()) {
|
|
showList.push(['reasoning', `!!!Reasoning: ${msg.ns.getReasoningContent()} !!!\n\n`])
|
|
}
|
|
if (msg.ns.has_toolresponse()) {
|
|
if (msg.ns.tool_call_id) {
|
|
showList.push(['toolcallid', `tool-call-id: ${msg.ns.tool_call_id}`])
|
|
}
|
|
if (msg.ns.name) {
|
|
showList.push(['toolname', `tool-name: ${msg.ns.name}`])
|
|
}
|
|
}
|
|
if (msg.ns.getContent().trim().length > 0) {
|
|
showList.push(['content', msg.ns.getContent().trim()])
|
|
}
|
|
for (const [name, content] of showList) {
|
|
if (content.length > 0) {
|
|
entry = ui.el_create_append_p(`${content}`, secContents);
|
|
entry.classList.add(`chat-message-${name}`)
|
|
}
|
|
}
|
|
// Handle Image
|
|
if (msg.ns.image_urls) {
|
|
for (const imgUrl of msg.ns.image_urls) {
|
|
let img = document.createElement('img')
|
|
img.classList.add('chat-message-img')
|
|
img.src = imgUrl
|
|
secContents?.append(img)
|
|
}
|
|
}
|
|
// Handle tool call edit/trigger ui, if reqd
|
|
let bTC = false
|
|
let bAuto = false
|
|
if (msg.ns.role === Roles.Assistant) {
|
|
if (iFromLast == 0) {
|
|
bTC = true
|
|
bAuto = true
|
|
} else if ((iFromLast == 1) && (nextMsg != undefined)) {
|
|
if (nextMsg.ns.role == Roles.ToolTemp) {
|
|
bTC = true
|
|
}
|
|
}
|
|
if (bTC) {
|
|
this.ui_toolcallvalidated_as_needed(msg, chatId, bAuto);
|
|
}
|
|
}
|
|
// Handle tool call message show
|
|
if (msg.ns.tool_calls) {
|
|
for (const tc of msg.ns.tool_calls) {
|
|
this.show_message_toolcall(secContents, tc)
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Refresh UI wrt given chatId, provided it matches the currently selected chatId
|
|
*
|
|
* Show the chat contents in elDivChat.
|
|
* Also update
|
|
* * the user query input box, with ToolTemp role message, if last one.
|
|
* * the tool call trigger ui, with Tool role message, if last one.
|
|
*
|
|
* 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 {string} chatId
|
|
* @param {boolean} bClear
|
|
* @param {boolean} bShowInfoAll
|
|
*/
|
|
chat_show(chatId, bClear=true, bShowInfoAll=false) {
|
|
if (chatId != this.curChatId) {
|
|
return false
|
|
}
|
|
let chat = this.simpleChats[this.curChatId];
|
|
if (bClear) {
|
|
this.elDivChat.replaceChildren();
|
|
this.ui_userinput_reset()
|
|
this.elDivStreams[chatId]?.clear()
|
|
}
|
|
this.ui_toolcallvalidated_as_needed(new ChatMessageEx());
|
|
this.elLastChatMessage = null
|
|
let chatToShow = chat.recent_chat(chat.cfg.chatProps.iRecentUserMsgCnt);
|
|
for(const [i, x] of chatToShow.entries()) {
|
|
let iFromLast = (chatToShow.length - 1)-i
|
|
let nextMsg = undefined
|
|
if (iFromLast == 1) {
|
|
nextMsg = chatToShow[i+1]
|
|
}
|
|
this.show_message(this.elDivChat, chat.chatId, x, iFromLast, nextMsg)
|
|
}
|
|
this.elDivChat.appendChild(this.elDivStreams[chatId].div)
|
|
this.elDivChat.appendChild(this.elDivStreams[AI_TC_SESSIONNAME].div)
|
|
if (this.elLastChatMessage != null) {
|
|
this.scroll_el_into_view(this.elLastChatMessage)
|
|
} else {
|
|
if (bClear) {
|
|
this.elDivChat.innerHTML = usage_note(chat.cfg.get_sRecentUserMsgCnt());
|
|
this.me.setup_load(this.elDivChat, chat);
|
|
chat.cfg.show_info(this.elDivChat, bShowInfoAll);
|
|
this.me.show_title(this.elDivChat);
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
/**
|
|
* Remove the specified chat message's ui block from the current chat session ui.
|
|
* @param {number} uniqIdChatMsg
|
|
*/
|
|
chatmsg_ui_remove(uniqIdChatMsg) {
|
|
ui.remove_els(`[CMUniqId="${uniqIdChatMsg}"]`)
|
|
}
|
|
|
|
/**
|
|
* Refresh chat session ui wrt the last N messages
|
|
* in specified chat session, if current.
|
|
*
|
|
* This involves either
|
|
* replacing any existing ui block wrt a given message
|
|
* OR ELSE
|
|
* appending new ui block wrt that given message.
|
|
*
|
|
* Also tool call & response edit/trigger/submit ui will be
|
|
* updated as needed, provided lastN is atleast 2.
|
|
*
|
|
* If the houseKeeping.clear flag is set then fallback to
|
|
* the brute force full on chat_show.
|
|
*
|
|
* @param {string} chatId
|
|
* @param {number} lastN
|
|
*/
|
|
chat_uirefresh(chatId, lastN=2) {
|
|
let chat = this.simpleChats[chatId];
|
|
if (chat.chatId != this.curChatId) {
|
|
return false
|
|
}
|
|
if (this.me.houseKeeping.clear) {
|
|
this.me.houseKeeping.clear = false
|
|
return this.chat_show(chatId, true, true)
|
|
}
|
|
this.ui_userinput_reset(false)
|
|
this.ui_toolcallvalidated_as_needed(new ChatMessageEx());
|
|
for(let i=lastN; i > 0; i-=1) {
|
|
let msg = chat.xchat[chat.xchat.length-i]
|
|
let nextMsg = chat.xchat[chat.xchat.length-(i-1)]
|
|
if (msg) {
|
|
this.chatmsg_ui_remove(msg.uniqId)
|
|
this.show_message(this.elDivChat, chat.chatId, msg, (i-1), nextMsg)
|
|
}
|
|
}
|
|
if (!this.elDivChat.contains(this.elDivStreams[chatId].div)) {
|
|
console.log(`DBUG:SimpleChat:MCUI:UiRefresh:${chatId}: DivStream ${this.elDivStreams[chatId].div.id} missing...`)
|
|
}
|
|
this.elDivChat.appendChild(this.elDivStreams[chatId].div)
|
|
this.elDivChat.appendChild(this.elDivStreams[AI_TC_SESSIONNAME].div)
|
|
if (this.elLastChatMessage != null) {
|
|
this.scroll_el_into_view(this.elLastChatMessage)
|
|
}
|
|
return true
|
|
}
|
|
|
|
/**
|
|
* Add a chatmsg to specified chat session.
|
|
* Update the chat session ui, if current.
|
|
*
|
|
* @param {string} chatId
|
|
* @param {ChatMessageEx} msg
|
|
*/
|
|
chatmsg_addsmart_uishow(chatId, msg) {
|
|
let chat = this.simpleChats[chatId];
|
|
if (!chat) {
|
|
return { added: false, shown: false }
|
|
}
|
|
chat.add_smart(msg)
|
|
return { added: true, shown: this.chat_uirefresh(chat.chatId) }
|
|
}
|
|
|
|
/**
|
|
* Remove the specified ChatMessage block in ui, without needing to show full chat session again.
|
|
* @param {string} curChatId
|
|
* @param {number} uniqIdChatMsg
|
|
* @param {boolean} bUpdateUI
|
|
*/
|
|
chatmsg_del_uiupdate(curChatId, uniqIdChatMsg, bUpdateUI=true) {
|
|
let index = this.simpleChats[curChatId].delete(uniqIdChatMsg)
|
|
if ((index >= 0) && (curChatId == this.curChatId) && bUpdateUI) {
|
|
this.chatmsg_ui_remove(uniqIdChatMsg)
|
|
if (index >= (this.simpleChats[curChatId].xchat.length-1)) {
|
|
// so that tool call edit/trigger and tool call response/submit
|
|
// ui / control etal can be suitably adjusted, force a refresh
|
|
// of the ui wrt the currently last two messages.
|
|
this.chat_uirefresh(curChatId)
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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_toolcallvalidated_as_needed(new ChatMessageEx());
|
|
|
|
this.elBtnSettings.addEventListener("click", (ev)=>{
|
|
this.elDivChat.replaceChildren();
|
|
let chat = this.simpleChats[this.curChatId]
|
|
chat.cfg.show_settings(this.elDivChat, chat.chatId);
|
|
this.elDivUserIn.hidden = true
|
|
this.elDivTool.hidden = true
|
|
this.me.houseKeeping.clear = true;
|
|
});
|
|
this.elBtnClearChat.addEventListener("click", (ev)=>{
|
|
this.simpleChats[this.curChatId].clear()
|
|
this.chat_show(this.curChatId)
|
|
});
|
|
this.elBtnSessionsPrompts.addEventListener("click", (ev)=>{
|
|
if (this.elDivSessionsPrompts.classList.contains("visibility-visible")) {
|
|
this.elDivSessionsPrompts.classList.replace("visibility-visible", "visibility-hidden")
|
|
} else {
|
|
this.elDivSessionsPrompts.classList.replace("visibility-hidden", "visibility-visible")
|
|
}
|
|
})
|
|
|
|
this.elBtnUser.addEventListener("click", (ev)=>{
|
|
clearTimeout(this.timers.toolcallResponseSubmitClick)
|
|
this.timers.toolcallResponseSubmitClick = undefined
|
|
if (this.elInUser.disabled) {
|
|
return;
|
|
}
|
|
let chat = this.simpleChats[this.curChatId]
|
|
this.handle_user_submit(chat.chatId, chat.cfg.chatProps.apiEP).catch((/** @type{Error} */reason)=>{
|
|
let msg = `ERRR:SimpleChat\nMCUI:HandleUserSubmit:${chat.chatId}\n${reason.name}:${reason.message}\n${reason.cause?reason.cause:""}`;
|
|
console.error(msg.replace("\n", ":"));
|
|
alert(msg);
|
|
});
|
|
});
|
|
|
|
this.elBtnTool.addEventListener("click", (ev)=>{
|
|
clearTimeout(this.timers.toolcallTriggerClick)
|
|
this.timers.toolcallTriggerClick = undefined
|
|
if (this.elDivTool.hidden) {
|
|
return;
|
|
}
|
|
this.handle_tool_run(this.curChatId);
|
|
})
|
|
|
|
// Handle messages from tools web workers
|
|
this.me.toolsMgr.workers_cb((cid, tcid, name, data)=>{
|
|
clearTimeout(this.timers.toolcallResponseTimeout)
|
|
this.timers.toolcallResponseTimeout = undefined
|
|
let chat = this.simpleChats[cid];
|
|
let limitedData = data
|
|
if (chat.cfg.tools.iResultMaxDataLength > 0) {
|
|
if (data.length > chat.cfg.tools.iResultMaxDataLength) {
|
|
limitedData = data.slice(0, chat.cfg.tools.iResultMaxDataLength) + `\n\n\nALERT: Data too long, was chopped ....`
|
|
}
|
|
}
|
|
if (this.chatmsg_addsmart_uishow(cid, new ChatMessageEx(NSChatMessage.new_tool_response(Roles.ToolTemp, tcid, name, limitedData))).shown) {
|
|
if (chat.cfg.tools.autoSecs > 0) {
|
|
this.timers.toolcallResponseSubmitClick = setTimeout(()=>{
|
|
this.elBtnUser.click()
|
|
}, chat.cfg.tools.autoSecs*this.TimePeriods.ToolCallAutoSecsTimeUnit)
|
|
}
|
|
}
|
|
})
|
|
|
|
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);
|
|
this.chat_show(chat.chatId)
|
|
ev.preventDefault();
|
|
}
|
|
});
|
|
|
|
// ChatMessage edit popover menu
|
|
|
|
this.elPopoverChatMsgDelBtn.addEventListener('click', (ev) => {
|
|
console.log(`DBUG:SimpleChat:MCUI:ChatMsgPO:Del:${this.curChatId}:${this.uniqIdChatMsgPO}`)
|
|
this.chatmsg_del_uiupdate(this.curChatId, this.uniqIdChatMsgPO)
|
|
})
|
|
|
|
this.elPopoverChatMsgCopyBtn.addEventListener('click', (ev) => {
|
|
console.log(`DBUG:SimpleChat:MCUI:ChatMsgPO:Copy:${this.curChatId}:${this.uniqIdChatMsgPO}`)
|
|
let chatSession = this.simpleChats[this.curChatId]
|
|
let index = chatSession.get_chatmessage_index(this.uniqIdChatMsgPO)
|
|
let chat = chatSession.xchat[index]
|
|
if (!chat.ns.has_content()) {
|
|
return
|
|
}
|
|
let item = new ClipboardItem({ 'text/plain': new Blob([chat.ns.getContent()], { type: 'text/plain'}) });
|
|
navigator.clipboard.write([item])
|
|
})
|
|
|
|
}
|
|
|
|
/**
|
|
* Setup a new chat session and optionally switch to it.
|
|
* @param {string} chatId
|
|
* @param {boolean} bSwitchSession
|
|
*/
|
|
async new_chat_session(chatId, bSwitchSession=false) {
|
|
this.simpleChats[chatId] = new SimpleChat(chatId, this.me.defaultCfg, this.me.toolsMgr);
|
|
await this.simpleChats[chatId].setup();
|
|
this.elDivStreams[chatId] = new ELDivStream(chatId);
|
|
this.elDivStreams[chatId].clear();
|
|
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
|
|
* ie before they submit tool result to ai engine on server
|
|
* * as well as for user to enter their own queries.
|
|
*
|
|
* Based on the presence of predefined dataset attribute (role)
|
|
* wrt input element with value of Roles.ToolTemp,
|
|
* 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) && (chat.cfg.chatProps.bCompletionFreshChatAlways)) {
|
|
chat.clear();
|
|
}
|
|
|
|
this.ui_toolcallvalidated_as_needed(new ChatMessageEx());
|
|
|
|
chat.add_system_anytime(this.elInSystem.value, chatId);
|
|
|
|
let content = this.elInUser.value;
|
|
if (this.elInUser.dataset.role == Roles.ToolTemp) {
|
|
chat.promote_tooltemp(content)
|
|
this.chat_uirefresh(chat.chatId)
|
|
} else {
|
|
if (content.trim() == "") {
|
|
this.elInUser.placeholder = "dont forget to enter a message, before submitting to ai"
|
|
this.chat_uirefresh(chatId)
|
|
return;
|
|
}
|
|
try {
|
|
let images = []
|
|
if (this.me.dataURLs.length > 0) {
|
|
for (const img of this.me.dataURLs) {
|
|
if (img == null) {
|
|
continue
|
|
}
|
|
images.push(/** @type{string} */(img))
|
|
}
|
|
}
|
|
this.chatmsg_addsmart_uishow(chat.chatId, new ChatMessageEx(new NSChatMessage(Roles.User, content, undefined, undefined, undefined, undefined, images)))
|
|
} catch (err) {
|
|
throw new Error("HandleUserSubmit:ChatAddShow failure", {cause: err})
|
|
} finally {
|
|
// TODO:MAYBE: in future if we dont want to clear up user loaded image on failure
|
|
// move this to end of try block
|
|
this.ui_userinput_reset()
|
|
}
|
|
}
|
|
if (this.elInUser.dataset.placeholder) {
|
|
this.elInUser.placeholder = this.elInUser.dataset.placeholder;
|
|
}
|
|
|
|
this.elInUser.value = "working...";
|
|
this.elInUser.disabled = true;
|
|
|
|
try {
|
|
let theResp = await chat.handle_chat_hs(chat.cfg.baseURL, apiEP, this.elDivStreams)
|
|
if (chatId == this.curChatId) {
|
|
this.chat_uirefresh(chatId);
|
|
if ((theResp.trimmedContent) && (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}]`);
|
|
}
|
|
} finally {
|
|
this.ui_userinput_reset();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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.chatmsg_addsmart_uishow(chat.chatId, new ChatMessageEx(NSChatMessage.new_tool_response(Roles.ToolTemp, toolCallId, toolname, toolResult)))
|
|
} else {
|
|
this.timers.toolcallResponseTimeout = setTimeout(() => {
|
|
this.me.toolsMgr.toolcallpending_found_cleared(chat.chatId, toolCallId, 'MCUI:HandleToolRun:TimeOut')
|
|
this.chatmsg_addsmart_uishow(chat.chatId, new ChatMessageEx(NSChatMessage.new_tool_response(Roles.ToolTemp, toolCallId, toolname, `Tool/Function call ${toolname} taking too much time, aborting...`)))
|
|
}, chat.cfg.tools.toolCallResponseTimeoutMS)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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("NewCHAT", (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;
|
|
}
|
|
chatIdGot = SimpleChat.ChatIdClean(chatIdGot)
|
|
this.new_chat_session(chatIdGot, true).then(()=>{
|
|
this.create_session_btn(elDiv, chatIdGot);
|
|
ui.el_children_config_class(elDiv, chatIdGot, "session-selected", "");
|
|
})
|
|
},"NewChat", "+ new");
|
|
btnNew.title = "start a new chat session"
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Save configs wrt all the currently loaded sessions
|
|
*/
|
|
save_sessions_cfgs() {
|
|
for (const cid of Object.keys(this.simpleChats)) {
|
|
this.simpleChats[cid].save_cfg()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Switch ui to the specified chatId and set curChatId to same.
|
|
* @param {string} chatId
|
|
*/
|
|
async handle_session_switch(chatId) {
|
|
if (this.elInUser.dataset.placeholder) {
|
|
this.elInUser.placeholder = this.elInUser.dataset.placeholder;
|
|
}
|
|
let chat = this.simpleChats[chatId];
|
|
if (chat == undefined) {
|
|
console.error(`ERRR:SimpleChat:MCUI:HandleSessionSwitch:${chatId} missing...`);
|
|
return;
|
|
}
|
|
this.save_sessions_cfgs()
|
|
this.elDivUserIn.hidden = false
|
|
this.elInSystem.value = chat.get_system_latest().ns.getContent();
|
|
this.curChatId = chatId;
|
|
this.chat_show(chatId, true, true);
|
|
this.elInUser.focus();
|
|
this.me.houseKeeping.clear = true;
|
|
console.log(`INFO:SimpleChat:MCUI:HandleSessionSwitch:${chatId} entered...`);
|
|
}
|
|
|
|
}
|
|
|
|
|
|
/**
|
|
* Few web search engine url template strings.
|
|
* The SEARCHWORDS keyword will get replaced by the actual user specified search words at runtime.
|
|
*/
|
|
const SearchURLS = {
|
|
duckduckgo: {
|
|
'template': "https://duckduckgo.com/html/?q=SEARCHWORDS",
|
|
'drop': [ { 'tag': 'div', 'id': "header" } ]
|
|
},
|
|
bing: {
|
|
'template': "https://www.bing.com/search?q=SEARCHWORDS", // doesnt seem to like google chrome clients in particular
|
|
},
|
|
brave: {
|
|
'template': "https://search.brave.com/search?q=SEARCHWORDS",
|
|
},
|
|
google: {
|
|
'template': "https://www.google.com/search?q=SEARCHWORDS", // doesnt seem to like any client in general
|
|
},
|
|
}
|
|
|
|
|
|
export class Config {
|
|
|
|
constructor() {
|
|
this.baseURL = "http://127.0.0.1:8080";
|
|
this.tools = {
|
|
enabled: true,
|
|
proxyUrl: "http://127.0.0.1:3128",
|
|
proxyAuthInsecure: "NeverSecure",
|
|
searchUrl: SearchURLS.duckduckgo.template,
|
|
searchDrops: SearchURLS.duckduckgo.drop,
|
|
toolNames: /** @type {Array<string>} */([]),
|
|
/**
|
|
* Control the length of the tool call result data returned to ai after tool call.
|
|
* A value of 0 is treated as unlimited data.
|
|
*/
|
|
iResultMaxDataLength: 1024*128,
|
|
/**
|
|
* Control how many milliseconds to wait for tool call to respond, before generating a timed out
|
|
* error response and giving control back to end user.
|
|
*/
|
|
toolCallResponseTimeoutMS: 200*1000,
|
|
/**
|
|
* Control how many seconds to wait before auto triggering tool call or its response submission.
|
|
* A value of 0 is treated as auto triggering disable.
|
|
*/
|
|
autoSecs: 0
|
|
};
|
|
this.chatProps = {
|
|
apiEP: ApiEP.Type.Chat,
|
|
stream: true,
|
|
iRecentUserMsgCnt: 5,
|
|
bCompletionFreshChatAlways: true,
|
|
bCompletionInsertStandardRolePrefix: false,
|
|
bTrimGarbage: true,
|
|
};
|
|
/** @type {Object<string, number>} */
|
|
this.sRecentUserMsgCntDict = {
|
|
"Full": -1,
|
|
"Last0": 1,
|
|
"Last1": 2,
|
|
"Last2": 3,
|
|
"Last4": 5,
|
|
"Last9": 10,
|
|
};
|
|
/** @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": true,
|
|
//"frequency_penalty": 1.2,
|
|
//"presence_penalty": 1.2,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Clone a given instance of Config
|
|
* @param {Config} cloneMePlease
|
|
*/
|
|
static clone (cloneMePlease) {
|
|
let clonedData = structuredClone(cloneMePlease)
|
|
// Object.setPrototypeOf(clonedData, Config.prototype);
|
|
let newMe = new Config()
|
|
return Object.assign(newMe, clonedData)
|
|
}
|
|
|
|
/**
|
|
* 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", "tools", "apiRequestOptions", "chatProps"];
|
|
if (!bAll) {
|
|
props = [ "baseURL", "modelInfo", "tools", "chatProps" ];
|
|
}
|
|
let elInfo = document.createElement("div")
|
|
elInfo.id = "DefaultInfo"
|
|
elDiv.appendChild(elInfo)
|
|
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(elInfo, this, props, "Current Settings/Info (dev console document[gMe])", "", { toplegend: 'role-system' })
|
|
}).catch(err=>console.log(`WARN:ShowInfo:${err}`))
|
|
}
|
|
|
|
/**
|
|
* Show settings ui for configurable parameters, in the passed Div element.
|
|
* @param {HTMLDivElement} elDiv
|
|
* @param {string} tag
|
|
*/
|
|
show_settings(elDiv, tag) {
|
|
ui.ui_show_obj_props_edit(elDiv, "", this, ["baseURL", "headers", "tools", "apiRequestOptions", "chatProps"], `Settings-${tag}`, (prop, elProp)=>{
|
|
if (prop == "headers:Authorization") {
|
|
// @ts-ignore
|
|
elProp.placeholder = "Bearer OPENAI_API_KEY";
|
|
}
|
|
if (prop.startsWith("tools:toolName")) {
|
|
/** @type {HTMLInputElement} */(elProp).disabled = true
|
|
}
|
|
}, [":chatProps:apiEP", ":chatProps:iRecentUserMsgCnt"], (propWithPath, prop, elParent)=>{
|
|
if (propWithPath == ":chatProps:apiEP") {
|
|
let sel = ui.el_creatediv_select("SetApiEP", "ApiEndPoint", ApiEP.Type, this.chatProps.apiEP, (val)=>{
|
|
// @ts-ignore
|
|
this.chatProps.apiEP = ApiEP.Type[val];
|
|
});
|
|
elParent.appendChild(sel.div);
|
|
}
|
|
if (propWithPath == ":chatProps:iRecentUserMsgCnt") {
|
|
let sel = ui.el_creatediv_select("SetChatHistoryInCtxt", "ChatHistoryInCtxt", this.sRecentUserMsgCntDict, this.chatProps.iRecentUserMsgCnt, (val)=>{
|
|
this.chatProps.iRecentUserMsgCnt = this.sRecentUserMsgCntDict[val];
|
|
});
|
|
elParent.appendChild(sel.div);
|
|
}
|
|
})
|
|
}
|
|
|
|
get_sRecentUserMsgCnt() {
|
|
let sRecentUserMsgCnt = Object.keys(this.sRecentUserMsgCntDict).find((key)=>{
|
|
if (this.sRecentUserMsgCntDict[key] == this.chatProps.iRecentUserMsgCnt) {
|
|
return true
|
|
}
|
|
return false
|
|
});
|
|
if (sRecentUserMsgCnt) {
|
|
return sRecentUserMsgCnt;
|
|
}
|
|
return "Unknown";
|
|
}
|
|
|
|
}
|
|
|
|
|
|
export class Me {
|
|
|
|
constructor() {
|
|
this.defaultChatIds = [ "Default", "Other", AI_TC_SESSIONNAME ];
|
|
this.defaultCfg = new Config()
|
|
this.multiChat = new MultiChatUI(this);
|
|
this.toolsMgr = new mTools.ToolsManager()
|
|
/**
|
|
* @type {(string | ArrayBuffer | null)[]}
|
|
*/
|
|
this.dataURLs = []
|
|
this.houseKeeping = {
|
|
clear: true,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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) {
|
|
let tag = `Me:Load:${chat.chatId}`;
|
|
let elRestore = document.createElement("details")
|
|
elRestore.id = "DefaultRestore"
|
|
elRestore.hidden = true
|
|
elRestore.open = true
|
|
elRestore.innerHTML += '<summary class="role-system">Restore</summary>\n';
|
|
div.appendChild(elRestore)
|
|
mIdb.db_getkeys(DB_NAME, DB_STORE, tag, (status, related)=>{
|
|
if (!status || (related == null)) {
|
|
return
|
|
}
|
|
if (related.constructor.name == DOMException.name) {
|
|
return
|
|
}
|
|
if (/** @type {IDBValidKey[]} */(related).indexOf(chat.ods_key()) == -1) {
|
|
return;
|
|
}
|
|
elRestore.innerHTML += `<p>Load previously saved chat session, if available</p>`;
|
|
let btn = ui.el_create_button(chat.ods_key(), (ev)=>{
|
|
console.debug(`DBUG:${tag}`, chat);
|
|
this.multiChat.elInUser.value = `Loading ${chat.ods_key()}...`
|
|
chat.load((loadStatus, dbStatus, related)=>{
|
|
if (!loadStatus || !dbStatus) {
|
|
console.log(`WARN:${tag}:DidntLoad:${loadStatus}:${dbStatus}:${related}`);
|
|
return;
|
|
}
|
|
console.log(`INFO:${tag}:Loaded:${loadStatus}:${dbStatus}`);
|
|
queueMicrotask(()=>{
|
|
this.multiChat.chat_show(chat.chatId, true, true);
|
|
this.multiChat.elInSystem.value = chat.get_system_latest().ns.getContent();
|
|
});
|
|
});
|
|
});
|
|
elRestore.appendChild(btn);
|
|
elRestore.hidden = false;
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Show the title of this program
|
|
* @param {HTMLDivElement} elDiv
|
|
*/
|
|
show_title(elDiv) {
|
|
let elTitle = document.createElement("div");
|
|
elTitle.id = "DefaultTitle";
|
|
//SimpleChatTCRV-अन्वेषिका-सल्लाप
|
|
elTitle.appendChild(document.createTextNode("AnveshikaSallap"))
|
|
elDiv.appendChild(elTitle)
|
|
}
|
|
|
|
|
|
}
|