diff --git a/tools/server/public_simplechat/index.html b/tools/server/public_simplechat/index.html index f6413016fcc53..3cd840569c3a7 100644 --- a/tools/server/public_simplechat/index.html +++ b/tools/server/public_simplechat/index.html @@ -40,6 +40,19 @@

You need to have javascript enabled.

+
+
+
+
+ + +
+
+
+ +
+
+
diff --git a/tools/server/public_simplechat/local.tools/simpleproxy.json b/tools/server/public_simplechat/local.tools/simpleproxy.json new file mode 100644 index 0000000000000..1bae207341ec0 --- /dev/null +++ b/tools/server/public_simplechat/local.tools/simpleproxy.json @@ -0,0 +1,49 @@ +{ + "allowed.domains": [ + ".*\\.wikipedia\\.org$", + ".*\\.bing\\.com$", + "^www\\.bing\\.com$", + ".*\\.yahoo\\.com$", + "^search\\.yahoo\\.com$", + ".*\\.brave\\.com$", + "^search\\.brave\\.com$", + "^brave\\.com$", + ".*\\.duckduckgo\\.com$", + "^duckduckgo\\.com$", + ".*\\.google\\.com$", + "^google\\.com$", + "^arxiv\\.org$", + ".*\\.nature\\.com$", + ".*\\.science\\.org$", + "^apnews\\.com$", + ".*\\.apnews\\.com$", + ".*\\.reuters\\.com$", + ".*\\.bloomberg\\.com$", + ".*\\.forbes\\.com$", + ".*\\.npr\\.org$", + ".*\\.cnn\\.com$", + ".*\\.theguardian\\.com$", + ".*\\.bbc\\.com$", + ".*\\.france24\\.com$", + ".*\\.dw\\.com$", + ".*\\.jpost\\.com$", + ".*\\.aljazeera\\.com$", + ".*\\.alarabiya\\.net$", + ".*\\.rt\\.com$", + "^tass\\.com$", + ".*\\.channelnewsasia\\.com$", + ".*\\.scmp\\.com$", + ".*\\.nikkei\\.com$", + ".*\\.nhk\\.or\\.jp$", + ".*\\.indiatoday\\.in$", + "^theprint\\.in$", + ".*\\.ndtv\\.com$", + "^lwn\\.net$", + "^arstechnica\\.com$", + ".*\\.linkedin\\.com$", + ".*\\.github\\.io$", + "^github\\.com$", + ".*\\.github\\.com$" + ], + "bearer.insecure": "NeverSecure" +} diff --git a/tools/server/public_simplechat/local.tools/simpleproxy.py b/tools/server/public_simplechat/local.tools/simpleproxy.py new file mode 100644 index 0000000000000..8b17d012c053b --- /dev/null +++ b/tools/server/public_simplechat/local.tools/simpleproxy.py @@ -0,0 +1,455 @@ +# A simple proxy server +# by Humans for All +# +# Listens on the specified port (defaults to squids 3128) +# * if a url query is got wrt urlraw path +# http://localhost:3128/urlraw?url=http://site.of.interest/path/of/interest +# fetches the contents of the specified url and returns the same to the requester +# * if a url query is got wrt urltext path +# http://localhost:3128/urltext?url=http://site.of.interest/path/of/interest +# fetches the contents of the specified url and returns the same to the requester +# after removing html tags in general as well as contents of tags like style +# script, header, footer, nav ... +# * any request to aum path is used to respond with a predefined text response +# which can help identify this server, in a simple way. +# +# Expects a Bearer authorization line in the http header of the requests got. +# HOWEVER DO KEEP IN MIND THAT ITS A VERY INSECURE IMPLEMENTATION, AT BEST +# + + +import sys +import http.server +import urllib.parse +import urllib.request +from dataclasses import dataclass +import html.parser +import re +import time + + +gMe = { + '--port': 3128, + '--config': '/dev/null', + '--debug': False, + 'bearer.transformed.year': "", + 'server': None +} + +gConfigType = { + '--port': 'int', + '--config': 'str', + '--debug': 'bool', + '--allowed.domains': 'list', + '--bearer.insecure': 'str' +} + +gConfigNeeded = [ '--allowed.domains', '--bearer.insecure' ] + + +def bearer_transform(): + """ + Transform the raw bearer token to the network handshaked token, + if and when needed. + """ + global gMe + year = str(time.gmtime().tm_year) + if gMe['bearer.transformed.year'] == year: + return + import hashlib + s256 = hashlib.sha256(year.encode('utf-8')) + s256.update(gMe['--bearer.insecure'].encode('utf-8')) + gMe['--bearer.transformed'] = s256.hexdigest() + gMe['bearer.transformed.year'] = year + + +class ProxyHandler(http.server.BaseHTTPRequestHandler): + """ + Implements the logic for handling requests sent to this server. + """ + + def send_headers_common(self): + """ + Common headers to include in responses from this server + """ + self.send_header('Access-Control-Allow-Origin', '*') + self.send_header('Access-Control-Allow-Methods', 'GET, OPTIONS') + self.send_header('Access-Control-Allow-Headers', '*') + self.end_headers() + + def send_error(self, code: int, message: str | None = None, explain: str | None = None) -> None: + """ + Overrides the SendError helper + so that the common headers mentioned above can get added to them + else CORS failure will be triggered by the browser on fetch from browser. + """ + print(f"WARN:PH:SendError:{code}:{message}") + self.send_response(code, message) + self.send_headers_common() + + def auth_check(self): + """ + Simple Bearer authorization + ALERT: For multiple reasons, this is a very insecure implementation. + """ + bearer_transform() + authline = self.headers['Authorization'] + if authline == None: + return { 'AllOk': False, 'Msg': "No auth line" } + authlineA = authline.strip().split(' ') + if len(authlineA) != 2: + return { 'AllOk': False, 'Msg': "Invalid auth line" } + if authlineA[0] != 'Bearer': + return { 'AllOk': False, 'Msg': "Invalid auth type" } + if authlineA[1] != gMe['--bearer.transformed']: + return { 'AllOk': False, 'Msg': "Invalid auth" } + return { 'AllOk': True, 'Msg': "Auth Ok" } + + def do_GET(self): + """ + Handle GET requests + """ + print(f"\n\n\nDBUG:ProxyHandler:GET:{self.address_string()}:{self.path}") + print(f"DBUG:PH:Get:Headers:{self.headers}") + pr = urllib.parse.urlparse(self.path) + print(f"DBUG:ProxyHandler:GET:{pr}") + match pr.path: + case '/urlraw': + acGot = self.auth_check() + if not acGot['AllOk']: + self.send_error(400, f"WARN:{acGot['Msg']}") + else: + handle_urlraw(self, pr) + case '/urltext': + acGot = self.auth_check() + if not acGot['AllOk']: + self.send_error(400, f"WARN:{acGot['Msg']}") + else: + handle_urltext(self, pr) + case '/aum': + handle_aum(self, pr) + case _: + print(f"WARN:ProxyHandler:GET:UnknownPath{pr.path}") + self.send_error(400, f"WARN:UnknownPath:{pr.path}") + + def do_OPTIONS(self): + """ + Handle OPTIONS for CORS preflights (just in case from browser) + """ + print(f"DBUG:ProxyHandler:OPTIONS:{self.path}") + self.send_response(200) + self.send_headers_common() + + +def handle_aum(ph: ProxyHandler, pr: urllib.parse.ParseResult): + """ + Handle requests to aum path, which is used in a simple way to + verify that one is communicating with this proxy server + """ + ph.send_response_only(200, "bharatavarshe") + ph.send_header('Access-Control-Allow-Origin', '*') + ph.end_headers() + + +@dataclass(frozen=True) +class UrlReqResp: + """ + Used to return result wrt urlreq helper below. + """ + callOk: bool + httpStatus: int + httpStatusMsg: str = "" + contentType: str = "" + contentData: str = "" + + +def debug_dump(meta: dict, data: dict): + if not gMe['--debug']: + return + timeTag = f"{time.time():0.12f}" + with open(f"/tmp/simpleproxy.{timeTag}.meta", '+w') as f: + for k in meta: + f.write(f"\n\n\n\n{k}:{meta[k]}\n\n\n\n") + with open(f"/tmp/simpleproxy.{timeTag}.data", '+w') as f: + for k in data: + f.write(f"\n\n\n\n{k}:{data[k]}\n\n\n\n") + + +def validate_url(url: str, tag: str): + """ + Implement a re based filter logic on the specified url. + """ + tag=f"VU:{tag}" + if (not gMe.get('--allowed.domains')): + return UrlReqResp(False, 400, f"DBUG:{tag}:MissingAllowedDomains") + urlParts = urllib.parse.urlparse(url) + print(f"DBUG:ValidateUrl:{urlParts}, {urlParts.hostname}") + urlHName = urlParts.hostname + if not urlHName: + return UrlReqResp(False, 400, f"WARN:{tag}:Missing hostname in Url") + bMatched = False + for filter in gMe['--allowed.domains']: + if re.match(filter, urlHName): + bMatched = True + if not bMatched: + return UrlReqResp(False, 400, f"WARN:{tag}:requested hostname not allowed") + return UrlReqResp(True, 200) + + +def handle_urlreq(ph: ProxyHandler, pr: urllib.parse.ParseResult, tag: str): + """ + Common part of the url request handling used by both urlraw and urltext. + + Verify the url being requested is allowed. + + Include User-Agent, Accept-Language and Accept in the generated request using + equivalent values got in the request being proxied, so as to try mimic the + real client, whose request we are proxying. In case a header is missing in the + got request, fallback to using some possibly ok enough defaults. + + Fetch the requested url. + """ + tag=f"UrlReq:{tag}" + queryParams = urllib.parse.parse_qs(pr.query) + url = queryParams['url'] + print(f"DBUG:{tag}:Url:{url}") + url = url[0] + if (not url) or (len(url) == 0): + return UrlReqResp(False, 400, f"WARN:{tag}:MissingUrl") + gotVU = validate_url(url, tag) + if not gotVU.callOk: + return gotVU + try: + hUA = ph.headers.get('User-Agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:132.0) Gecko/20100101 Firefox/132.0') + hAL = ph.headers.get('Accept-Language', "en-US,en;q=0.9") + hA = ph.headers.get('Accept', "text/html,*/*") + headers = { + 'User-Agent': hUA, + 'Accept': hA, + 'Accept-Language': hAL + } + req = urllib.request.Request(url, headers=headers) + # Get requested url + print(f"DBUG:{tag}:Req:{req.full_url}:{req.headers}") + with urllib.request.urlopen(req, timeout=10) as response: + contentData = response.read().decode('utf-8') + statusCode = response.status or 200 + contentType = response.getheader('Content-Type') or 'text/html' + debug_dump({ 'url': req.full_url, 'headers': req.headers, 'ctype': contentType }, { 'cdata': contentData }) + return UrlReqResp(True, statusCode, "", contentType, contentData) + except Exception as exc: + return UrlReqResp(False, 502, f"WARN:{tag}:Failed:{exc}") + + +def handle_urlraw(ph: ProxyHandler, pr: urllib.parse.ParseResult): + try: + # Get requested url + got = handle_urlreq(ph, pr, "HandleUrlRaw") + if not got.callOk: + ph.send_error(got.httpStatus, got.httpStatusMsg) + return + # Send back to client + ph.send_response(got.httpStatus) + ph.send_header('Content-Type', got.contentType) + # Add CORS for browser fetch, just in case + ph.send_header('Access-Control-Allow-Origin', '*') + ph.end_headers() + ph.wfile.write(got.contentData.encode('utf-8')) + except Exception as exc: + ph.send_error(502, f"WARN:UrlRawFailed:{exc}") + + +class TextHtmlParser(html.parser.HTMLParser): + """ + A simple minded logic used to strip html content of + * all the html tags as well as + * all the contents belonging to below predefined tags like script, style, header, ... + + NOTE: if the html content/page uses any javascript for client side manipulation/generation of + html content, that logic wont be triggered, so also such client side dynamic content wont be + got. + + This helps return a relatively clean textual representation of the html file/content being parsed. + """ + + def __init__(self): + super().__init__() + self.inside = { + 'body': False, + 'script': False, + 'style': False, + 'header': False, + 'footer': False, + 'nav': False + } + self.monitored = [ 'body', 'script', 'style', 'header', 'footer', 'nav' ] + self.bCapture = False + self.text = "" + self.textStripped = "" + + def do_capture(self): + """ + Helps decide whether to capture contents or discard them. + """ + if self.inside['body'] and not (self.inside['script'] or self.inside['style'] or self.inside['header'] or self.inside['footer'] or self.inside['nav']): + return True + return False + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]): + if tag in self.monitored: + self.inside[tag] = True + + def handle_endtag(self, tag: str): + if tag in self.monitored: + self.inside[tag] = False + + def handle_data(self, data: str): + if self.do_capture(): + self.text += f"{data}\n" + + def syncup(self): + self.textStripped = self.text + + def strip_adjacent_newlines(self): + oldLen = -99 + newLen = len(self.textStripped) + aStripped = self.textStripped; + while oldLen != newLen: + oldLen = newLen + aStripped = aStripped.replace("\n\n\n","\n") + newLen = len(aStripped) + self.textStripped = aStripped + + def strip_whitespace_lines(self): + aLines = self.textStripped.splitlines() + self.textStripped = "" + for line in aLines: + if (len(line.strip())==0): + self.textStripped += "\n" + continue + self.textStripped += f"{line}\n" + + def get_stripped_text(self): + self.syncup() + self.strip_whitespace_lines() + self.strip_adjacent_newlines() + return self.textStripped + + +def handle_urltext(ph: ProxyHandler, pr: urllib.parse.ParseResult): + try: + # Get requested url + got = handle_urlreq(ph, pr, "HandleUrlText") + if not got.callOk: + ph.send_error(got.httpStatus, got.httpStatusMsg) + return + # Extract Text + textHtml = TextHtmlParser() + textHtml.feed(got.contentData) + # Send back to client + ph.send_response(got.httpStatus) + ph.send_header('Content-Type', got.contentType) + # Add CORS for browser fetch, just in case + ph.send_header('Access-Control-Allow-Origin', '*') + ph.end_headers() + ph.wfile.write(textHtml.get_stripped_text().encode('utf-8')) + debug_dump({ 'RawText': 'yes', 'StrippedText': 'yes' }, { 'RawText': textHtml.text, 'StrippedText': textHtml.get_stripped_text() }) + except Exception as exc: + ph.send_error(502, f"WARN:UrlTextFailed:{exc}") + + +def load_config(): + """ + Allow loading of a json based config file + + The config entries should be named same as their equivalent cmdline argument + entries but without the -- prefix. They will be loaded into gMe after adding + -- prefix. + + As far as the program is concerned the entries could either come from cmdline + or from a json based config file. + """ + global gMe + import json + with open(gMe['--config']) as f: + cfg = json.load(f) + for k in cfg: + print(f"DBUG:LoadConfig:{k}") + try: + cArg = f"--{k}" + aTypeCheck = gConfigType[cArg] + aValue = cfg[k] + aType = type(aValue).__name__ + if aType != aTypeCheck: + print(f"ERRR:LoadConfig:{k}:expected type [{aTypeCheck}] got type [{aType}]") + exit(112) + gMe[cArg] = aValue + except KeyError: + print(f"ERRR:LoadConfig:{k}:UnknownCommand") + exit(113) + + +def process_args(args: list[str]): + """ + Helper to process command line arguments. + + Flow setup below such that + * location of --config in commandline will decide whether command line or config file will get + priority wrt setting program parameters. + * str type values in cmdline are picked up directly, without running them through ast.literal_eval, + bcas otherwise one will have to ensure throught the cmdline arg mechanism that string quote is + retained for literal_eval + """ + import ast + global gMe + iArg = 1 + while iArg < len(args): + cArg = args[iArg] + if (not cArg.startswith("--")): + print(f"ERRR:ProcessArgs:{iArg}:{cArg}:MalformedCommandOr???") + exit(101) + print(f"DBUG:ProcessArgs:{iArg}:{cArg}") + try: + aTypeCheck = gConfigType[cArg] + aValue = args[iArg+1] + if aTypeCheck != 'str': + aValue = ast.literal_eval(aValue) + aType = type(aValue).__name__ + if aType != aTypeCheck: + print(f"ERRR:ProcessArgs:{iArg}:{cArg}:expected type [{aTypeCheck}] got type [{aType}]") + exit(102) + gMe[cArg] = aValue + iArg += 2 + if cArg == '--config': + load_config() + except KeyError: + print(f"ERRR:ProcessArgs:{iArg}:{cArg}:UnknownCommand") + exit(103) + print(gMe) + for k in gConfigNeeded: + if gMe.get(k) == None: + print(f"ERRR:ProcessArgs:{k}:missing, did you forget to pass the config file...") + exit(104) + + +def run(): + try: + gMe['serverAddr'] = ('', gMe['--port']) + gMe['server'] = http.server.HTTPServer(gMe['serverAddr'], ProxyHandler) + print(f"INFO:Run:Starting on {gMe['serverAddr']}") + gMe['server'].serve_forever() + except KeyboardInterrupt: + print("INFO:Run:Shuting down...") + if (gMe['server']): + gMe['server'].server_close() + sys.exit(0) + except Exception as exc: + print(f"ERRR:Run:Exiting:Exception:{exc}") + if (gMe['server']): + gMe['server'].server_close() + sys.exit(1) + + +if __name__ == "__main__": + process_args(sys.argv) + run() diff --git a/tools/server/public_simplechat/readme.md b/tools/server/public_simplechat/readme.md index 24e026d455b03..7f4df25e60ad9 100644 --- a/tools/server/public_simplechat/readme.md +++ b/tools/server/public_simplechat/readme.md @@ -7,24 +7,27 @@ by Humans for All. To run from the build dir -bin/llama-server -m path/model.gguf --path ../tools/server/public_simplechat +bin/llama-server -m path/model.gguf --path ../tools/server/public_simplechat --jinja Continue reading for the details. ## overview This simple web frontend, allows triggering/testing the server's /completions or /chat/completions endpoints -in a simple way with minimal code from a common code base. Inturn additionally it tries to allow single or -multiple independent back and forth chatting to an extent, with the ai llm model at a basic level, with their -own system prompts. +in a simple way with minimal code from a common code base. Additionally it also allows end users to have +single or multiple independent chat sessions with back and forth chatting to an extent, with the ai llm model +at a basic level, with their own system prompts. This allows seeing the generated text / ai-model response in oneshot at the end, after it is fully generated, or potentially as it is being generated, in a streamed manner from the server/ai-model. -![Chat and Settings screens](./simplechat_screens.webp "Chat and Settings screens") +![Chat and Settings (old) screens](./simplechat_screens.webp "Chat and Settings (old) screens") Auto saves the chat session locally as and when the chat is progressing and inturn at a later time when you -open SimpleChat, option is provided to restore the old chat session, if a matching one exists. +open SimpleChat, option is provided to restore the old chat session, if a matching one exists. In turn if +any of those chat sessions were pending wrt user triggering a tool call or submitting a tool call response, +the ui is setup as needed for end user to continue with those previously saved sessions, from where they +left off. The UI follows a responsive web design so that the layout can adapt to available display space in a usable enough manner, in general. @@ -33,13 +36,24 @@ Allows developer/end-user to control some of the behaviour by updating gMe membe console. Parallely some of the directly useful to end-user settings can also be changed using the provided settings ui. -NOTE: Current web service api doesnt expose the model context length directly, so client logic doesnt provide -any adaptive culling of old messages nor of replacing them with summary of their content etal. However there -is a optional sliding window based chat logic, which provides a simple minded culling of old messages from -the chat history before sending to the ai model. +For GenAi/LLM models supporting tool / function calling, allows one to interact with them and explore use of +ai driven augmenting of the knowledge used for generating answers as well as for cross checking ai generated +answers logically / programatically and by checking with other sources and lot more by making using of the +simple yet useful predefined tools / functions provided by this client web ui. The end user is provided full +control over tool calling and response submitting. -NOTE: Wrt options sent with the request, it mainly sets temperature, max_tokens and optionaly stream for now. -However if someone wants they can update the js file or equivalent member in gMe as needed. +For GenAi/LLM models which support reasoning, the thinking of the model will be shown to the end user as the +model is running through its reasoning. + +NOTE: As all genai/llm web service apis may or may not expose the model context length directly, and also +as using ai out of band for additional parallel work may not be efficient given the loading of current systems +by genai/llm models, so client logic doesnt provide any adaptive culling of old messages nor of replacing them +with summary of their content etal. However there is a optional sliding window based chat logic, which provides +a simple minded culling of old messages from the chat history before sending to the ai model. + +NOTE: Wrt options sent with the request, it mainly sets temperature, max_tokens and optionaly stream as well +as tool_calls mainly for now. However if someone wants they can update the js file or equivalent member in +gMe as needed. NOTE: One may be able to use this to chat with openai api web-service /chat/completions endpoint, in a very limited / minimal way. One will need to set model, openai url and authorization bearer key in settings ui. @@ -51,7 +65,7 @@ One could run this web frontend directly using server itself or if anyone is thi frontend to configure the server over http(s) or so, then run this web frontend using something like python's http module. -### running using tools/server +### running directly using tools/server ./llama-server -m path/model.gguf --path tools/server/public_simplechat [--port PORT] @@ -64,6 +78,32 @@ next run this web front end in tools/server/public_simplechat * cd ../tools/server/public_simplechat * python3 -m http.server PORT +### for tool calling + +remember to + +* pass --jinja to llama-server to enable tool calling support from the server ai engine end. + +* set tools.enabled to true in the settings page of the client side gui. + +* use a GenAi/LLM model which supports tool calling. + +* if fetch web page or web search tool call is needed remember to run bundled local.tools/simpleproxy.py + helper along with its config file, before using/loading this client ui through a browser + + * cd tools/server/public_simplechat/local.tools; python3 ./simpleproxy.py --config simpleproxy.json + + * remember that this is a relatively minimal dumb proxy logic along with optional stripping of non textual + content like head, scripts, styles, headers, footers, ... Be careful when accessing web through this and + use it only with known safe sites. + + * look into local.tools/simpleproxy.json for specifying + + * the white list of allowed.domains + * the shared bearer token between server and client ui + + + ### using the front end Open this simple web front end from your local browser @@ -78,8 +118,9 @@ Once inside * try trim garbage in response or not * amount of chat history in the context sent to server/ai-model * oneshot or streamed mode. + * use built in tool calling or not and its related params. -* In completion mode +* In completion mode >> note: most recent work has been in chat mode << * one normally doesnt use a system prompt in completion mode. * logic by default doesnt insert any role specific "ROLE: " prefix wrt each role's message. If the model requires any prefix wrt user role messages, then the end user has to @@ -116,7 +157,27 @@ Once inside * the user input box will be disabled and a working message will be shown in it. * if trim garbage is enabled, the logic will try to trim repeating text kind of garbage to some extent. +* any reasoning / thinking by the model is shown to the end user, as it is occuring, if the ai model + shares the same over the http interface. + +* tool calling flow when working with ai models which support tool / function calling + * if tool calling is enabled and the user query results in need for one of the builtin tools to be + called, then the ai response might include request for tool call. + * the SimpleChat client will show details of the tool call (ie tool name and args passed) requested + and allow the user to trigger it as is or after modifying things as needed. + NOTE: Tool sees the original tool call only, for now + * inturn returned / generated result is placed into user query entry text area with approriate tags + ie generated result with meta data + * if user is ok with the tool response, they can click submit to send the same to the GenAi/LLM. + User can even modify the response generated by the tool, if required, before submitting. + * ALERT: Sometimes the reasoning or chat from ai model may indicate tool call, but you may actually + not get/see a tool call, in such situations, dont forget to cross check that tool calling is + enabled in the settings. + * just refresh the page, to reset wrt the chat history and or system prompt and start afresh. + This also helps if you had forgotten to start the bundled simpleproxy.py server before hand. + Start the simpleproxy.py server and refresh the client ui page, to get access to web access + related tool calls. * Using NewChat one can start independent chat sessions. * two independent chat sessions are setup by default. @@ -144,78 +205,88 @@ Me/gMe consolidates the settings which control the behaviour into one object. One can see the current settings, as well as change/update them using browsers devel-tool/console. It is attached to the document object. Some of these can also be updated using the Settings UI. - baseURL - the domain-name/ip-address and inturn the port to send the request. + * baseURL - the domain-name/ip-address and inturn the port to send the request. + + * chatProps - maintain a set of properties which manipulate chatting with ai engine + + * apiEP - select between /completions and /chat/completions endpoint provided by the server/ai-model. + + * stream - control between oneshot-at-end and live-stream-as-its-generated collating and showing of the generated response. + + the logic assumes that the text sent from the server follows utf-8 encoding. + + in streaming mode - if there is any exception, the logic traps the same and tries to ensure that text generated till then is not lost. + + * if a very long text is being generated, which leads to no user interaction for sometime and inturn the machine goes into power saving mode or so, the platform may stop network connection, leading to exception. + + * iRecentUserMsgCnt - a simple minded SlidingWindow to limit context window load at Ai Model end. This is set to 10 by default. So in addition to latest system message, last/latest iRecentUserMsgCnt user messages after the latest system prompt and its responses from the ai model will be sent to the ai-model, when querying for a new response. Note that if enabled, only user messages after the latest system message/prompt will be considered. + + This specified sliding window user message count also includes the latest user query. + + * less than 0 : Send entire chat history to server - bStream - control between oneshot-at-end and live-stream-as-its-generated collating and showing - of the generated response. + * 0 : Send only the system message if any to the server - the logic assumes that the text sent from the server follows utf-8 encoding. + * greater than 0 : Send the latest chat history from the latest system prompt, limited to specified cnt. - in streaming mode - if there is any exception, the logic traps the same and tries to ensure - that text generated till then is not lost. + * bCompletionFreshChatAlways - whether Completion mode collates complete/sliding-window history when communicating with the server or only sends the latest user query/message. - if a very long text is being generated, which leads to no user interaction for sometime and - inturn the machine goes into power saving mode or so, the platform may stop network connection, - leading to exception. + * bCompletionInsertStandardRolePrefix - whether Completion mode inserts role related prefix wrt the messages that get inserted into prompt field wrt /Completion endpoint. - apiEP - select between /completions and /chat/completions endpoint provided by the server/ai-model. + * bTrimGarbage - whether garbage repeatation at the end of the generated ai response, should be trimmed or left as is. If enabled, it will be trimmed so that it wont be sent back as part of subsequent chat history. At the same time the actual trimmed text is shown to the user, once when it was generated, so user can check if any useful info/data was there in the response. - bCompletionFreshChatAlways - whether Completion mode collates complete/sliding-window history when - communicating with the server or only sends the latest user query/message. + One may be able to request the ai-model to continue (wrt the last response) (if chat-history is enabled as part of the chat-history-in-context setting), and chances are the ai-model will continue starting from the trimmed part, thus allows long response to be recovered/continued indirectly, in many cases. - bCompletionInsertStandardRolePrefix - whether Completion mode inserts role related prefix wrt the - messages that get inserted into prompt field wrt /Completion endpoint. + The histogram/freq based trimming logic is currently tuned for english language wrt its is-it-a-alpabetic|numeral-char regex match logic. - bTrimGarbage - whether garbage repeatation at the end of the generated ai response, should be - trimmed or left as is. If enabled, it will be trimmed so that it wont be sent back as part of - subsequent chat history. At the same time the actual trimmed text is shown to the user, once - when it was generated, so user can check if any useful info/data was there in the response. + * tools - contains controls related to tool calling - One may be able to request the ai-model to continue (wrt the last response) (if chat-history - is enabled as part of the chat-history-in-context setting), and chances are the ai-model will - continue starting from the trimmed part, thus allows long response to be recovered/continued - indirectly, in many cases. + * enabled - control whether tool calling is enabled or not - The histogram/freq based trimming logic is currently tuned for english language wrt its - is-it-a-alpabetic|numeral-char regex match logic. + remember to enable this only for GenAi/LLM models which support tool/function calling. - apiRequestOptions - maintains the list of options/fields to send along with api request, - irrespective of whether /chat/completions or /completions endpoint. + * proxyUrl - specify the address for the running instance of bundled local.tools/simpleproxy.py - If you want to add additional options/fields to send to the server/ai-model, and or - modify the existing options value or remove them, for now you can update this global var - using browser's development-tools/console. + * proxyAuthInsecure - shared token between simpleproxy.py server and client ui, for accessing service provided by it. - For string, numeric and boolean fields in apiRequestOptions, including even those added by a - user at runtime by directly modifying gMe.apiRequestOptions, setting ui entries will be auto - created. + Shared token is currently hashed with the current year and inturn handshaked over the network. In future if required one could also include a dynamic token provided by simpleproxy server during /aum handshake and running counter or so into hashed token. ALERT: However do remember that currently the handshake occurs over http and not https, so others can snoop the network and get token. Per client ui running counter and random dynamic token can help mitigate things to some extent, if required in future. - cache_prompt option supported by example/server is allowed to be controlled by user, so that - any caching supported wrt system-prompt and chat history, if usable can get used. When chat - history sliding window is enabled, cache_prompt logic may or may not kick in at the backend - wrt same, based on aspects related to model, positional encoding, attention mechanism etal. - However system prompt should ideally get the benefit of caching. + * searchUrl - specify the search engine's search url template along with the tag SEARCHWORDS in place where the search words should be substituted at runtime. - headers - maintains the list of http headers sent when request is made to the server. By default - Content-Type is set to application/json. Additionally Authorization entry is provided, which can - be set if needed using the settings ui. + * toolCallResponseTimeoutMS - specifies the time (in msecs) for which the logic should wait for a tool call to respond + before a default timed out error response is generated and control given back to end user, for them to decide whether + to submit the error response or wait for actual tool call response further. - iRecentUserMsgCnt - a simple minded SlidingWindow to limit context window load at Ai Model end. - This is disabled by default. However if enabled, then in addition to latest system message, only - the last/latest iRecentUserMsgCnt user messages after the latest system prompt and its responses - from the ai model will be sent to the ai-model, when querying for a new response. IE if enabled, - only user messages after the latest system message/prompt will be considered. + * auto - the amount of time in seconds to wait before the tool call request is auto triggered and generated response is auto submitted back. - This specified sliding window user message count also includes the latest user query. - <0 : Send entire chat history to server - 0 : Send only the system message if any to the server - >0 : Send the latest chat history from the latest system prompt, limited to specified cnt. + setting this value to 0 (default), disables auto logic, so that end user can review the tool calls requested by ai and if needed even modify them, before triggering/executing them as well as review and modify results generated by the tool call, before submitting them back to the ai. + the builtin tools' meta data is sent to the ai model in the requests sent to it. -By using gMe's iRecentUserMsgCnt and apiRequestOptions.max_tokens/n_predict one can try to control -the implications of loading of the ai-model's context window by chat history, wrt chat response to -some extent in a simple crude way. You may also want to control the context size enabled when the -server loads ai-model, on the server end. + inturn if the ai model requests a tool call to be made, the same will be done and the response sent back to the ai model, under user control, by default. + + as tool calling will involve a bit of back and forth between ai assistant and end user, it is recommended to set iRecentUserMsgCnt to 10 or more, so that enough context is retained during chatting with ai models with tool support. + + * apiRequestOptions - maintains the list of options/fields to send along with api request, irrespective of whether /chat/completions or /completions endpoint. + + If you want to add additional options/fields to send to the server/ai-model, and or modify the existing options value or remove them, for now you can update this global var using browser's development-tools/console. + + For string, numeric and boolean fields in apiRequestOptions, including even those added by a user at runtime by directly modifying gMe.apiRequestOptions, setting ui entries will be auto created. + + cache_prompt option supported by example/server is allowed to be controlled by user, so that any caching supported wrt system-prompt and chat history, if usable can get used. When chat history sliding window is enabled, cache_prompt logic may or may not kick in at the backend wrt same, based on aspects related to model, positional encoding, attention mechanism etal. However system prompt should ideally get the benefit of caching. + + * headers - maintains the list of http headers sent when request is made to the server. By default + + * Content-Type is set to application/json. + + * Additionally Authorization entry is provided, which can be set if needed using the settings ui. + + +By using gMe's chatProps.iRecentUserMsgCnt and apiRequestOptions.max_tokens/n_predict one can try to +control the implications of loading of the ai-model's context window by chat history, wrt chat response +to some extent in a simple crude way. You may also want to control the context size enabled when the +server loads ai-model, on the server end. One can look at the current context size set on the server +end by looking at the settings/info block shown when ever one switches-to/is-shown a new session. Sometimes the browser may be stuborn with caching of the file, so your updates to html/css/js @@ -224,9 +295,9 @@ matter clearing site data, dont directly override site caching in all cases. Wor have to change port. Or in dev tools of browser, you may be able to disable caching fully. -Currently the server to communicate with is maintained globally and not as part of a specific -chat session. So if one changes the server ip/url in setting, then all chat sessions will auto -switch to this new server, when you try using those sessions. +Currently the settings are maintained globally and not as part of a specific chat session, including +the server to communicate with. So if one changes the server ip/url in setting, then all chat sessions +will auto switch to this new server, when you try using those sessions. By switching between chat.add_system_begin/anytime, one can control whether one can change @@ -238,15 +309,17 @@ the system prompt, anytime during the conversation or only at the beginning. By default things are setup to try and make the user experience a bit better, if possible. However a developer when testing the server of ai-model may want to change these value. -Using iRecentUserMsgCnt reduce chat history context sent to the server/ai-model to be -just the system-prompt, prev-user-request-and-ai-response and cur-user-request, instead of +Using chatProps.iRecentUserMsgCnt reduce chat history context sent to the server/ai-model to be +just the system-prompt, few prev-user-requests-and-ai-responses and cur-user-request, instead of full chat history. This way if there is any response with garbage/repeatation, it doesnt -mess with things beyond the next question/request/query, in some ways. The trim garbage +mess with things beyond the next few question/request/query, in some ways. The trim garbage option also tries to help avoid issues with garbage in the context to an extent. -Set max_tokens to 1024, so that a relatively large previous reponse doesnt eat up the space -available wrt next query-response. However dont forget that the server when started should -also be started with a model context size of 1k or more, to be on safe side. +Set max_tokens to 2048 or as needed, so that a relatively large previous reponse doesnt eat up +the space available wrt next query-response. While parallely allowing a good enough context size +for some amount of the chat history in the current session to influence future answers. However +dont forget that the server when started should also be started with a model context size of +2k or more, as needed. The /completions endpoint of tools/server doesnt take max_tokens, instead it takes the internal n_predict, for now add the same here on the client side, maybe later add max_tokens @@ -257,7 +330,11 @@ wrt the set of fields sent to server along with the user query, to check how the wrt repeatations in general in the generated text response. A end-user can change these behaviour by editing gMe from browser's devel-tool/console or by -using the provided settings ui (for settings exposed through the ui). +using the provided settings ui (for settings exposed through the ui). The logic uses a generic +helper which autocreates property edit ui elements for the specified set of properties. If the +new property is a number or text or boolean or a object with properties within it, autocreate +logic will try handle it automatically. A developer can trap this autocreation flow and change +things if needed. ### OpenAi / Equivalent API WebService @@ -281,6 +358,221 @@ NOTE: Not tested, as there is no free tier api testing available. However logica work. +### Tool Calling + +Given that browsers provide a implicit env for not only showing ui, but also running logic, +simplechat client ui allows use of tool calling support provided by the newer ai models by +end users of llama.cpp's server in a simple way without needing to worry about seperate mcp +host / router, tools etal, for basic useful tools/functions like calculator, code execution +(javascript in this case). + +Additionally if users want to work with web content as part of their ai chat session, Few +functions related to web access which work with a included python based simple proxy server +have been implemented. + +This can allow end users to use some basic yet useful tool calls to enhance their ai chat +sessions to some extent. It also provides for a simple minded exploration of tool calling +support in newer ai models and some fun along the way as well as occasional practical use +like + +* verifying mathematical or logical statements/reasoning made by the ai model during chat +sessions by getting it to also create and execute mathematical expressions or code to verify +such stuff and so. + +* access content from internet and augment the ai model's context with additional data as +needed to help generate better responses. this can also be used for + * generating the latest news summary by fetching from news aggregator sites and collating + organising and summarising the same + * searching for specific topics and summarising the results + * or so + +The tool calling feature has been tested with Gemma3N, Granite4 and GptOss. + +ALERT: The simple minded way in which this is implemented, it provides some minimal safety +mechanism like running ai generated code in web workers and restricting web access to user +specified whitelist and so, but it can still be dangerous in the worst case, So remember +to verify all the tool calls requested and the responses generated manually to ensure +everything is fine, during interaction with ai models with tools support. One could also +always run this from a discardable vm, just in case if one wants to be extra cautious. + +#### Builtin Tools + +The following tools/functions are currently provided by default + +##### directly in browser + +* simple_calculator - which can solve simple arithmatic expressions + +* run_javascript_function_code - which can be used to run some javascript code in the browser + context. + +Currently the ai generated code / expression is run through a simple minded eval inside a web worker +mechanism. Use of WebWorker helps avoid exposing browser global scope to the generated code directly. +However any shared web worker scope isnt isolated. Either way always remember to cross check the tool +requests and generated responses when using tool calling. + +##### using bundled simpleproxy.py (helps bypass browser cors restriction, ...) + +* fetch_web_url_raw - fetch contents of the requested url through a proxy server + +* fetch_web_url_text - fetch text parts of the content from the requested url through a proxy server. + Related logic tries to strip html response of html tags and also head, script, style, header,footer, + nav, ... blocks. + +* search_web_text - search for the specified words using the configured search engine and return the +plain textual content from the search result page. + +the above set of web related tool calls work by handshaking with a bundled simple local web proxy +(/caching in future) server logic, this helps bypass the CORS restrictions applied if trying to +directly fetch from the browser js runtime environment. + +Depending on the path specified wrt the proxy server, it executes the corresponding logic. Like if +urltext path is used (and not urlraw), the logic in addition to fetching content from given url, it +tries to convert html content into equivalent plain text content to some extent in a simple minded +manner by dropping head block as well as all scripts/styles/footers/headers/nav blocks and inturn +dropping the html tags. + +The client ui logic does a simple check to see if the bundled simpleproxy is running at specified +proxyUrl before enabling these web and related tool calls. + +The bundled simple proxy + +* can be found at + * tools/server/public_simplechat/local.tools/simpleproxy.py + +* it provides for a basic white list of allowed domains to access, to be specified by the end user. + This should help limit web access to a safe set of sites determined by the end user. There is also + a provision for shared bearer token to be specified by the end user. + +* it tries to mimic the client/browser making the request to it by propogating header entries like + user-agent, accept and accept-language from the got request to the generated request during proxying + so that websites will hopefully respect the request rather than blindly rejecting it as coming from + a non-browser entity. + +In future it can be further extended to help with other relatively simple yet useful tool calls like +data / documents_store, fetch_rss and so. + + * for now fetch_rss can be indirectly achieved using fetch_web_url_raw. + +#### Extending with new tools + +This client ui implements the json schema based function calling convention supported by gen ai +engines over http. + +Provide a descriptive meta data explaining the tool / function being provided for tool calling, +as well as its arguments. + +Provide a handler which +* implements the specified tool / function call or +* rather in some cases constructs the code to be run to get the tool / function call job done, + and inturn pass the same to the provided web worker to get it executed. Use console.log while + generating any response that should be sent back to the ai model, in your constructed code. +* once the job is done, return the generated result as needed, along with tool call related meta + data like chatSessionId, toolCallId, toolName which was passed along with the tool call. + +Update the tc_switch to include a object entry for the tool, which inturn includes +* the meta data wrt the tool call +* a reference to the handler - handler should take chatSessionId, toolCallId, toolName and toolArgs. + It should pass these along to the tools web worker, if used. +* the result key (was used previously, may use in future, but for now left as is) + +Look into tooljs.mjs for javascript and inturn web worker based tool calls and toolweb.mjs +for the simpleproxy.py based tool calls. + +#### OLD: Mapping tool calls and responses to normal assistant - user chat flow + +Instead of maintaining tool_call request and resultant response in logically seperate parallel +channel used for requesting tool_calls by the assistant and the resulstant tool role response, +the SimpleChatTC pushes it into the normal assistant - user chat flow itself, by including the +tool call and response as a pair of tagged request with details in the assistant block and inturn +tagged response in the subsequent user block. + +This allows GenAi/LLM to be still aware of the tool calls it made as well as the responses it got, +so that it can incorporate the results of the same in the subsequent chat / interactions. + +NOTE: This flow tested to be ok enough with Gemma-3N-E4B-it-Q8_0 LLM ai model for now. Logically +given the way current ai models work, most of them should understand things as needed, but need +to test this with other ai models later. + +TODO:OLD: Need to think later, whether to continue this simple flow, or atleast use tool role wrt +the tool call responses or even go further and have the logically seperate tool_calls request +structures also. + +DONE: rather both tool_calls structure wrt assistant messages and tool role based tool call +result messages are generated as needed now. + +#### Related stuff + +Promise as well as users of promise (for now fetch) have been trapped wrt their then and catch flow, +so that any scheduled asynchronous code or related async error handling using promise mechanism also +gets executed, before tool calling returns and thus data / error generated by those async code also +get incorporated in result sent to ai engine on the server side. + + +### Progress + +#### Done + +Tool Calling support added, along with a bunch of useful tool calls as well as a bundled simple proxy +if one wants to access web as part of tool call usage. + +Reasoning / thinking response from Ai Models is shown to the user, as they are being generated/shared. + +Chat Messages/Session and UI handling have been moved into corresponding Classes to an extent, this +helps ensure that +* switching chat sessions or loading a previous auto saved chat session will restore state including + ui such that end user can continue the chat session from where they left it, even if in the middle + of a tool call handshake. +* new fields added to http handshake in oneshot or streaming mode can be handled in a structured way + to an extent. + +Chat message parts seperated out and tagged to allow theming chat message as needed in future. +The default Chat UI theme/look changed to help differentiate between different messages in chat +history as well as the parts of each message in a slightly better manner. Change the theme slightly +between normal and print views (beyond previous infinite height) for better printed chat history. + +#### ToDo + +Is the tool call promise land trap deep enough, need to think through and explore around this once later. + +Trap error responses. + +Handle multimodal handshaking with ai models. + +Add fetch_rss and documents|data_store tool calling, through the simpleproxy.py if and where needed. + +Save used config entries along with the auto saved chat sessions and inturn give option to reload the +same when saved chat is loaded. + +MAYBE make the settings in general chat session specific, rather than the current global config flow. + + +### Debuging the handshake and beyond + +When working with llama.cpp server based GenAi/LLM running locally, to look at the handshake directly +from the commandline, you could run something like below + +* sudo tcpdump -i lo -s 0 -vvv -A host 127.0.0.1 and port 8080 | tee /tmp/td.log +* or one could also try look at the network tab in the browser developer console + +One could always remove message entries or manipulate chat sessions by accessing document['gMe'] +in devel console of the browser + +* if you want the last tool call response you submitted to be re-available for tool call execution and + resubmitting of response fresh, for any reason, follow below steps + * remove the assistant response from end of chat session, if any, using + * document['gMe'].multiChat.simpleChats['SessionId'].xchat.pop() + * reset role of Tool response chat message to TOOL.TEMP from tool + * toolMessageIndex = document['gMe'].multiChat.simpleChats['SessionId'].xchat.length - 1 + * document['gMe'].multiChat.simpleChats['SessionId'].xchat[toolMessageIndex].role = "TOOL.TEMP" + * clicking on the SessionId at top in UI, should refresh the chat ui and inturn it should now give + the option to control that tool call again + * this can also help in the case where the chat session fails with context window exceeded + * you restart the GenAi/LLM server after increasing the context window as needed + * edit the chat session history as mentioned above, to the extent needed + * resubmit the last needed user/tool response as needed + + ## At the end Also a thank you to all open source and open model developers, who strive for the common good. diff --git a/tools/server/public_simplechat/simplechat.css b/tools/server/public_simplechat/simplechat.css index 13bfb80b48be8..1f913fa272d49 100644 --- a/tools/server/public_simplechat/simplechat.css +++ b/tools/server/public_simplechat/simplechat.css @@ -21,10 +21,58 @@ .role-user { background-color: lightgray; } +.role-tool { + background-color: lightyellow; +} .role-trim { background-color: lightpink; } +.chat-message { + border-style: solid; + border-color: grey; + border-width: thin; + border-radius: 2px; + display: flex; +} +.chat-message-role { + border-style: dotted; + border-color: black; + border-width: thin; + border-radius: 4px; + writing-mode: vertical-lr; + padding-inline: 1vmin; +} +.chat-message-reasoning { + border-block-style: dashed; + overflow-wrap: break-word; + word-break: break-word; + hyphens: auto; +} +.chat-message-toolcall { + border-style: solid; + border-color: grey; + border-width: thin; + border-radius: 2px; +} +.chat-message-toolcall-arg { + border-style: solid; + border-color: grey; + border-width: thin; + border-radius: 2px; +} +.chat-message-content { + overflow-wrap: break-word; + word-break: break-word; + hyphens: auto; +} +.chat-message-content-live { + overflow-wrap: break-word; + word-break: break-word; + hyphens: auto; +} + + .gridx2 { display: grid; grid-template-columns: repeat(2, 1fr); @@ -47,7 +95,7 @@ min-height: 40vh; } button { - min-width: 8vw; + padding-inline: 2vmin; } .sameline { @@ -66,14 +114,42 @@ button { padding-inline-start: 2vw; } + +.DivObjPropsInfoL0 { + margin: 0%; +} +[class^=SectionObjPropsInfoL] { + margin-left: 2vmin; +} + + * { margin: 0.6vmin; } + @media print { + #fullbody { height: auto; } + .chat-message { + border-style: solid; + border-color: grey; + border-width: thin; + border-radius: 2px; + display:inherit; + } + .chat-message-role { + border-style: dotted; + border-color: black; + border-width: thin; + border-radius: 4px; + writing-mode:inherit; + padding-inline: 1vmin; + } + + } diff --git a/tools/server/public_simplechat/simplechat.js b/tools/server/public_simplechat/simplechat.js index 2fcd24a860bd4..044e889d2e8e3 100644 --- a/tools/server/public_simplechat/simplechat.js +++ b/tools/server/public_simplechat/simplechat.js @@ -4,11 +4,15 @@ 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"; + static ToolTemp = "TOOL.TEMP"; } class ApiEP { @@ -16,6 +20,7 @@ class ApiEP { Chat: "chat", Completion: "completion", } + /** @type {Object} */ static UrlSuffix = { 'chat': `/chat/completions`, 'completion': `/completions`, @@ -35,6 +40,242 @@ class ApiEP { } +/** + * @typedef {{id: string, type: string, function: {name: string, arguments: string}}} NSToolCalls + */ + +/** + * @typedef {{role: string, content: string, reasoning_content: string, tool_calls: Array}} NSChatMessage + */ + +class ChatMessageEx { + + /** + * Represent a Message in the Chat + * @param {string} role + * @param {string} content + * @param {string} reasoning_content + * @param {Array} tool_calls + * @param {string} trimmedContent + */ + constructor(role = "", content="", reasoning_content="", tool_calls=[], trimmedContent="") { + /** @type {NSChatMessage} */ + this.ns = { role: role, content: content, tool_calls: tool_calls, reasoning_content: reasoning_content } + 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.reasoning_content, old.ns.tool_calls, old.trimmedContent) + } + + clear() { + this.ns.role = ""; + this.ns.content = ""; + this.ns.reasoning_content = ""; + this.ns.tool_calls = []; + this.trimmedContent = ""; + } + + /** + * Create a all in one tool call result string + * Use browser's dom logic to handle strings in a xml/html safe way by escaping things where needed, + * so that extracting the same later doesnt create any problems. + * @param {string} toolCallId + * @param {string} toolName + * @param {string} toolResult + */ + static createToolCallResultAllInOne(toolCallId, toolName, toolResult) { + let dp = new DOMParser() + let doc = dp.parseFromString("", "text/xml") + for (const k of [["id", toolCallId], ["name", toolName], ["content", toolResult]]) { + let el = doc.createElement(k[0]) + el.appendChild(doc.createTextNode(k[1])) + doc.documentElement.appendChild(el) + } + let xmlStr = new XMLSerializer().serializeToString(doc); + xmlStr = xmlStr.replace(/\/name>\n\s*(.*?)<\/id>\s*(.*?)<\/name>\s*([\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/html content within to an extent. + * + * NOTE: Rather text/html is a more relaxed/tolarent mode for parseFromString than text/xml. + * NOTE: Maybe better to switch to a json string format or use a more intelligent xml encoder + * in createToolCallResultAllInOne so that extractor like this dont have to worry about special + * xml chars like & as is, in the AllInOne content. For now text/html tolarence seems ok enough. + * + * @param {string} allInOne + */ + static extractToolCallResultAllInOne(allInOne) { + const dParser = new DOMParser(); + const got = dParser.parseFromString(allInOne, 'text/html'); + 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"]; + 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 { + if (toolCalls[0]["function"]["arguments"] !== undefined) { + this.ns.tool_calls[0].function.arguments += toolCalls[0]["function"]["arguments"]; + } + } + } + if (reasoningContent !== undefined) { + this.ns.reasoning_content += reasoningContent + } + } + } + } 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 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 = nwo["choices"][0]["text"]; + } catch { + this.ns.content = nwo["content"]; + } + } + } + + has_toolcall() { + if (this.ns.tool_calls.length == 0) { + return false + } + return true + } + + /** + * 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.reasoning_content.trim() !== "") { + reasoning = `!!!Reasoning: ${this.ns.reasoning_content.trim()} !!!\n\n`; + } + if (this.ns.content !== "") { + content = this.ns.content; + } + if (this.has_toolcall()) { + toolcall = `\n\n\n${this.ns.tool_calls[0].function.name}\n${this.ns.tool_calls[0].function.arguments}\n\n`; + } + return `${reasoning} ${content} ${toolcall}`; + } + +} + let gUsageMsg = `

