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..949b7e014d5af --- /dev/null +++ b/tools/server/public_simplechat/local.tools/simpleproxy.json @@ -0,0 +1,15 @@ +{ + "allowed.domains": [ + ".*\\.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$" + ] +} 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..86f5aa0e7b60f --- /dev/null +++ b/tools/server/public_simplechat/local.tools/simpleproxy.py @@ -0,0 +1,385 @@ +# 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. +# + + +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, + 'server': None +} + + +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 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': + handle_urlraw(self, pr) + case '/urltext': + 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: + gMe[f"--{k}"] = cfg[k] + + +def process_args(args: list[str]): + import ast + """ + Helper to process command line arguments + """ + global gMe + gMe['INTERNAL.ProcessArgs.Malformed'] = [] + gMe['INTERNAL.ProcessArgs.Unknown'] = [] + iArg = 1 + while iArg < len(args): + cArg = args[iArg] + if (not cArg.startswith("--")): + gMe['INTERNAL.ProcessArgs.Malformed'].append(cArg) + print(f"WARN:ProcessArgs:{iArg}:IgnoringMalformedCommandOr???:{cArg}") + iArg += 1 + continue + match cArg: + case '--port': + iArg += 1 + gMe[cArg] = int(args[iArg]) + iArg += 1 + case '--config': + iArg += 1 + gMe[cArg] = args[iArg] + iArg += 1 + load_config() + case '--allowed.domains': + iArg += 1 + gMe[cArg] = ast.literal_eval(args[iArg]) + iArg += 1 + case '--debug': + iArg += 1 + gMe[cArg] = ast.literal_eval(args[iArg]) + iArg += 1 + case _: + gMe['INTERNAL.ProcessArgs.Unknown'].append(cArg) + print(f"WARN:ProcessArgs:{iArg}:IgnoringUnknownCommand:{cArg}") + iArg += 1 + print(gMe) + + +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..83781a31ead85 100644 --- a/tools/server/public_simplechat/readme.md +++ b/tools/server/public_simplechat/readme.md @@ -7,7 +7,7 @@ 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. @@ -33,13 +33,18 @@ 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. +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 by using the predefined tools/functions. +The end user is provided control over tool calling and response submitting. + 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. -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. +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 +56,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 +69,26 @@ 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 url / page tool call is needed remember to run the bundled local.tools/simpleproxy.py + helper along with its config file + + * cd tools/server/public_simplechat/local.tools; python3 ./simpleproxy.py --config simpleproxy.json + + * remember that this is a relatively dumb proxy logic along with optional stripping of scripts / styles + / headers / footers /..., Be careful if trying to fetch web pages, and use it only with known safe sites. + + * it allows one to specify a white list of allowed.domains, look into local.tools/simpleproxy.json + ### using the front end Open this simple web front end from your local browser @@ -78,8 +103,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 -* 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,6 +142,17 @@ 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. +* 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. + * just refresh the page, to reset wrt the chat history and or system prompt and start afresh. * Using NewChat one can start independent chat sessions. @@ -146,38 +183,68 @@ It is attached to the document object. Some of these can also be updated using t baseURL - the domain-name/ip-address and inturn the port to send the request. - bStream - control between oneshot-at-end and live-stream-as-its-generated collating and showing - of the generated response. + 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. - 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. - 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. - 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. - apiEP - select between /completions and /chat/completions endpoint provided by the server/ai-model. + 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. - bCompletionFreshChatAlways - whether Completion mode collates complete/sliding-window history when - communicating with the server or only sends the latest user query/message. + bCompletionFreshChatAlways - whether Completion mode collates complete/sliding-window history when + communicating with the server or only sends the latest user query/message. - bCompletionInsertStandardRolePrefix - whether Completion mode inserts role related prefix wrt the - messages that get inserted into prompt field wrt /Completion endpoint. + bCompletionInsertStandardRolePrefix - whether Completion mode inserts role related prefix wrt the + messages that get inserted into prompt field wrt /Completion endpoint. - 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. + 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. - 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. + 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. - The histogram/freq based trimming logic is currently tuned for english language wrt its - is-it-a-alpabetic|numeral-char regex match logic. + The histogram/freq based trimming logic is currently tuned for english language wrt its + is-it-a-alpabetic|numeral-char regex match logic. + + tools - contains controls related to tool calling + + enabled - control whether tool calling is enabled or not + + remember to enable this only for GenAi/LLM models which support tool/function calling. + + fetchProxyUrl - specify the address for the running instance of bundled local.tools/simpleproxy.py + + the builtin tools' meta data is sent to the ai model in the requests sent to it. + + 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. + + 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. @@ -200,22 +267,12 @@ It is attached to the document object. Some of these can also be updated using t Content-Type is set to application/json. Additionally Authorization entry is provided, which can be set if needed using the settings ui. - 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. - 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. - - -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. +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 +281,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 +295,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 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, 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, to be on safe side. 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 +316,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 +344,108 @@ NOTE: Not tested, as there is no free tier api testing available. However logica work. +### Tool Calling + +ALERT: The simple minded way in which this is implemented, it can be dangerous in the worst case, +Always 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. + +#### Builtin Tools + +The following tools/functions are currently provided by default +* 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. +* fetch_web_url_raw - fetch requested url through a proxy server +* fetch_web_url_text - fetch requested url through a proxy server + and also try strip the html respose of html tags and also head, script, style, header,footer,... blocks. + +Currently the 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. + +fetch_web_url_raw/text and family works along with a corresponding simple local web proxy/caching +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, if urltext +(and not urlraw), it additionally tries to convert html content into equivalent text to some extent +in a simple minded manner by dropping head block as well as all scripts/styles/footers/headers/nav. +* the logic does a simple check to see if the bundled simpleproxy is running at specified fetchProxyUrl + before enabling fetch web 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 an extent + * 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. + + +#### Extending with new tools + +Provide a descriptive meta data explaining the tool / function being provided for tool calling, +as well as its arguments. + +Provide a handler which should implement the specified tool / function call or rather for many +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. Remember to use console.log while +generating any response that should be sent back to the ai model, in your constructed code. + +Update the tc_switch to include a object entry for the tool, which inturn includes +* the meta data as well as +* a reference to the handler and also + the handler should take toolCallId, toolName and toolArgs and pass these along to + web worker as needed. +* the result key (was used previously, may use in future, but for now left as is) + +#### 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 the GenAi/LLM to be 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. + +#### 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. + + +### ToDo + +Is the tool call promise land trap deep enough, need to think through and explore around this once later. + +Trap error responses. + +Handle reasoning/thinking responses from ai models. + +Handle multimodal handshaking with ai models. + + +### Debuging the handshake + +When working with llama.cpp server based GenAi/LLM running locally + +sudo tcpdump -i lo -s 0 -vvv -A host 127.0.0.1 and port 8080 | tee /tmp/td.log + + ## 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..98e88d99fb4a7 100644 --- a/tools/server/public_simplechat/simplechat.css +++ b/tools/server/public_simplechat/simplechat.css @@ -21,6 +21,9 @@ .role-user { background-color: lightgray; } +.role-tool { + background-color: lightyellow; +} .role-trim { background-color: lightpink; } @@ -66,10 +69,20 @@ button { padding-inline-start: 2vw; } + +.DivObjPropsInfoL0 { + margin: 0%; +} +[class^=SectionObjPropsInfoL] { + margin-left: 2vmin; +} + + * { margin: 0.6vmin; } + @media print { #fullbody { diff --git a/tools/server/public_simplechat/simplechat.js b/tools/server/public_simplechat/simplechat.js index 2fcd24a860bd4..c0f0ac4544f82 100644 --- a/tools/server/public_simplechat/simplechat.js +++ b/tools/server/public_simplechat/simplechat.js @@ -4,11 +4,14 @@ import * as du from "./datautils.mjs"; import * as ui from "./ui.mjs" +import * as tools from "./tools.mjs" + class Roles { static System = "system"; static User = "user"; static Assistant = "assistant"; + static Tool = "tool"; } class ApiEP { @@ -16,6 +19,7 @@ class ApiEP { Chat: "chat", Completion: "completion", } + /** @type {Object} */ static UrlSuffix = { 'chat': `/chat/completions`, 'completion': `/completions`, @@ -35,6 +39,219 @@ class ApiEP { } +/** + * @typedef {{id: string, type: string, function: {name: string, arguments: string}}} NSToolCalls + */ + +/** + * @typedef {{role: string, content: string, tool_calls: Array}} NSChatMessage + */ + +class ChatMessageEx { + + /** + * Represent a Message in the Chat + * @param {string} role + * @param {string} content + * @param {Array} tool_calls + * @param {string} trimmedContent + */ + constructor(role = "", content="", tool_calls=[], trimmedContent="") { + /** @type {NSChatMessage} */ + this.ns = { role: role, content: content, tool_calls: tool_calls } + this.trimmedContent = trimmedContent; + } + + /** + * Create a new instance from an existing instance + * @param {ChatMessageEx} old + */ + static newFrom(old) { + return new ChatMessageEx(old.ns.role, old.ns.content, old.ns.tool_calls, old.trimmedContent) + } + + clear() { + this.ns.role = ""; + this.ns.content = ""; + this.ns.tool_calls = []; + this.trimmedContent = ""; + } + + /** + * Create a all in one tool call result string + * 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) + } + return new XMLSerializer().serializeToString(doc); + } + + /** + * Extract the elements of the all in one tool call result string + * @param {string} allInOne + */ + static extractToolCallResultAllInOneSimpleMinded(allInOne) { + const regex = /\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"]; + if (toolCalls !== undefined) { + if (toolCalls[0]["function"]["name"] !== undefined) { + this.ns.tool_calls.push(toolCalls[0]); + /* + this.ns.tool_calls[0].function.name = toolCalls[0]["function"]["name"]; + this.ns.tool_calls[0].id = toolCalls[0]["id"]; + this.ns.tool_calls[0].type = toolCalls[0]["type"]; + this.ns.tool_calls[0].function.arguments = toolCalls[0]["function"]["arguments"] + */ + } else { + if (toolCalls[0]["function"]["arguments"] !== undefined) { + this.ns.tool_calls[0].function.arguments += toolCalls[0]["function"]["arguments"]; + } + } + } + } + } + } else { + try { + this.ns.content += nwo["choices"][0]["text"]; + } catch { + this.ns.content += nwo["content"]; + } + } + } + + /** + * Update based on the data got from network in oneshot mode + * @param {any} nwo + * @param {string} apiEP + */ + update_oneshot(nwo, apiEP) { + if (apiEP == ApiEP.Type.Chat) { + let curContent = nwo["choices"][0]["message"]["content"]; + if (curContent != undefined) { + if (curContent != null) { + this.ns.content = curContent; + } + } + let curTCs = nwo["choices"][0]["message"]["tool_calls"]; + if (curTCs != undefined) { + this.ns.tool_calls = curTCs; + } + } else { + try { + this.ns.content = nwo["choices"][0]["text"]; + } catch { + this.ns.content = nwo["content"]; + } + } + } + + has_toolcall() { + if (this.ns.tool_calls.length == 0) { + return false + } + return true + } + + content_equiv() { + if (this.ns.content !== "") { + return this.ns.content; + } else if (this.has_toolcall()) { + return `\n${this.ns.tool_calls[0].function.name}\n${this.ns.tool_calls[0].function.arguments}\n`; + } else { + return "" + } + } + +} + let gUsageMsg = `