Usage

@@ -44,8 +285,12 @@ let gUsageMsg = `
  • Completion mode - no system prompt normally.
  • Use shift+enter for inserting enter/newline.
  • -
  • Enter your query to ai assistant below.
  • -
  • Default ContextWindow = [System, Last Query+Resp, Cur Query].
  • +
  • Enter your query to ai assistant in textarea provided below.
  • +
  • If ai assistant requests a tool call, varify same before triggering it.
  • +
      +
    • submit tool response placed into user query textarea
    • +
    +
  • Default ContextWindow = [System, Last9 Query+Resp, Cur Query].
    • ChatHistInCtxt, MaxTokens, ModelCtxt window to expand
    @@ -53,7 +298,7 @@ let gUsageMsg = ` `; -/** @typedef {{role: string, content: string}[]} ChatMessages */ +/** @typedef {ChatMessageEx[]} ChatMessages */ /** @typedef {{iLastSys: number, xchat: ChatMessages}} SimpleChatODS */ @@ -70,7 +315,7 @@ class SimpleChat { */ this.xchat = []; this.iLastSys = -1; - this.latestResponse = ""; + this.latestResponse = new ChatMessageEx(); } clear() { @@ -96,16 +341,25 @@ class SimpleChat { /** @type {SimpleChatODS} */ let ods = JSON.parse(sods); this.iLastSys = ods.iLastSys; - this.xchat = ods.xchat; + this.xchat = []; + for (const cur of ods.xchat) { + if (cur.ns == undefined) { // this relates to the old on-disk-structure/format, needs to be removed later + /** @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.reasoning_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. + * + * 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) { @@ -115,11 +369,11 @@ class SimpleChat { if (iRecentUserMsgCnt == 0) { console.warn("WARN:SimpleChat:SC:RecentChat:iRecentUsermsgCnt of 0 means no user message/query sent"); } - /** @type{ChatMessages} */ + /** @type {ChatMessages} */ let rchat = []; let sysMsg = this.get_system_latest(); - if (sysMsg.length != 0) { - rchat.push({role: Roles.System, content: sysMsg}); + if (sysMsg.ns.content.length != 0) { + rchat.push(sysMsg) } let iUserCnt = 0; let iStart = this.xchat.length; @@ -128,41 +382,74 @@ class SimpleChat { break; } let msg = this.xchat[i]; - if (msg.role == Roles.User) { + 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.role == Roles.System) { + if (msg.ns.role == Roles.System) { continue; } - rchat.push({role: msg.role, content: msg.content}); + rchat.push(msg) } return rchat; } + /** - * Collate the latest response from the server/ai-model, as it is becoming available. - * This is mainly useful for the stream mode. - * @param {string} content + * 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 */ - append_response(content) { - this.latestResponse += content; + recent_chat_ns(iRecentUserMsgCnt) { + let xchat = this.recent_chat(iRecentUserMsgCnt); + let chat = []; + for (const msg of xchat) { + if (msg.ns.role == Roles.ToolTemp) { + // Skip (temp) tool response which has not yet been accepted by user + // In future need to check that it is the last message + // and not something in between, which shouldnt occur normally. + continue + } + let tmsg = ChatMessageEx.newFrom(msg); + if (!tmsg.has_toolcall()) { + tmsg.ns_delete("tool_calls") + } + if (tmsg.ns.reasoning_content.trim() === "") { + tmsg.ns_delete("reasoning_content") + } + 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 - * @param {string} role - * @param {string|undefined|null} content + * Add an entry into xchat. + * If the last message in chat history is a ToolTemp message, discard it + * as the runtime logic is asking for adding new message instead of promoting the tooltemp message. + * + * NOTE: A new copy is created and added into xchat. + * Also update iLastSys system prompt index tracker + * @param {ChatMessageEx} chatMsg */ - add(role, content) { - if ((content == undefined) || (content == null) || (content == "")) { - return false; + add(chatMsg) { + if (this.xchat.length > 0) { + let lastIndex = this.xchat.length - 1; + if (this.xchat[lastIndex].ns.role == Roles.ToolTemp) { + console.debug("DBUG:SimpleChat:Add:Discarding prev ToolTemp message...") + this.xchat.pop() + } } - this.xchat.push( {role: role, content: content} ); - if (role == Roles.System) { + this.xchat.push(ChatMessageEx.newFrom(chatMsg)); + if (chatMsg.ns.role == Roles.System) { this.iLastSys = this.xchat.length - 1; } this.save(); @@ -170,18 +457,53 @@ class SimpleChat { } /** - * Show the contents in the specified div + * 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 + } + if (this.xchat[lastIndex].ns.role != Roles.ToolTemp) { + console.error("DBUG:SimpleChat:PromoteToolTemp:LastChatMsg not ToolTemp") + return + } + this.xchat[lastIndex].ns.role = Roles.Tool; + this.xchat[lastIndex].ns.content = content; + } + + /** + * Show the chat contents in the specified div. + * Also update the user query input box, with ToolTemp role message, if any. + * + * 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 {HTMLInputElement} elInUser * @param {boolean} bClear + * @param {boolean} bShowInfoAll */ - show(div, bClear=true) { + showTOREMOVE(div, elInUser, 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.role}: ${x.content}`, div); - entry.className = `role-${x.role}`; + for(const [i, x] of this.recent_chat(gMe.chatProps.iRecentUserMsgCnt).entries()) { + if (x.ns.role === Roles.ToolTemp) { + if (i == (this.xchat.length - 1)) { + elInUser.value = x.ns.content; + } + continue + } + 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) { @@ -190,7 +512,7 @@ class SimpleChat { if (bClear) { div.innerHTML = gUsageMsg; gMe.setup_load(div, this); - gMe.show_info(div); + gMe.show_info(div, bShowInfoAll); } } return last; @@ -219,15 +541,18 @@ class SimpleChat { * The needed fields/options are picked from a global object. * Add optional stream flag, if required. * Convert the json into string. - * @param {Object} obj + * @param {Object} obj */ request_jsonstr_extend(obj) { for(let k in gMe.apiRequestOptions) { obj[k] = gMe.apiRequestOptions[k]; } - if (gMe.bStream) { + if (gMe.chatProps.stream) { obj["stream"] = true; } + if (gMe.tools.enabled) { + obj["tools"] = tools.meta(); + } return JSON.stringify(obj); } @@ -236,7 +561,7 @@ class SimpleChat { */ request_messages_jsonstr() { let req = { - messages: this.recent_chat(gMe.iRecentUserMsgCnt), + messages: this.recent_chat_ns(gMe.chatProps.iRecentUserMsgCnt), } return this.request_jsonstr_extend(req); } @@ -248,15 +573,15 @@ class SimpleChat { request_prompt_jsonstr(bInsertStandardRolePrefix) { let prompt = ""; let iCnt = 0; - for(const chat of this.recent_chat(gMe.iRecentUserMsgCnt)) { + for(const msg of this.recent_chat(gMe.chatProps.iRecentUserMsgCnt)) { iCnt += 1; if (iCnt > 1) { prompt += "\n"; } if (bInsertStandardRolePrefix) { - prompt += `${chat.role}: `; + prompt += `${msg.ns.role}: `; } - prompt += `${chat.content}`; + prompt += `${msg.ns.content}`; } let req = { prompt: prompt, @@ -272,77 +597,14 @@ class SimpleChat { if (apiEP == ApiEP.Type.Chat) { return this.request_messages_jsonstr(); } else { - return this.request_prompt_jsonstr(gMe.bCompletionInsertStandardRolePrefix); + return this.request_prompt_jsonstr(gMe.chatProps.bCompletionInsertStandardRolePrefix); } } - /** - * Extract the ai-model/assistant's response from the http response got. - * Optionally trim the message wrt any garbage at the end. - * @param {any} respBody - * @param {string} apiEP - */ - response_extract(respBody, apiEP) { - let assistant = ""; - if (apiEP == ApiEP.Type.Chat) { - assistant = respBody["choices"][0]["message"]["content"]; - } else { - try { - assistant = respBody["choices"][0]["text"]; - } catch { - assistant = respBody["content"]; - } - } - return assistant; - } - - /** - * Extract the ai-model/assistant's response from the http response got in streaming mode. - * @param {any} respBody - * @param {string} apiEP - */ - response_extract_stream(respBody, apiEP) { - let assistant = ""; - if (apiEP == ApiEP.Type.Chat) { - if (respBody["choices"][0]["finish_reason"] !== "stop") { - assistant = respBody["choices"][0]["delta"]["content"]; - } - } else { - try { - assistant = respBody["choices"][0]["text"]; - } catch { - assistant = respBody["content"]; - } - } - return assistant; - } - - /** - * Allow setting of system prompt, but only at begining. - * @param {string} sysPrompt - * @param {string} msgTag - */ - add_system_begin(sysPrompt, msgTag) { - if (this.xchat.length == 0) { - if (sysPrompt.length > 0) { - return this.add(Roles.System, sysPrompt); - } - } else { - if (sysPrompt.length > 0) { - if (this.xchat[0].role !== Roles.System) { - console.error(`ERRR:SimpleChat:SC:${msgTag}:You need to specify system prompt before any user query, ignoring...`); - } else { - if (this.xchat[0].content !== sysPrompt) { - console.error(`ERRR:SimpleChat:SC:${msgTag}:You cant change system prompt, mid way through, ignoring...`); - } - } - } - } - return false; - } /** * 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 */ @@ -352,25 +614,24 @@ class SimpleChat { } if (this.iLastSys < 0) { - return this.add(Roles.System, sysPrompt); + return this.add(new ChatMessageEx(Roles.System, sysPrompt)); } - let lastSys = this.xchat[this.iLastSys].content; + let lastSys = this.xchat[this.iLastSys].ns.content; if (lastSys !== sysPrompt) { - return this.add(Roles.System, sysPrompt); + return this.add(new ChatMessageEx(Roles.System, sysPrompt)); } return false; } /** - * Retrieve the latest system prompt. + * Retrieve the latest system prompt related chat message entry. */ get_system_latest() { if (this.iLastSys == -1) { - return ""; + return new ChatMessageEx(Roles.System); } - let sysPrompt = this.xchat[this.iLastSys].content; - return sysPrompt; + return this.xchat[this.iLastSys]; } @@ -382,12 +643,14 @@ class SimpleChat { */ async handle_response_multipart(resp, apiEP, elDiv) { let elP = ui.el_create_append_p("", elDiv); + elP.classList.add("chat-message-content-live") if (!resp.body) { throw Error("ERRR:SimpleChat:SC:HandleResponseMultiPart:No body..."); } let tdUtf8 = new TextDecoder("utf-8"); let rr = resp.body.getReader(); - this.latestResponse = ""; + this.latestResponse.clear() + this.latestResponse.ns.role = Roles.Assistant let xLines = new du.NewLines(); while(true) { let { value: cur, done: done } = await rr.read(); @@ -412,16 +675,16 @@ class SimpleChat { } let curJson = JSON.parse(curLine); console.debug("DBUG:SC:PART:Json:", curJson); - this.append_response(this.response_extract_stream(curJson, apiEP)); + this.latestResponse.update_stream(curJson, apiEP); } - elP.innerText = this.latestResponse; + elP.innerText = this.latestResponse.content_equiv() elP.scrollIntoView(false); if (done) { break; } } - console.debug("DBUG:SC:PART:Full:", this.latestResponse); - return this.latestResponse; + console.debug("DBUG:SC:PART:Full:", this.latestResponse.content_equiv()); + return ChatMessageEx.newFrom(this.latestResponse); } /** @@ -432,43 +695,65 @@ class SimpleChat { async handle_response_oneshot(resp, apiEP) { let respBody = await resp.json(); console.debug(`DBUG:SimpleChat:SC:${this.chatId}:HandleUserSubmit:RespBody:${JSON.stringify(respBody)}`); - return this.response_extract(respBody, apiEP); + 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 = { - assistant: "", - trimmed: "", - } - if (gMe.bStream) { + let theResp = null; + if (gMe.chatProps.stream) { try { - theResp.assistant = await this.handle_response_multipart(resp, apiEP, elDiv); - this.latestResponse = ""; + theResp = await this.handle_response_multipart(resp, apiEP, elDiv); + this.latestResponse.clear(); } catch (error) { - theResp.assistant = this.latestResponse; - this.add(Roles.Assistant, theResp.assistant); - this.latestResponse = ""; + theResp = this.latestResponse; + theResp.ns.role = Roles.Assistant; + this.add(theResp); + this.latestResponse.clear(); throw error; } } else { - theResp.assistant = await this.handle_response_oneshot(resp, apiEP); + theResp = await this.handle_response_oneshot(resp, apiEP); } - if (gMe.bTrimGarbage) { - let origMsg = theResp.assistant; - theResp.assistant = du.trim_garbage_at_end(origMsg); - theResp.trimmed = origMsg.substring(theResp.assistant.length); + if (gMe.chatProps.bTrimGarbage) { + let origMsg = theResp.ns.content; + theResp.ns.content = du.trim_garbage_at_end(origMsg); + theResp.trimmedContent = origMsg.substring(theResp.ns.content.length); } - this.add(Roles.Assistant, theResp.assistant); + 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(this.chatId, toolcallid, toolname, toolargs) + } catch (/** @type {any} */error) { + return `Tool/Function call raised an exception:${error.name}:${error.message}` + } + } + } @@ -480,6 +765,34 @@ class MultiChatUI { /** @type {string} */ this.curChatId = ""; + this.TimePeriods = { + ToolCallAutoTimeUnit: 1000 + } + + 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 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")); @@ -488,6 +801,10 @@ class MultiChatUI { 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"); @@ -495,6 +812,10 @@ class MultiChatUI { 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"); } /** @@ -506,22 +827,191 @@ class MultiChatUI { 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 + if (gMe.tools.auto > 0) { + this.timers.toolcallTriggerClick = setTimeout(()=>{ + this.elBtnTool.click() + }, gMe.tools.auto*this.TimePeriods.ToolCallAutoTimeUnit) + } + } 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 + * * clear user input (if requested, default true) * * enable user input * * set focus to user input + * @param {boolean} [bClearElInUser=true] */ - ui_reset_userinput() { - this.elInUser.value = ""; + ui_reset_userinput(bClearElInUser=true) { + if (bClearElInUser) { + this.elInUser.value = ""; + } this.elInUser.disabled = false; this.elInUser.focus(); } + /** + * Show the passed function / tool call details in specified parent element. + * @param {HTMLElement} elParent + * @param {NSToolCalls} tc + */ + show_message_toolcall(elParent, tc) { + let secTC = document.createElement('section') + secTC.classList.add('chat-message-toolcall') + elParent.append(secTC) + let entry = ui.el_create_append_p(`name: ${tc.function.name}`, secTC); + entry = ui.el_create_append_p(`id: ${tc.id}`, secTC); + 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] + } + } + + /** + * 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 {ChatMessageEx} msg + * @param {number} iFromLast + * @param {ChatMessageEx | undefined} nextMsg + */ + show_message(elParent, msg, iFromLast, nextMsg) { + // Handle ToolTemp + if (msg.ns.role === Roles.ToolTemp) { + if (iFromLast == 0) { + this.elInUser.value = msg.ns.content; + } + return + } + // Create main section + let secMain = document.createElement('section') + secMain.classList.add(`role-${msg.ns.role}`) + secMain.classList.add('chat-message') + elParent?.append(secMain) + 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 + //entry = ui.el_create_append_p(`${msg.content_equiv()}`, secContents); + let showList = [] + if (msg.ns.reasoning_content.trim().length > 0) { + showList.push(['reasoning', `!!!Reasoning: ${msg.ns.reasoning_content.trim()} !!!\n\n`]) + } + if (msg.ns.content.trim().length > 0) { + showList.push(['content', msg.ns.content.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 tool call ui, if reqd + let bTC = false + if (msg.ns.role === Roles.Assistant) { + if (iFromLast == 0) { + bTC = true + } else if ((iFromLast == 1) && (nextMsg != undefined)) { + if (nextMsg.ns.role == Roles.ToolTemp) { + bTC = true + } + } + if (bTC) { + this.ui_reset_toolcall_as_needed(msg); + } + } + // Handle tool call non ui + if (msg.has_toolcall() && !bTC) { + for (const i in msg.ns.tool_calls) { + this.show_message_toolcall(secContents, msg.ns.tool_calls[i]) + } + } + } + + /** + * 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_reset_toolcall_as_needed(new ChatMessageEx()); + } + this.elLastChatMessage = null + let chatToShow = chat.recent_chat(gMe.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, x, iFromLast, nextMsg) + } + if (this.elLastChatMessage != null) { + /** @type{HTMLElement} */(this.elLastChatMessage).scrollIntoView(false); // Stupid ts-check js-doc intersection ??? + } else { + if (bClear) { + this.elDivChat.innerHTML = gUsageMsg; + gMe.setup_load(this.elDivChat, chat); + gMe.show_info(this.elDivChat, bShowInfoAll); + } + } + return true + } + /** * Setup the needed callbacks wrt UI, curChatId to defaultChatId and * optionally switch to specified defaultChatId. @@ -535,16 +1025,20 @@ class MultiChatUI { 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)=>{ + clearTimeout(this.timers.toolcallResponseSubmitClick) + this.timers.toolcallResponseSubmitClick = undefined if (this.elInUser.disabled) { return; } - this.handle_user_submit(this.curChatId, gMe.apiEP).catch((/** @type{Error} */reason)=>{ + this.handle_user_submit(this.curChatId, gMe.chatProps.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); @@ -552,6 +1046,31 @@ class MultiChatUI { }); }); + 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 worker + tools.setup((cid, tcid, name, data)=>{ + clearTimeout(this.timers.toolcallResponseTimeout) + this.timers.toolcallResponseTimeout = undefined + let chat = this.simpleChats[cid]; + chat.add(new ChatMessageEx(Roles.ToolTemp, ChatMessageEx.createToolCallResultAllInOne(tcid, name, data))) + if (this.chat_show(cid)) { + if (gMe.tools.auto > 0) { + this.timers.toolcallResponseSubmitClick = setTimeout(()=>{ + this.elBtnUser.click() + }, gMe.tools.auto*this.TimePeriods.ToolCallAutoTimeUnit) + } + } + 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. @@ -571,7 +1090,7 @@ class MultiChatUI { 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); + this.chat_show(chat.chatId) ev.preventDefault(); } }); @@ -593,6 +1112,14 @@ class MultiChatUI { /** * 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 */ @@ -604,18 +1131,25 @@ class MultiChatUI { // 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)) { + if ((apiEP == ApiEP.Type.Completion) && (gMe.chatProps.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 (!chat.add(Roles.User, content)) { + if (content.trim() == "") { console.debug(`WARN:SimpleChat:MCUI:${chatId}:HandleUserSubmit:Ignoring empty user input...`); return; } - chat.show(this.elDivChat); + if (content.startsWith("")) { + chat.promote_tooltemp(content) + } else { + chat.add(new ChatMessageEx(Roles.User, content)) + } + this.chat_show(chat.chatId); let theUrl = ApiEP.Url(gMe.baseURL, apiEP); let theBody = chat.request_jsonstr(apiEP); @@ -632,9 +1166,9 @@ class MultiChatUI { let theResp = await chat.handle_response(resp, apiEP, this.elDivChat); if (chatId == this.curChatId) { - chat.show(this.elDivChat); - if (theResp.trimmed.length > 0) { - let p = ui.el_create_append_p(`TRIMMED:${theResp.trimmed}`, this.elDivChat); + this.chat_show(chatId); + if (theResp.trimmedContent.length > 0) { + let p = ui.el_create_append_p(`TRIMMED:${theResp.trimmedContent}`, this.elDivChat); p.className="role-trim"; } } else { @@ -643,6 +1177,34 @@ class MultiChatUI { 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) { + chat.add(new ChatMessageEx(Roles.ToolTemp, ChatMessageEx.createToolCallResultAllInOne(toolCallId, toolname, toolResult))) + this.chat_show(chat.chatId) + this.ui_reset_userinput(false) + } else { + this.timers.toolcallResponseTimeout = setTimeout(() => { + chat.add(new ChatMessageEx(Roles.ToolTemp, ChatMessageEx.createToolCallResultAllInOne(toolCallId, toolname, `Tool/Function call ${toolname} taking too much time, aborting...`))) + this.chat_show(chat.chatId) + this.ui_reset_userinput(false) + }, gMe.tools.toolCallResponseTimeoutMS) + } + } + /** * Show buttons for NewChat and available chat sessions, in the passed elDiv. * If elDiv is undefined/null, then use this.elDivSessions. @@ -682,6 +1244,11 @@ class MultiChatUI { } } + /** + * 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); @@ -708,46 +1275,83 @@ class MultiChatUI { console.error(`ERRR:SimpleChat:MCUI:HandleSessionSwitch:${chatId} missing...`); return; } - this.elInSystem.value = chat.get_system_latest(); + this.elInSystem.value = chat.get_system_latest().ns.content; this.elInUser.value = ""; - chat.show(this.elDivChat); - this.elInUser.focus(); this.curChatId = chatId; + this.chat_show(chatId, true, true); + this.elInUser.focus(); 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: "https://duckduckgo.com/html/?q=SEARCHWORDS", + bing: "https://www.bing.com/search?q=SEARCHWORDS", // doesnt seem to like google chrome clients in particular + brave: "https://search.brave.com/search?q=SEARCHWORDS", + google: "https://www.google.com/search?q=SEARCHWORDS", // doesnt seem to like any client in general +} + + class Me { constructor() { this.baseURL = "http://127.0.0.1:8080"; this.defaultChatIds = [ "Default", "Other" ]; this.multiChat = new MultiChatUI(); - this.bStream = true; - this.bCompletionFreshChatAlways = true; - this.bCompletionInsertStandardRolePrefix = false; - this.bTrimGarbage = true; - this.iRecentUserMsgCnt = 2; + this.tools = { + enabled: false, + proxyUrl: "http://127.0.0.1:3128", + proxyAuthInsecure: "NeverSecure", + searchUrl: SearchURLS.duckduckgo, + toolNames: /** @type {Array} */([]), + /** + * 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: 20000, + /** + * 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. + */ + auto: 0 + }; + this.chatProps = { + apiEP: ApiEP.Type.Chat, + stream: true, + iRecentUserMsgCnt: 10, + bCompletionFreshChatAlways: true, + bCompletionInsertStandardRolePrefix: false, + bTrimGarbage: true, + }; + /** @type {Object} */ this.sRecentUserMsgCnt = { "Full": -1, "Last0": 1, "Last1": 2, "Last2": 3, "Last4": 5, + "Last9": 10, }; - this.apiEP = ApiEP.Type.Chat; + /** @type {Object} */ 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. + /** + * Add needed fields wrt json object to be sent wrt LLM web services completions endpoint. + * @type {Object} + */ this.apiRequestOptions = { "model": "gpt-3.5-turbo", "temperature": 0.7, - "max_tokens": 1024, - "n_predict": 1024, + "max_tokens": 2048, + "n_predict": 2048, "cache_prompt": false, //"frequency_penalty": 1.2, //"presence_penalty": 1.2, @@ -779,8 +1383,8 @@ class Me { console.log("DBUG:SimpleChat:SC:Load", chat); chat.load(); queueMicrotask(()=>{ - chat.show(div); - this.multiChat.elInSystem.value = chat.get_system_latest(); + this.multiChat.chat_show(chat.chatId, true, true); + this.multiChat.elInSystem.value = chat.get_system_latest().ns.content; }); }); div.appendChild(btn); @@ -792,68 +1396,17 @@ class Me { * @param {boolean} bAll */ show_info(elDiv, bAll=false) { - - let p = ui.el_create_append_p("Settings (devel-tools-console document[gMe])", elDiv); - p.className = "role-system"; - - if (bAll) { - - ui.el_create_append_p(`baseURL:${this.baseURL}`, elDiv); - - ui.el_create_append_p(`Authorization:${this.headers["Authorization"]}`, elDiv); - - ui.el_create_append_p(`bStream:${this.bStream}`, elDiv); - - ui.el_create_append_p(`bTrimGarbage:${this.bTrimGarbage}`, elDiv); - - ui.el_create_append_p(`ApiEndPoint:${this.apiEP}`, elDiv); - - ui.el_create_append_p(`iRecentUserMsgCnt:${this.iRecentUserMsgCnt}`, elDiv); - - ui.el_create_append_p(`bCompletionFreshChatAlways:${this.bCompletionFreshChatAlways}`, elDiv); - - ui.el_create_append_p(`bCompletionInsertStandardRolePrefix:${this.bCompletionInsertStandardRolePrefix}`, elDiv); - + let props = ["baseURL", "modelInfo","headers", "tools", "apiRequestOptions", "chatProps"]; + if (!bAll) { + props = [ "baseURL", "modelInfo", "tools", "chatProps" ]; } - - ui.el_create_append_p(`apiRequestOptions:${JSON.stringify(this.apiRequestOptions, null, " - ")}`, elDiv); - ui.el_create_append_p(`headers:${JSON.stringify(this.headers, null, " - ")}`, elDiv); - - } - - /** - * Auto create ui input elements for fields in apiRequestOptions - * Currently supports text and number field types. - * @param {HTMLDivElement} elDiv - */ - show_settings_apirequestoptions(elDiv) { - let typeDict = { - "string": "text", - "number": "number", - }; - let fs = document.createElement("fieldset"); - let legend = document.createElement("legend"); - legend.innerText = "ApiRequestOptions"; - fs.appendChild(legend); - elDiv.appendChild(fs); - for(const k in this.apiRequestOptions) { - let val = this.apiRequestOptions[k]; - let type = typeof(val); - if (((type == "string") || (type == "number"))) { - let inp = ui.el_creatediv_input(`Set${k}`, k, typeDict[type], this.apiRequestOptions[k], (val)=>{ - if (type == "number") { - val = Number(val); - } - this.apiRequestOptions[k] = val; - }); - fs.appendChild(inp.div); - } else if (type == "boolean") { - let bbtn = ui.el_creatediv_boolbutton(`Set{k}`, k, {true: "true", false: "false"}, val, (userVal)=>{ - this.apiRequestOptions[k] = userVal; - }); - fs.appendChild(bbtn.div); + 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}`)) } /** @@ -861,50 +1414,29 @@ class Me { * @param {HTMLDivElement} elDiv */ show_settings(elDiv) { - - let inp = ui.el_creatediv_input("SetBaseURL", "BaseURL", "text", this.baseURL, (val)=>{ - this.baseURL = val; - }); - elDiv.appendChild(inp.div); - - inp = ui.el_creatediv_input("SetAuthorization", "Authorization", "text", this.headers["Authorization"], (val)=>{ - this.headers["Authorization"] = val; - }); - inp.el.placeholder = "Bearer OPENAI_API_KEY"; - elDiv.appendChild(inp.div); - - let bb = ui.el_creatediv_boolbutton("SetStream", "Stream", {true: "[+] yes stream", false: "[-] do oneshot"}, this.bStream, (val)=>{ - this.bStream = val; - }); - elDiv.appendChild(bb.div); - - bb = ui.el_creatediv_boolbutton("SetTrimGarbage", "TrimGarbage", {true: "[+] yes trim", false: "[-] dont trim"}, this.bTrimGarbage, (val)=>{ - this.bTrimGarbage = val; - }); - elDiv.appendChild(bb.div); - - this.show_settings_apirequestoptions(elDiv); - - let sel = ui.el_creatediv_select("SetApiEP", "ApiEndPoint", ApiEP.Type, this.apiEP, (val)=>{ - this.apiEP = ApiEP.Type[val]; - }); - elDiv.appendChild(sel.div); - - sel = ui.el_creatediv_select("SetChatHistoryInCtxt", "ChatHistoryInCtxt", this.sRecentUserMsgCnt, this.iRecentUserMsgCnt, (val)=>{ - this.iRecentUserMsgCnt = this.sRecentUserMsgCnt[val]; - }); - elDiv.appendChild(sel.div); - - bb = ui.el_creatediv_boolbutton("SetCompletionFreshChatAlways", "CompletionFreshChatAlways", {true: "[+] yes fresh", false: "[-] no, with history"}, this.bCompletionFreshChatAlways, (val)=>{ - this.bCompletionFreshChatAlways = val; - }); - elDiv.appendChild(bb.div); - - bb = ui.el_creatediv_boolbutton("SetCompletionInsertStandardRolePrefix", "CompletionInsertStandardRolePrefix", {true: "[+] yes insert", false: "[-] dont insert"}, this.bCompletionInsertStandardRolePrefix, (val)=>{ - this.bCompletionInsertStandardRolePrefix = val; - }); - elDiv.appendChild(bb.div); - + ui.ui_show_obj_props_edit(elDiv, "", this, ["baseURL", "headers", "tools", "apiRequestOptions", "chatProps"], "Settings", (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.sRecentUserMsgCnt, this.chatProps.iRecentUserMsgCnt, (val)=>{ + this.chatProps.iRecentUserMsgCnt = this.sRecentUserMsgCnt[val]; + }); + elParent.appendChild(sel.div); + } + }) } } @@ -917,8 +1449,13 @@ 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().then((toolNames)=>gMe.tools.toolNames=toolNames) for (let cid of gMe.defaultChatIds) { gMe.multiChat.new_chat_session(cid); } diff --git a/tools/server/public_simplechat/test-tools-cmdline.sh b/tools/server/public_simplechat/test-tools-cmdline.sh new file mode 100644 index 0000000000000..8fc62d2af9a48 --- /dev/null +++ b/tools/server/public_simplechat/test-tools-cmdline.sh @@ -0,0 +1,92 @@ +echo "DONT FORGET TO RUN llama-server" +echo "build/bin/llama-server -m ~/Downloads/GenAi.Text/gemma-3n-E4B-it-Q8_0.gguf --path tools/server/public_simplechat --jinja" +echo "Note: Remove stream: true line below, if you want one shot instead of streaming response from ai server" +echo "Note: Using different locations below, as the mechanism / url used to fetch will / may need to change" +echo "Note: sudo tcpdump -i lo -s 0 -vvv -A host 127.0.0.1 and port 8080 | tee /tmp/td.log can be used to capture the hs" +curl http://localhost:8080/v1/chat/completions -d '{ + "model": "gpt-3.5-turbo", + "stream": true, + "tools": [ + { + "type":"function", + "function":{ + "name":"javascript", + "description":"Runs code in an javascript interpreter and returns the result of the execution after 60 seconds.", + "parameters":{ + "type":"object", + "properties":{ + "code":{ + "type":"string", + "description":"The code to run in the javascript interpreter." + } + }, + "required":["code"] + } + } + }, + { + "type":"function", + "function":{ + "name":"web_fetch", + "description":"Connects to the internet and fetches the specified url, may take few seconds", + "parameters":{ + "type":"object", + "properties":{ + "url":{ + "type":"string", + "description":"The url to fetch from internet." + } + }, + "required":["url"] + } + } + }, + { + "type":"function", + "function":{ + "name":"simple_calc", + "description":"Calculates the provided arithmatic expression using javascript interpreter and returns the result of the execution after few seconds.", + "parameters":{ + "type":"object", + "properties":{ + "arithexp":{ + "type":"string", + "description":"The arithmatic expression that will be calculated using javascript interpreter." + } + }, + "required":["arithexp"] + } + } + } + ], + "messages": [ + { + "role": "user", + "content": "What and all tools you have access to" + } + ] +}' + + +exit + + + "content": "what is your name." + "content": "What and all tools you have access to" + "content": "do you have access to any tools" + "content": "Print a hello world message with python." + "content": "Print a hello world message with javascript." + "content": "Calculate the sum of 5 and 27." + "content": "Can you get me todays date." + "content": "Can you get me a summary of latest news from bbc world" + "content": "Can you get todays date. And inturn add 10 to todays date" + "content": "Who is known as father of the nation in India, also is there a similar figure for USA as well as UK" + "content": "Who is known as father of the nation in India, Add 10 to double his year of birth and show me the results." + "content": "How is the weather today in london." + "content": "How is the weather today in london. Add 324 to todays temperature in celcius in london" + "content": "How is the weather today in bengaluru. Add 324 to todays temperature in celcius in kochi" + "content": "Add 324 to todays temperature in celcius in london" + "content": "Add 324 to todays temperature in celcius in delhi" + "content": "Add 324 to todays temperature in celcius in delhi. Dont forget to get todays weather info about delhi so that the temperature is valid" + "content": "Add 324 to todays temperature in celcius in bengaluru. Dont forget to get todays weather info about bengaluru so that the temperature is valid. Use a free weather info site which doesnt require any api keys to get the info" + "content": "Can you get the cutoff rank for all the deemed medical universities in India for UGNeet 25" diff --git a/tools/server/public_simplechat/tooljs.mjs b/tools/server/public_simplechat/tooljs.mjs new file mode 100644 index 0000000000000..a30330ab8244c --- /dev/null +++ b/tools/server/public_simplechat/tooljs.mjs @@ -0,0 +1,101 @@ +//@ts-check +// ALERT - Simple Stupid flow - Using from a discardable VM is better +// Helpers to handle tools/functions calling wrt +// * javascript interpreter +// * simple arithmatic calculator +// by Humans for All +// + + +let gToolsWorker = /** @type{Worker} */(/** @type {unknown} */(null)); + + +let js_meta = { + "type": "function", + "function": { + "name": "run_javascript_function_code", + "description": "Runs given code using eval within a web worker context in a browser's javascript environment and returns the console.log outputs of the execution after few seconds", + "parameters": { + "type": "object", + "properties": { + "code": { + "type": "string", + "description": "The code that will be run using eval within a web worker in the browser's javascript interpreter environment." + } + }, + "required": ["code"] + } + } + } + + +/** + * Implementation of the javascript interpretor logic. Minimal skeleton for now. + * ALERT: Has access to the javascript web worker environment and can mess with it and beyond + * @param {string} chatid + * @param {string} toolcallid + * @param {string} toolname + * @param {any} obj + */ +function js_run(chatid, toolcallid, toolname, obj) { + gToolsWorker.postMessage({ cid: chatid, tcid: toolcallid, name: toolname, code: obj["code"]}) +} + + +let calc_meta = { + "type": "function", + "function": { + "name": "simple_calculator", + "description": "Calculates the provided arithmatic expression using console.log within a web worker of a browser's javascript interpreter environment and returns the output of the execution once it is done in few seconds", + "parameters": { + "type": "object", + "properties": { + "arithexpr":{ + "type":"string", + "description":"The arithmatic expression that will be calculated by passing it to console.log of a browser's javascript interpreter." + } + }, + "required": ["arithexpr"] + } + } + } + + +/** + * Implementation of the simple calculator logic. Minimal skeleton for now. + * ALERT: Has access to the javascript web worker environment and can mess with it and beyond + * @param {string} chatid + * @param {string} toolcallid + * @param {string} toolname + * @param {any} obj + */ +function calc_run(chatid, toolcallid, toolname, obj) { + gToolsWorker.postMessage({ cid: chatid, tcid: toolcallid, name: toolname, code: `console.log(${obj["arithexpr"]})`}) +} + + +/** + * @type {Object>} + */ +export let tc_switch = { + "run_javascript_function_code": { + "handler": js_run, + "meta": js_meta, + "result": "" + }, + "simple_calculator": { + "handler": calc_run, + "meta": calc_meta, + "result": "" + }, +} + + +/** + * Used to get hold of the web worker to use for running tool/function call related code + * Also to setup tool calls, which need to cross check things at runtime + * @param {Worker} toolsWorker + */ +export async function init(toolsWorker) { + gToolsWorker = toolsWorker +} diff --git a/tools/server/public_simplechat/tools.mjs b/tools/server/public_simplechat/tools.mjs new file mode 100644 index 0000000000000..73a79f460c71c --- /dev/null +++ b/tools/server/public_simplechat/tools.mjs @@ -0,0 +1,82 @@ +//@ts-check +// ALERT - Simple Stupid flow - Using from a discardable VM is better +// Helpers to handle tools/functions calling in a direct and dangerous way +// by Humans for All +// + + +import * as tjs from './tooljs.mjs' +import * as tweb from './toolweb.mjs' + + +let gToolsWorker = new Worker('./toolsworker.mjs', { type: 'module' }); +/** + * Maintain currently available tool/function calls + * @type {Object>} + */ +export let tc_switch = {} + + +export async function init() { + /** + * @type {string[]} + */ + let toolNames = [] + await tjs.init(gToolsWorker).then(()=>{ + for (const key in tjs.tc_switch) { + tc_switch[key] = tjs.tc_switch[key] + toolNames.push(key) + } + }) + let tNs = await tweb.init(gToolsWorker) + for (const key in tNs) { + tc_switch[key] = tNs[key] + toolNames.push(key) + } + return toolNames +} + + +export function meta() { + let tools = [] + for (const key in tc_switch) { + tools.push(tc_switch[key]["meta"]) + } + return tools +} + + +/** + * Setup the callback that will be called when ever message + * is recieved from the Tools Web Worker. + * @param {(chatId: string, toolCallId: string, name: string, data: string) => void} cb + */ +export function setup(cb) { + gToolsWorker.onmessage = function (ev) { + cb(ev.data.cid, ev.data.tcid, ev.data.name, ev.data.data) + } +} + + +/** + * Try call the specified tool/function call. + * Returns undefined, if the call was placed successfully + * Else some appropriate error message will be returned. + * @param {string} chatid + * @param {string} toolcallid + * @param {string} toolname + * @param {string} toolargs + */ +export async function tool_call(chatid, toolcallid, toolname, toolargs) { + for (const fn in tc_switch) { + if (fn == toolname) { + try { + tc_switch[fn]["handler"](chatid, toolcallid, fn, JSON.parse(toolargs)) + return undefined + } catch (/** @type {any} */error) { + return `Tool/Function call raised an exception:${error.name}:${error.message}` + } + } + } + return `Unknown Tool/Function Call:${toolname}` +} diff --git a/tools/server/public_simplechat/toolsconsole.mjs b/tools/server/public_simplechat/toolsconsole.mjs new file mode 100644 index 0000000000000..b372dc74ef329 --- /dev/null +++ b/tools/server/public_simplechat/toolsconsole.mjs @@ -0,0 +1,57 @@ +//@ts-check +// Helpers to handle tools/functions calling wrt console +// by Humans for All +// + + +/** The redirected console.log's capture-data-space */ +export let gConsoleStr = "" +/** + * Maintain original console.log, when needed + * @type { {(...data: any[]): void} | null} + */ +let gOrigConsoleLog = null + + +/** + * The trapping console.log + * @param {any[]} args + */ +export function console_trapped(...args) { + let res = args.map((arg)=>{ + if (typeof arg == 'object') { + return JSON.stringify(arg); + } else { + return String(arg); + } + }).join(' '); + gConsoleStr += `${res}\n`; +} + +/** + * Save the original console.log, if needed. + * Setup redir of console.log. + * Clear the redirected console.log's capture-data-space. + */ +export function console_redir() { + if (gOrigConsoleLog == null) { + if (console.log == console_trapped) { + throw new Error("ERRR:ToolsConsole:ReDir:Original Console.Log lost???"); + } + gOrigConsoleLog = console.log + } + console.log = console_trapped + gConsoleStr = "" +} + +/** + * Revert the redirected console.log to the original console.log, if possible. + */ +export function console_revert() { + if (gOrigConsoleLog !== null) { + if (gOrigConsoleLog == console_trapped) { + throw new Error("ERRR:ToolsConsole:Revert:Original Console.Log lost???"); + } + console.log = gOrigConsoleLog + } +} diff --git a/tools/server/public_simplechat/toolsworker.mjs b/tools/server/public_simplechat/toolsworker.mjs new file mode 100644 index 0000000000000..15675a8df8e87 --- /dev/null +++ b/tools/server/public_simplechat/toolsworker.mjs @@ -0,0 +1,28 @@ +//@ts-check +// STILL DANGER DANGER DANGER - Simple and Stupid - Use from a discardable VM only +// Helpers to handle tools/functions calling using web worker +// by Humans for All +// + +/** + * Expects to get a message with id, name and code to run + * Posts message with id, name and data captured from console.log outputs + */ + + +import * as tconsole from "./toolsconsole.mjs" +import * as xpromise from "./xpromise.mjs" + + +self.onmessage = async function (ev) { + console.info("DBUG:WW:OnMessage started...") + tconsole.console_redir() + try { + await xpromise.evalWithPromiseTracking(ev.data.code); + } catch (/** @type {any} */error) { + console.log(`\n\nTool/Function call "${ev.data.name}" raised an exception:${error.name}:${error.message}\n\n`) + } + tconsole.console_revert() + self.postMessage({ cid: ev.data.cid, tcid: ev.data.tcid, name: ev.data.name, data: tconsole.gConsoleStr}) + console.info("DBUG:WW:OnMessage done") +} diff --git a/tools/server/public_simplechat/toolweb.mjs b/tools/server/public_simplechat/toolweb.mjs new file mode 100644 index 0000000000000..eeecf846b0f19 --- /dev/null +++ b/tools/server/public_simplechat/toolweb.mjs @@ -0,0 +1,288 @@ +//@ts-check +// ALERT - Simple Stupid flow - Using from a discardable VM is better +// Helpers to handle tools/functions calling related to web access +// which work in sync with the bundled simpleproxy.py server logic. +// by Humans for All +// + + +let gToolsWorker = /** @type{Worker} */(/** @type {unknown} */(null)); + + +/** + * Send a message to Tools WebWorker's monitor in main thread directly + * @param {MessageEvent} mev + */ +function message_toolsworker(mev) { + // @ts-ignore + gToolsWorker.onmessage(mev) +} + + +/** + * Retrieve the global Me instance + */ +function get_gme() { + return (/** @type {Object>} */(/** @type {unknown} */(document)))['gMe'] +} + + +function bearer_transform() { + let data = `${new Date().getUTCFullYear()}${get_gme().tools.proxyAuthInsecure}` + return crypto.subtle.digest('sha-256', new TextEncoder().encode(data)).then(ab=>{ + return Array.from(new Uint8Array(ab)).map(b=>b.toString(16).padStart(2,'0')).join('') + }) +} + +/** + * Helper http get logic wrt the bundled SimpleProxy server, + * which helps execute a given proxy dependent tool call. + * Expects the simple minded proxy server to be running locally + * * listening on a configured port + * * expecting http requests + * * with a predefined query token and value wrt a predefined path + * NOTE: Initial go, handles textual data type. + * ALERT: Accesses a seperate/external web proxy/caching server, be aware and careful + * @param {string} chatid + * @param {string} toolcallid + * @param {string} toolname + * @param {any} obj + * @param {string} path + * @param {string} qkey + * @param {string} qvalue + */ +async function proxyserver_get_1arg(chatid, toolcallid, toolname, obj, path, qkey, qvalue) { + if (gToolsWorker.onmessage != null) { + let newUrl = `${get_gme().tools.proxyUrl}/${path}?${qkey}=${qvalue}` + let btoken = await bearer_transform() + fetch(newUrl, { headers: { 'Authorization': `Bearer ${btoken}` }}).then(resp => { + if (!resp.ok) { + throw new Error(`${resp.status}:${resp.statusText}`); + } + return resp.text() + }).then(data => { + message_toolsworker(new MessageEvent('message', {data: {cid: chatid, tcid: toolcallid, name: toolname, data: data}})) + }).catch((err)=>{ + message_toolsworker(new MessageEvent('message', {data: {cid: chatid, tcid: toolcallid, name: toolname, data: `Error:${err}`}})) + }) + } +} + + +/** + * Setup a proxy server dependent tool call + * NOTE: Currently the logic is setup for the bundled simpleproxy.py + * @param {string} tag + * @param {string} tcPath + * @param {string} tcName + * @param {{ [x: string]: any; }} tcsData + * @param {Object>} tcs + */ +async function proxyserver_tc_setup(tag, tcPath, tcName, tcsData, tcs) { + await fetch(`${get_gme().tools.proxyUrl}/aum?url=${tcPath}.jambudweepe.akashaganga.multiverse.987654321123456789`).then(resp=>{ + if (resp.statusText != 'bharatavarshe') { + console.log(`WARN:ToolWeb:${tag}:Dont forget to run the bundled local.tools/simpleproxy.py to enable me`) + return + } else { + console.log(`INFO:ToolWeb:${tag}:Enabling...`) + } + tcs[tcName] = tcsData; + }).catch(err=>console.log(`WARN:ToolWeb:${tag}:ProxyServer missing?:${err}\nDont forget to run the bundled local.tools/simpleproxy.py`)) +} + + +// +// Fetch Web Url Raw +// + + +let fetchweburlraw_meta = { + "type": "function", + "function": { + "name": "fetch_web_url_raw", + "description": "Fetch the requested web url through a proxy server and return the got content as is, in few seconds", + "parameters": { + "type": "object", + "properties": { + "url":{ + "type":"string", + "description":"url of the web page to fetch from the internet" + } + }, + "required": ["url"] + } + } + } + + +/** + * Implementation of the fetch web url raw logic. + * Expects a simple minded proxy server to be running locally + * * listening on a configured port + * * expecting http requests + * * with a query token named url wrt the path urlraw + * which gives the actual url to fetch + * ALERT: Accesses a seperate/external web proxy/caching server, be aware and careful + * @param {string} chatid + * @param {string} toolcallid + * @param {string} toolname + * @param {any} obj + */ +function fetchweburlraw_run(chatid, toolcallid, toolname, obj) { + return proxyserver_get_1arg(chatid, toolcallid, toolname, obj, 'urlraw', 'url', encodeURIComponent(obj.url)); +} + + +/** + * Setup fetch_web_url_raw for tool calling + * NOTE: Currently the logic is setup for the bundled simpleproxy.py + * @param {Object>} tcs + */ +async function fetchweburlraw_setup(tcs) { + return proxyserver_tc_setup('FetchWebUrlRaw', 'urlraw', 'fetch_web_url_raw', { + "handler": fetchweburlraw_run, + "meta": fetchweburlraw_meta, + "result": "" + }, tcs); +} + + +// +// Fetch Web Url Text +// + + +let fetchweburltext_meta = { + "type": "function", + "function": { + "name": "fetch_web_url_text", + "description": "Fetch the requested web url through a proxy server and return its text content after stripping away the html tags as well as head, script, style, header, footer, nav blocks, in few seconds", + "parameters": { + "type": "object", + "properties": { + "url":{ + "type":"string", + "description":"url of the page that will be fetched from the internet and inturn unwanted stuff stripped from its contents to some extent" + } + }, + "required": ["url"] + } + } + } + + +/** + * Implementation of the fetch web url text logic. + * Expects a simple minded proxy server to be running locally + * * listening on a configured port + * * expecting http requests + * * with a query token named url wrt urltext path, + * which gives the actual url to fetch + * * strips out head as well as any script, style, header, footer, nav and so blocks in body + * before returning remaining body contents. + * ALERT: Accesses a seperate/external web proxy/caching server, be aware and careful + * @param {string} chatid + * @param {string} toolcallid + * @param {string} toolname + * @param {any} obj + */ +function fetchweburltext_run(chatid, toolcallid, toolname, obj) { + return proxyserver_get_1arg(chatid, toolcallid, toolname, obj, 'urltext', 'url', encodeURIComponent(obj.url)); +} + + +/** + * Setup fetch_web_url_text for tool calling + * NOTE: Currently the logic is setup for the bundled simpleproxy.py + * @param {Object>} tcs + */ +async function fetchweburltext_setup(tcs) { + return proxyserver_tc_setup('FetchWebUrlText', 'urltext', 'fetch_web_url_text', { + "handler": fetchweburltext_run, + "meta": fetchweburltext_meta, + "result": "" + }, tcs); +} + + +// +// Search Web Text +// + + +let searchwebtext_meta = { + "type": "function", + "function": { + "name": "search_web_text", + "description": "search web for given words and return the plain text content after stripping the html tags as well as head, script, style, header, footer, nav blocks from got html result page, in few seconds", + "parameters": { + "type": "object", + "properties": { + "words":{ + "type":"string", + "description":"the words to search for on the web" + } + }, + "required": ["words"] + } + } + } + + +/** + * Implementation of the search web text logic. Initial go. + * Builds on urltext path of the bundled simpleproxy.py. + * Expects simpleproxy.py server to be running locally + * * listening on a configured port + * * expecting http requests + * * with a query token named url wrt urltext path, + * which gives the actual url to fetch + * * strips out head as well as any script, style, header, footer, nav and so blocks in body + * before returning remaining body contents. + * ALERT: Accesses a seperate/external web proxy/caching server, be aware and careful + * @param {string} chatid + * @param {string} toolcallid + * @param {string} toolname + * @param {any} obj + */ +function searchwebtext_run(chatid, toolcallid, toolname, obj) { + if (gToolsWorker.onmessage != null) { + /** @type {string} */ + let searchUrl = get_gme().tools.searchUrl; + searchUrl = searchUrl.replace("SEARCHWORDS", encodeURIComponent(obj.words)); + return proxyserver_get_1arg(chatid, toolcallid, toolname, obj, 'urltext', 'url', encodeURIComponent(searchUrl)); + } +} + + +/** + * Setup search_web_text for tool calling + * NOTE: Currently the logic is setup for the bundled simpleproxy.py + * @param {Object>} tcs + */ +async function searchwebtext_setup(tcs) { + return proxyserver_tc_setup('SearchWebText', 'urltext', 'search_web_text', { + "handler": searchwebtext_run, + "meta": searchwebtext_meta, + "result": "" + }, tcs); +} + + + +/** + * Used to get hold of the web worker to use for running tool/function call related code + * Also to setup tool calls, which need to cross check things at runtime + * @param {Worker} toolsWorker + */ +export async function init(toolsWorker) { + /** + * @type {Object>} tcs + */ + let tc_switch = {} + gToolsWorker = toolsWorker + await fetchweburlraw_setup(tc_switch) + await fetchweburltext_setup(tc_switch) + await searchwebtext_setup(tc_switch) + return tc_switch +} diff --git a/tools/server/public_simplechat/ui.mjs b/tools/server/public_simplechat/ui.mjs index b2d5b9aeab76c..fb447d3e6e7e2 100644 --- a/tools/server/public_simplechat/ui.mjs +++ b/tools/server/public_simplechat/ui.mjs @@ -4,6 +4,27 @@ // +/** + * Insert key-value pairs into passed element object. + * @param {HTMLElement} el + * @param {string} key + * @param {any} value + */ +function el_set(el, key, value) { + // @ts-ignore + el[key] = value +} + +/** + * Retrieve the value corresponding to given key from passed element object. + * @param {HTMLElement} el + * @param {string} key + */ +function el_get(el, key) { + // @ts-ignore + return el[key] +} + /** * Set the class of the children, based on whether it is the idSelected or not. * @param {HTMLDivElement} elBase @@ -72,16 +93,16 @@ export function el_create_append_p(text, elParent=undefined, id=undefined) { */ export function el_create_boolbutton(id, texts, defaultValue, cb) { let el = document.createElement("button"); - el["xbool"] = defaultValue; - el["xtexts"] = structuredClone(texts); - el.innerText = el["xtexts"][String(defaultValue)]; + el_set(el, "xbool", defaultValue) + el_set(el, "xtexts", structuredClone(texts)) + el.innerText = el_get(el, "xtexts")[String(defaultValue)]; if (id) { el.id = id; } el.addEventListener('click', (ev)=>{ - el["xbool"] = !el["xbool"]; - el.innerText = el["xtexts"][String(el["xbool"])]; - cb(el["xbool"]); + el_set(el, "xbool", !el_get(el, "xbool")); + el.innerText = el_get(el, "xtexts")[String(el_get(el, "xbool"))]; + cb(el_get(el, "xbool")); }) return el; } @@ -121,8 +142,8 @@ export function el_creatediv_boolbutton(id, label, texts, defaultValue, cb, clas */ export function el_create_select(id, options, defaultOption, cb) { let el = document.createElement("select"); - el["xselected"] = defaultOption; - el["xoptions"] = structuredClone(options); + el_set(el, "xselected", defaultOption); + el_set(el, "xoptions", structuredClone(options)); for(let cur of Object.keys(options)) { let op = document.createElement("option"); op.value = cur; @@ -209,3 +230,127 @@ export function el_creatediv_input(id, label, type, defaultValue, cb, className= div.appendChild(el); return { div: div, el: el }; } + + +/** + * Auto create ui input elements for specified fields/properties in given object + * Currently supports text, number, boolean field types. + * Also supports recursing if a object type field is found. + * + * If for any reason the caller wants to refine the created ui element for a specific prop, + * they can define a fRefiner callback, which will be called back with prop name and ui element. + * The fRefiner callback even helps work with Obj with-in Obj scenarios. + * + * For some reason if caller wants to handle certain properties on their own + * * specify the prop name of interest along with its prop-tree-hierarchy in lTrapThese + * * always start with : when ever refering to propWithPath, + * as it indirectly signifies root of properties tree + * * remember to seperate the properties tree hierarchy members using : + * * fTrapper will be called with the parent ui element + * into which the new ui elements created for editting the prop, if any, should be attached, + * along with the current prop of interest and its full propWithPath representation. + * @param {HTMLDivElement|HTMLFieldSetElement} elParent + * @param {string} propsTreeRoot + * @param {any} oObj + * @param {Array} lProps + * @param {string} sLegend + * @param {((prop:string, elProp: HTMLElement)=>void)| undefined} fRefiner + * @param {Array | undefined} lTrapThese + * @param {((propWithPath: string, prop: string, elParent: HTMLFieldSetElement)=>void) | undefined} fTrapper + */ +export function ui_show_obj_props_edit(elParent, propsTreeRoot, oObj, lProps, sLegend, fRefiner=undefined, lTrapThese=undefined, fTrapper=undefined) { + let typeDict = { + "string": "text", + "number": "number", + }; + let elFS = document.createElement("fieldset"); + let elLegend = document.createElement("legend"); + elLegend.innerText = sLegend; + elFS.appendChild(elLegend); + elParent.appendChild(elFS); + for(const k of lProps) { + let propsTreeRootNew = `${propsTreeRoot}:${k}` + if (lTrapThese) { + if (lTrapThese.indexOf(propsTreeRootNew) != -1) { + if (fTrapper) { + fTrapper(propsTreeRootNew, k, elFS) + } + continue + } + } + let val = oObj[k]; + let type = typeof(val); + if (((type == "string") || (type == "number"))) { + let inp = el_creatediv_input(`Set${k}`, k, typeDict[type], oObj[k], (val)=>{ + if (type == "number") { + val = Number(val); + } + oObj[k] = val; + }); + if (fRefiner) { + fRefiner(k, inp.el) + } + elFS.appendChild(inp.div); + } else if (type == "boolean") { + let bbtn = el_creatediv_boolbutton(`Set{k}`, k, {true: "true", false: "false"}, val, (userVal)=>{ + oObj[k] = userVal; + }); + if (fRefiner) { + fRefiner(k, bbtn.el) + } + elFS.appendChild(bbtn.div); + } else if (type == "object") { + ui_show_obj_props_edit(elFS, propsTreeRootNew, val, Object.keys(val), k, (prop, elProp)=>{ + if (fRefiner) { + let theProp = `${k}:${prop}` + fRefiner(theProp, elProp) + } + }, lTrapThese, fTrapper) + } + } +} + + +/** + * Show the specified properties and their values wrt the given object, + * with in the elParent provided. + * @param {HTMLDivElement | HTMLElement} elParent + * @param {any} oObj + * @param {Array} lProps + * @param {string} sLegend + * @param {string} sOffset - can be used to prefix each of the prop entries + * @param {any | undefined} dClassNames - can specify class for top level div and legend + */ +export function ui_show_obj_props_info(elParent, oObj, lProps, sLegend, sOffset="", dClassNames=undefined) { + if (sOffset.length == 0) { + let div = document.createElement("div"); + div.classList.add(`DivObjPropsInfoL${sOffset.length}`) + elParent.appendChild(div) + elParent = div + } + let elPLegend = el_create_append_p(sLegend, elParent) + if (dClassNames) { + if (dClassNames['div']) { + elParent.className = dClassNames['div'] + } + if (dClassNames['legend']) { + elPLegend.className = dClassNames['legend'] + } + } + let elS = document.createElement("section"); + elS.classList.add(`SectionObjPropsInfoL${sOffset.length}`) + elParent.appendChild(elPLegend); + elParent.appendChild(elS); + + for (const k of lProps) { + let kPrint = `${sOffset}${k}` + let val = oObj[k]; + let vtype = typeof(val) + if (vtype != 'object') { + el_create_append_p(`${kPrint}: ${oObj[k]}`, elS) + } else { + ui_show_obj_props_info(elS, val, Object.keys(val), kPrint, `>${sOffset}`) + //el_create_append_p(`${k}:${JSON.stringify(oObj[k], null, " - ")}`, elS); + } + } +} diff --git a/tools/server/public_simplechat/xpromise.mjs b/tools/server/public_simplechat/xpromise.mjs new file mode 100644 index 0000000000000..6f001ef9de6e7 --- /dev/null +++ b/tools/server/public_simplechat/xpromise.mjs @@ -0,0 +1,87 @@ +//@ts-check +// Helpers for a tracked promise land +// Traps regular promise as well as promise by fetch +// by Humans for All +// + + +/** + * @typedef {(resolve: (value: any) => void, reject: (reason?: any) => void) => void} PromiseExecutor + */ + + +/** + * Eval which allows promises generated by the evald code to be tracked. + * @param {string} codeToEval + */ +export async function evalWithPromiseTracking(codeToEval) { + const _Promise = globalThis.Promise; + const _fetch = globalThis.fetch + + /** @type {any[]} */ + const trackedPromises = []; + + const Promise = function ( /** @type {PromiseExecutor} */ executor) { + console.info("WW:PT:Promise") + const promise = new _Promise(executor); + trackedPromises.push(promise); + + // @ts-ignore + promise.then = function (...args) { + console.info("WW:PT:Then") + const newPromise = _Promise.prototype.then.apply(this, args); + trackedPromises.push(newPromise); + return newPromise; + }; + + promise.catch = function (...args) { + console.info("WW:PT:Catch") + const newPromise = _Promise.prototype.catch.apply(this, args); + trackedPromises.push(newPromise); + return newPromise; + }; + + return promise; + }; + + Promise.prototype = _Promise.prototype; + Object.assign(Promise, _Promise); + + const fetch = function(/** @type {any[]} */ ...args) { + console.info("WW:PT:Fetch") + // @ts-ignore + const fpromise = _fetch(args); + trackedPromises.push(fpromise) + + // @ts-ignore + fpromise.then = function (...args) { + console.info("WW:PT:FThen") + const newPromise = _Promise.prototype.then.apply(this, args); + trackedPromises.push(newPromise); + return newPromise; + }; + + fpromise.catch = function (...args) { + console.info("WW:PT:FCatch") + const newPromise = _Promise.prototype.catch.apply(this, args); + trackedPromises.push(newPromise); + return newPromise; + }; + + return fpromise; + } + + fetch.prototype = _fetch.prototype; + Object.assign(fetch, _fetch); + + //let tf = new Function(codeToEval); + //await tf() + await eval(`(async () => { ${codeToEval} })()`); + + // Should I allow things to go back to related event loop once + //await Promise(resolve=>setTimeout(resolve, 0)); + + // Need and prefer promise failures to be trapped using reject/catch logic + // so using all instead of allSettled. + return _Promise.all(trackedPromises); +}