Usage

@@ -44,8 +261,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 +274,7 @@ let gUsageMsg = ` `; -/** @typedef {{role: string, content: string}[]} ChatMessages */ +/** @typedef {ChatMessageEx[]} ChatMessages */ /** @typedef {{iLastSys: number, xchat: ChatMessages}} SimpleChatODS */ @@ -70,7 +291,7 @@ class SimpleChat { */ this.xchat = []; this.iLastSys = -1; - this.latestResponse = ""; + this.latestResponse = new ChatMessageEx(); } clear() { @@ -96,7 +317,16 @@ 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) { + /** @typedef {{role: string, content: string}} OldChatMessage */ + let tcur = /** @type {OldChatMessage} */(/** @type {unknown} */(cur)); + this.xchat.push(new ChatMessageEx(tcur.role, tcur.content)) + } else { + this.xchat.push(new ChatMessageEx(cur.ns.role, cur.ns.content, cur.ns.tool_calls, cur.trimmedContent)) + } + } } /** @@ -118,8 +348,8 @@ class SimpleChat { /** @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 +358,55 @@ 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) { + let tmsg = ChatMessageEx.newFrom(msg); + if (!tmsg.has_toolcall()) { + tmsg.ns_delete("tool_calls") + } + if (tmsg.ns.role == Roles.Tool) { + let res = ChatMessageEx.extractToolCallResultAllInOne(tmsg.ns.content) + tmsg.ns.content = res.content + tmsg.ns_set_extra("tool_call_id", res.tool_call_id) + tmsg.ns_set_extra("name", res.name) + } + chat.push(tmsg.ns); + } + return chat } /** - * Add an entry into xchat - * @param {string} role - * @param {string|undefined|null} content + * Add an entry into xchat. + * NOTE: A new copy is created and added into xchat. + * Also update iLastSys system prompt index tracker + * @param {ChatMessageEx} chatMsg */ - add(role, content) { - if ((content == undefined) || (content == null) || (content == "")) { - return false; - } - this.xchat.push( {role: role, content: content} ); - if (role == Roles.System) { + add(chatMsg) { + this.xchat.push(ChatMessageEx.newFrom(chatMsg)); + if (chatMsg.ns.role == Roles.System) { this.iLastSys = this.xchat.length - 1; } this.save(); @@ -170,18 +414,23 @@ class SimpleChat { } /** - * Show the contents in the specified div + * Show the chat contents in the specified div. + * If requested to clear prev stuff and inturn no chat content then show + * * usage info + * * option to load prev saved chat if any + * * as well as settings/info. * @param {HTMLDivElement} div * @param {boolean} bClear + * @param {boolean} bShowInfoAll */ - show(div, bClear=true) { + show(div, bClear=true, bShowInfoAll=false) { if (bClear) { div.replaceChildren(); } let last = undefined; - for(const x of this.recent_chat(gMe.iRecentUserMsgCnt)) { - let entry = ui.el_create_append_p(`${x.role}: ${x.content}`, div); - entry.className = `role-${x.role}`; + for(const x of this.recent_chat(gMe.chatProps.iRecentUserMsgCnt)) { + let entry = ui.el_create_append_p(`${x.ns.role}: ${x.content_equiv()}`, div); + entry.className = `role-${x.ns.role}`; last = entry; } if (last !== undefined) { @@ -190,7 +439,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 +468,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 +488,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 +500,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 +524,14 @@ class SimpleChat { if (apiEP == ApiEP.Type.Chat) { return this.request_messages_jsonstr(); } else { - return this.request_prompt_jsonstr(gMe.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 this.request_prompt_jsonstr(gMe.chatProps.bCompletionInsertStandardRolePrefix); } - 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 +541,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]; } @@ -387,7 +575,8 @@ class SimpleChat { } 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 +601,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 +621,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(toolcallid, toolname, toolargs) + } catch (/** @type {any} */error) { + return `Tool/Function call raised an exception:${error.name}:${error.message}` + } + } + } @@ -480,6 +691,29 @@ class MultiChatUI { /** @type {string} */ this.curChatId = ""; + this.TimePeriods = { + ToolCallResponseTimeout: 10000, + 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 + } + // the ui elements this.elInSystem = /** @type{HTMLInputElement} */(document.getElementById("system-in")); this.elDivChat = /** @type{HTMLDivElement} */(document.getElementById("chat-div")); @@ -488,6 +722,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 +733,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,18 +748,47 @@ 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(); } @@ -535,16 +806,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 +827,28 @@ 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((id, name, data)=>{ + clearTimeout(this.timers.toolcallResponseTimeout) + this.timers.toolcallResponseTimeout = undefined + this.elInUser.value = ChatMessageEx.createToolCallResultAllInOne(id, name, data); + this.ui_reset_userinput(false) + if (gMe.tools.auto > 0) { + this.timers.toolcallResponseSubmitClick = setTimeout(()=>{ + this.elBtnUser.click() + }, gMe.tools.auto*this.TimePeriods.ToolCallAutoTimeUnit) + } + }) + 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. @@ -593,6 +890,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,17 +909,24 @@ 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; } + if (content.startsWith("")) { + chat.add(new ChatMessageEx(Roles.Tool, content)) + } else { + chat.add(new ChatMessageEx(Roles.User, content)) + } chat.show(this.elDivChat); let theUrl = ApiEP.Url(gMe.baseURL, apiEP); @@ -633,16 +945,43 @@ 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); + if (theResp.trimmedContent.length > 0) { + let p = ui.el_create_append_p(`TRIMMED:${theResp.trimmedContent}`, this.elDivChat); p.className="role-trim"; } } else { console.debug(`DBUG:SimpleChat:MCUI:HandleUserSubmit:ChatId has changed:[${chatId}] [${this.curChatId}]`); } + this.ui_reset_toolcall_as_needed(theResp); this.ui_reset_userinput(); } + /** + * Handle running of specified tool call if any, for the specified chat session. + * Also sets up a timeout, so that user gets control back to interact with the ai model. + * @param {string} chatId + */ + async handle_tool_run(chatId) { + let chat = this.simpleChats[chatId]; + this.elInUser.value = "toolcall in progress..."; + this.elInUser.disabled = true; + let toolname = this.elInToolName.value.trim() + let toolCallId = this.elInToolName.dataset.tool_call_id; + if (toolCallId === undefined) { + toolCallId = "??? ToolCallId Missing ???" + } + let toolResult = await chat.handle_toolcall(toolCallId, toolname, this.elInToolArgs.value) + if (toolResult !== undefined) { + this.elInUser.value = ChatMessageEx.createToolCallResultAllInOne(toolCallId, toolname, toolResult); + this.ui_reset_userinput(false) + } else { + this.timers.toolcallResponseTimeout = setTimeout(() => { + this.elInUser.value = ChatMessageEx.createToolCallResultAllInOne(toolCallId, toolname, `Tool/Function call ${toolname} taking too much time, aborting...`); + this.ui_reset_userinput(false) + }, this.TimePeriods.ToolCallResponseTimeout) + } + } + /** * Show buttons for NewChat and available chat sessions, in the passed elDiv. * If elDiv is undefined/null, then use this.elDivSessions. @@ -682,6 +1021,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,9 +1052,9 @@ 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); + chat.show(this.elDivChat, true, true); this.elInUser.focus(); this.curChatId = chatId; console.log(`INFO:SimpleChat:MCUI:HandleSessionSwitch:${chatId} entered...`); @@ -725,29 +1069,47 @@ class Me { 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, + fetchProxyUrl: "http://127.0.0.1:3128", + toolNames: /** @type {Array} */([]), + /** + * 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 +1141,8 @@ class Me { console.log("DBUG:SimpleChat:SC:Load", chat); chat.load(); queueMicrotask(()=>{ - chat.show(div); - this.multiChat.elInSystem.value = chat.get_system_latest(); + chat.show(div, true, true); + this.multiChat.elInSystem.value = chat.get_system_latest().ns.content; }); }); div.appendChild(btn); @@ -792,68 +1154,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 +1172,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 +1207,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..cfd216e3666b7 --- /dev/null +++ b/tools/server/public_simplechat/tooljs.mjs @@ -0,0 +1,257 @@ +//@ts-check +// DANGER DANGER DANGER - Simple and Stupid - Use from a discardable VM only +// 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} toolcallid + * @param {string} toolname + * @param {any} obj + */ +function js_run(toolcallid, toolname, obj) { + gToolsWorker.postMessage({ id: 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} toolcallid + * @param {string} toolname + * @param {any} obj + */ +function calc_run(toolcallid, toolname, obj) { + gToolsWorker.postMessage({ id: toolcallid, name: toolname, code: `console.log(${obj["arithexpr"]})`}) +} + + +/** + * Send a message to Tools WebWorker's monitor in main thread directly + * @param {MessageEvent} mev + */ +function message_toolsworker(mev) { + // @ts-ignore + gToolsWorker.onmessage(mev) +} + + +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. Dumb initial go. + * Expects a simple minded proxy server to be running locally + * * listening on port 3128 + * * 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} toolcallid + * @param {string} toolname + * @param {any} obj + */ +function fetchweburlraw_run(toolcallid, toolname, obj) { + if (gToolsWorker.onmessage != null) { + // @ts-ignore + let newUrl = `${document['gMe'].tools.fetchProxyUrl}/urlraw?url=${encodeURIComponent(obj.url)}` + fetch(newUrl).then(resp => { + if (!resp.ok) { + throw new Error(`${resp.status}:${resp.statusText}`); + } + return resp.text() + }).then(data => { + message_toolsworker(new MessageEvent('message', {data: {id: toolcallid, name: toolname, data: data}})) + }).catch((err)=>{ + message_toolsworker(new MessageEvent('message', {data: {id: toolcallid, name: toolname, data: `Error:${err}`}})) + }) + } +} + + +/** + * 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) { + // @ts-ignore + let got = await fetch(`${document["gMe"].tools.fetchProxyUrl}/aum?url=jambudweepe.multiverse.987654321123456789`).then(resp=>{ + if (resp.statusText != 'bharatavarshe') { + console.log("WARN:ToolJS:FetchWebUrlRaw:Dont forget to run the bundled local.tools/simpleproxy.py to enable me") + return + } else { + console.log("INFO:ToolJS:FetchWebUrlRaw:Enabling...") + } + tcs["fetch_web_url_raw"] = { + "handler": fetchweburlraw_run, + "meta": fetchweburlraw_meta, + "result": "" + }; + }).catch(err=>console.log(`WARN:ToolJS:FetchWebUrlRaw:ProxyServer missing?:${err}\nDont forget to run the bundled local.tools/simpleproxy.py`)) +} + + +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. Dumb initial go. + * Expects a simple minded proxy server to be running locally + * * listening on port 3128 + * * 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} toolcallid + * @param {string} toolname + * @param {any} obj + */ +function fetchweburltext_run(toolcallid, toolname, obj) { + if (gToolsWorker.onmessage != null) { + // @ts-ignore + let newUrl = `${document['gMe'].tools.fetchProxyUrl}/urltext?url=${encodeURIComponent(obj.url)}` + fetch(newUrl).then(resp => { + if (!resp.ok) { + throw new Error(`${resp.status}:${resp.statusText}`); + } + return resp.text() + }).then(data => { + message_toolsworker(new MessageEvent('message', {data: {id: toolcallid, name: toolname, data: data}})) + }).catch((err)=>{ + message_toolsworker(new MessageEvent('message', {data: {id: toolcallid, name: toolname, data: `Error:${err}`}})) + }) + } +} + + +/** + * 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) { + // @ts-ignore + let got = await fetch(`${document["gMe"].tools.fetchProxyUrl}/aum?url=jambudweepe.akashaganga.multiverse.987654321123456789`).then(resp=>{ + if (resp.statusText != 'bharatavarshe') { + console.log("WARN:ToolJS:FetchWebUrlText:Dont forget to run the bundled local.tools/simpleproxy.py to enable me") + return + } else { + console.log("INFO:ToolJS:FetchWebUrlText:Enabling...") + } + tcs["fetch_web_url_text"] = { + "handler": fetchweburltext_run, + "meta": fetchweburltext_meta, + "result": "" + }; + }).catch(err=>console.log(`WARN:ToolJS:FetchWebUrlText:ProxyServer missing?:${err}\nDont forget to run the bundled local.tools/simpleproxy.py`)) +} + + +/** + * @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 + await fetchweburlraw_setup(tc_switch) + await fetchweburltext_setup(tc_switch) +} diff --git a/tools/server/public_simplechat/tools.mjs b/tools/server/public_simplechat/tools.mjs new file mode 100644 index 0000000000000..2b4237258e332 --- /dev/null +++ b/tools/server/public_simplechat/tools.mjs @@ -0,0 +1,68 @@ +//@ts-check +// DANGER DANGER DANGER - Simple and Stupid - Use from a discardable VM only +// Helpers to handle tools/functions calling in a direct and dangerous way +// by Humans for All +// + + +import * as tjs from './tooljs.mjs' + + +let gToolsWorker = new Worker('./toolsworker.mjs', { type: 'module' }); +/** + * @type {Object>} + */ +export let tc_switch = {} + +export async function init() { + return tjs.init(gToolsWorker).then(()=>{ + let toolNames = [] + for (const key in tjs.tc_switch) { + tc_switch[key] = tjs.tc_switch[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 {(id: string, name: string, data: string) => void} cb + */ +export function setup(cb) { + gToolsWorker.onmessage = function (ev) { + cb(ev.data.id, 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} toolcallid + * @param {string} toolname + * @param {string} toolargs + */ +export async function tool_call(toolcallid, toolname, toolargs) { + for (const fn in tc_switch) { + if (fn == toolname) { + try { + tc_switch[fn]["handler"](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..b85b83b33b327 --- /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({ id: ev.data.id, name: ev.data.name, data: tconsole.gConsoleStr}) + console.info("DBUG:WW:OnMessage done") +} 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); +}