diff --git a/webui/src/main/kotlin/com/simiacryptus/skyenet/servers/AppServerBase.kt b/webui/src/main/kotlin/com/simiacryptus/skyenet/servers/AppServerBase.kt index 8a52cc2d..1f2362f5 100644 --- a/webui/src/main/kotlin/com/simiacryptus/skyenet/servers/AppServerBase.kt +++ b/webui/src/main/kotlin/com/simiacryptus/skyenet/servers/AppServerBase.kt @@ -1,15 +1,16 @@ package com.simiacryptus.skyenet.servers +import com.google.api.services.oauth2.model.Userinfo import com.simiacryptus.openai.OpenAIClient import com.simiacryptus.skyenet.OutputInterceptor -import com.simiacryptus.skyenet.util.AwsUtil.decryptResource import com.simiacryptus.skyenet.servlet.AuthenticatedWebsite import com.simiacryptus.skyenet.servlet.UsageServlet -import com.simiacryptus.skyenet.sessions.ApplicationBase import com.simiacryptus.skyenet.servlet.UserInfoServlet import com.simiacryptus.skyenet.servlet.UserSettingsServlet +import com.simiacryptus.skyenet.sessions.ApplicationBase import com.simiacryptus.skyenet.sessions.WebSocketServer +import com.simiacryptus.skyenet.util.AwsUtil.decryptResource import jakarta.servlet.DispatcherType import jakarta.servlet.Servlet import jakarta.servlet.http.HttpServlet @@ -30,6 +31,7 @@ import java.util.* abstract class AppServerBase( private val localName: String = "localhost", + private val publicName: String = "localhost", private val port: Int = 8081, ) { var domainName: String = "" @@ -38,11 +40,12 @@ abstract class AppServerBase( data class ChildWebApp( val path: String, val server: WebSocketServer, - val isAuthenticated: Boolean = false + val isAuthenticated: Boolean = false, + val isPublicOnly: Boolean = false ) private fun domainName(isServer: Boolean) = - if (isServer) "https://apps.simiacrypt.us" else "http://$localName:$port" + if (isServer) "https://$publicName" else "http://$localName:$port" val welcomeResources = Resource.newResource(javaClass.classLoader.getResource("welcome")) val userInfoServlet = UserInfoServlet() @@ -103,17 +106,18 @@ abstract class AppServerBase( inner class WelcomeServlet() : HttpServlet() { override fun doGet(req: HttpServletRequest?, resp: HttpServletResponse?) { - val requestURI = req?.requestURI ?: "/" + val user = AuthenticatedWebsite.getUser(req!!) + val requestURI = req.requestURI ?: "/" resp?.contentType = when (requestURI) { "/" -> "text/html" else -> ApplicationBase.getMimeType(requestURI) } when { - requestURI == "/" -> resp?.writer?.write(homepage().trimIndent()) - requestURI == "/index.html" -> resp?.writer?.write(homepage().trimIndent()) - requestURI.startsWith("/userInfo") -> userInfoServlet.doGet(req!!, resp!!) - requestURI.startsWith("/userSettings") -> userSettingsServlet.doGet(req!!, resp!!) - requestURI.startsWith("/usage") -> usageServlet.doGet(req!!, resp!!) + requestURI == "/" -> resp?.writer?.write(homepage(user).trimIndent()) + requestURI == "/index.html" -> resp?.writer?.write(homepage(user).trimIndent()) + requestURI.startsWith("/userInfo") -> userInfoServlet.doGet(req, resp!!) + requestURI.startsWith("/userSettings") -> userSettingsServlet.doGet(req, resp!!) + requestURI.startsWith("/usage") -> usageServlet.doGet(req, resp!!) else -> try { val inputStream = welcomeResources.addPath(requestURI)?.inputStream inputStream?.copyTo(resp?.outputStream!!) @@ -132,36 +136,41 @@ abstract class AppServerBase( } } - @Language("HTML") - private fun homepage() = """ - - - - SimiaCryptus Skyenet Apps - - - - - - -
-
- -
- Login -
- -
- ${ - childWebApps.joinToString("
") { - """${it.server.applicationName}""" + private fun homepage(user: Userinfo?): String { + @Language("HTML") + val html = """ + + + + SimiaCryptus Skyenet Apps + + + + + + +
+
+ +
+ Login +
+ +
+ ${ + childWebApps.filter { + !it.isAuthenticated || (user != null && !it.isPublicOnly) + }.joinToString("
") { + """${it.server.applicationName}""" + } } +
+ + + + """ + return html } -
- - - - """ private fun start( port: Int, diff --git a/webui/src/main/kotlin/com/simiacryptus/skyenet/servers/ReadOnlyApp.kt b/webui/src/main/kotlin/com/simiacryptus/skyenet/servers/ReadOnlyApp.kt index 415da5e4..09898679 100644 --- a/webui/src/main/kotlin/com/simiacryptus/skyenet/servers/ReadOnlyApp.kt +++ b/webui/src/main/kotlin/com/simiacryptus/skyenet/servers/ReadOnlyApp.kt @@ -1,6 +1,5 @@ package com.simiacryptus.skyenet.servers -import com.simiacryptus.openai.OpenAIClient import com.simiacryptus.skyenet.sessions.* import org.slf4j.LoggerFactory @@ -8,11 +7,11 @@ open class ReadOnlyApp( applicationName: String, temperature: Double = 0.3, oauthConfig: String? = null, - val api: OpenAIClient, ) : ApplicationBase( applicationName = applicationName, oauthConfig = oauthConfig, temperature = temperature, + resourceBase = "readOnly", ) { companion object { @@ -20,12 +19,7 @@ open class ReadOnlyApp( } override fun newSession(sessionId: String): SessionInterface { - return BasicChatSession( - parent = this@ReadOnlyApp, - model = OpenAIClient.Models.GPT35Turbo, - sessionId = sessionId, - api = api - ) + throw UnsupportedOperationException() } diff --git a/webui/src/main/kotlin/com/simiacryptus/skyenet/sessions/SessionDataStorage.kt b/webui/src/main/kotlin/com/simiacryptus/skyenet/sessions/SessionDataStorage.kt index 1b8fc0c2..432619a2 100644 --- a/webui/src/main/kotlin/com/simiacryptus/skyenet/sessions/SessionDataStorage.kt +++ b/webui/src/main/kotlin/com/simiacryptus/skyenet/sessions/SessionDataStorage.kt @@ -39,7 +39,7 @@ open class SessionDataStorage( val files = dataDir.listFiles()?.flatMap { it.listFiles()?.toList() ?: listOf() }?.filter { sessionDir -> val operationDir = File(sessionDir, "messages") if (!operationDir.exists()) false else { - val listFiles = operationDir.listFiles() + val listFiles = operationDir.listFiles().filter { it.isFile && !it.name.startsWith("aaa") } (listFiles?.size ?: 0) > 0 } } diff --git a/webui/src/main/resources/readOnly/chat.css b/webui/src/main/resources/readOnly/chat.css new file mode 100644 index 00000000..02cac400 --- /dev/null +++ b/webui/src/main/resources/readOnly/chat.css @@ -0,0 +1,228 @@ +#messages { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 40px; + overflow-y: auto; + padding: 10px; + padding-top: 40px; + flex-grow: 1; + flex-shrink: 1; +} + +.message { + background-color: #f0f0f0; + border-radius: 5px; + padding: 10px; + margin-bottom: 10px; + overflow: scroll; + z-index: 100; +} + +.reply-form { + width: 100%; +} + +.chat-input { + resize: vertical; +} + +.reply-input { + resize: vertical; +} + +.href-link { + text-decoration: underline; + color: blue; +} + +#message { + flex-grow: 1; + margin-right: 5px; +} + +.spinner-border { + display: block; + width: 40px; + height: 40px; + border: 4px solid rgba(0, 0, 0, 0.1); + border-left-color: #007bff; + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +#toolbar { + background-color: #f1f1f1; + padding: 5px; + position: fixed; + top: 0; + width: 100%; + horiz-align: left; + z-index: 1; +} + +#toolbar a { + color: #000; + text-decoration: none; + padding: 5px; +} + +#toolbar a:hover { + background-color: #ddd; +} + + +#namebar { + background-color: #f1f1f1; + padding: 5px; + position: fixed; + top: 0; + right: 0; + horiz-align: right; + z-index: 2; +} + +#namebar a { + color: #000; + text-decoration: none; + padding: 5px; +} + +#namebar a:hover { + background-color: #ddd; +} + +/* Modal */ +.modal { + display: none; + position: fixed; + z-index: 1000; + left: 0; + top: 0; + width: 100%; + height: 100%; + overflow: auto; + background-color: rgba(0, 0, 0, 0.4); +} + +.modal-content { + background-color: #fefefe; + margin: 15% auto; + padding: 20px; + border: 1px solid #888; + width: 80%; + position: relative; +} + +.close { + color: #aaa; + float: right; + font-size: 28px; + font-weight: bold; + cursor: pointer; +} + +.close:hover, +.close:focus { + color: #000; + text-decoration: none; + cursor: pointer; +} + +pre { + white-space: pre-wrap; /* Since CSS modules do not automatically apply this */ +} + +#container { + display: flex; + flex-direction: column; + height: 100vh; /* Adjust this value based on your preferred container height */ +} + +.play-button { + font-size: 48px; + font-weight: bold; + border: none; + background: transparent; + cursor: pointer; + transition: transform 0.1s; +} + +.play-button:focus { + outline: none; +} + +.play-button:active { + transform: scale(0.9); +} + + +.regen-button { + font-size: 48px; + font-weight: bold; + color: chartreuse; + border: none; + background: transparent; + cursor: pointer; + transition: transform 0.1s; +} + +.regen-button:focus { + outline: none; +} + +.regen-button:active { + transform: scale(0.9); +} + +.message-container { + position: relative; /* Add this line to set the position property of the parent div */ +} + +.cancel-button { + position: absolute; + top: 8px; + right: 8px; + font-size: 16px; + font-weight: bold; + border: none; + background: transparent; + cursor: pointer; + transition: transform 0.1s; +} + +.cancel-button:focus { + outline: none; +} + +.cancel-button:active { + transform: scale(0.9); +} + +.collapsible-content { + overflow: hidden; + max-height: 0; + transition: max-height 0.2s ease-out; +} diff --git a/webui/src/main/resources/readOnly/chat.js b/webui/src/main/resources/readOnly/chat.js new file mode 100644 index 00000000..f946c020 --- /dev/null +++ b/webui/src/main/resources/readOnly/chat.js @@ -0,0 +1,65 @@ + +let socket; + +function getSessionId() { + if (!window.location.hash) { + fetch('newSession') + .then(response => { + if (response.ok) { + return response.text(); + } else { + throw new Error('Failed to get new session ID'); + } + }) + .then(sessionId => { + window.location.hash = sessionId; + connect(sessionId); + }); + } else { + return window.location.hash.substring(1); + } +} + +function send(message) { + console.log('Sending message:', message); + socket.send(message); +} + +function connect(sessionId, customReceiveFunction) { + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const host = window.location.hostname; + const port = window.location.port; + let path = window.location.pathname; + let strings = path.split('/'); + if(strings.length >= 2 && strings[1] !== '' && strings[1] !== 'index.html') { + path = '/' + strings[1] + '/'; + } else { + path = '/'; + } + + socket = new WebSocket(`${protocol}//${host}:${port}${path}ws?sessionId=${sessionId}`); + + socket.addEventListener('open', (event) => { + console.log('WebSocket connected:', event); + showDisconnectedOverlay(false); + }); + + socket.addEventListener('message', customReceiveFunction || onWebSocketText); + + socket.addEventListener('close', (event) => { + console.log('WebSocket closed:', event); + showDisconnectedOverlay(true); + setTimeout(() => { + connect(getSessionId(), customReceiveFunction); + }, 3000); + }); + + socket.addEventListener('error', (event) => { + console.error('WebSocket error:', event); + }); +} + +function showDisconnectedOverlay(show) { + const overlay = document.getElementById('disconnected-overlay'); + overlay.style.display = show ? 'block' : 'none'; +} diff --git a/webui/src/main/resources/readOnly/favicon.svg b/webui/src/main/resources/readOnly/favicon.svg new file mode 100644 index 00000000..2cbd9fdb --- /dev/null +++ b/webui/src/main/resources/readOnly/favicon.svg @@ -0,0 +1,724 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/webui/src/main/resources/readOnly/index.html b/webui/src/main/resources/readOnly/index.html new file mode 100644 index 00000000..aeb8b50a --- /dev/null +++ b/webui/src/main/resources/readOnly/index.html @@ -0,0 +1,45 @@ + + + + + WebSocket Client + + + + + + + + + + +
+ Home + Session List + Session Settings + Files + Usage +
+ +
+ Login +
+ + + +
+
+
+ +
+

Disconnected. Attempting to reconnect...

+
+ + + + diff --git a/webui/src/main/resources/readOnly/main.js b/webui/src/main/resources/readOnly/main.js new file mode 100644 index 00000000..7629f48c --- /dev/null +++ b/webui/src/main/resources/readOnly/main.js @@ -0,0 +1,130 @@ +function showModal(endpoint) { + fetchData(endpoint); + document.getElementById('modal').style.display = 'block'; +} + +function closeModal() { + document.getElementById('modal').style.display = 'none'; +} + +async function fetchData(endpoint) { + try { + // Add session id to the endpoint as a path parameter + const sessionId = getSessionId(); + if (sessionId) { + endpoint = endpoint + "?sessionId=" + sessionId; + } + const response = await fetch(endpoint); + const text = await response.text(); + document.getElementById('modal-content').innerHTML = "
" + text + "
"; + Prism.highlightAll(); + } catch (error) { + console.error('Error fetching data:', error); + } +} + +let messageVersions = {}; + +function onWebSocketText(event) { + console.log('WebSocket message:', event); + const messagesDiv = document.getElementById('messages'); + + // Parse message e.g. "id,version,content" + const firstCommaIndex = event.data.indexOf(','); + const secondCommaIndex = event.data.indexOf(',', firstCommaIndex + 1); + const messageId = event.data.substring(0, firstCommaIndex); + const messageVersion = event.data.substring(firstCommaIndex + 1, secondCommaIndex); + const messageContent = event.data.substring(secondCommaIndex + 1); + // If messageVersion isn't more than the version for the messageId using the version map, then ignore the message + if (messageVersion <= (messageVersions[messageId] || 0)) { + console.log("Ignoring message with id " + messageId + " and version " + messageVersion); + return; + } else { + messageVersions[messageId] = messageVersion; + } + + let messageDiv = document.getElementById(messageId); + + if (messageDiv) { + messageDiv.innerHTML = messageContent; + } else { + messageDiv = document.createElement('div'); + messageDiv.className = 'message message-container'; // Add the message-container class + messageDiv.id = messageId; + messageDiv.innerHTML = messageContent; + messagesDiv.appendChild(messageDiv); + } + + messagesDiv.scrollTop = messagesDiv.scrollHeight; + Prism.highlightAll(); +} + +document.addEventListener('DOMContentLoaded', () => { + + document.getElementById('history').addEventListener('click', () => showModal('sessions')); + document.getElementById('settings').addEventListener('click', () => showModal('settings')); + document.querySelector('.close').addEventListener('click', closeModal); + + window.addEventListener('click', (event) => { + if (event.target === document.getElementById('modal')) { + closeModal(); + } + }); + + const messageInput = document.getElementById('message'); + const usage = document.getElementById('usage'); + + const sessionId = getSessionId(); + if (sessionId) { + connect(sessionId, onWebSocketText); + usage.href = '/usage/?sessionId=' + sessionId; + } else { + connect(undefined, onWebSocketText); + } + + document.getElementById("files").addEventListener("click", function (event) { + event.preventDefault(); // Prevent the default behavior of the anchor tag + const sessionId = getSessionId(); + const url = "fileIndex/" + sessionId + "/"; + window.open(url, "_blank"); // Open the URL in a new tab + }); + + const loginLink = document.getElementById('username'); + if (loginLink) { + loginLink.href = '/googleLogin?redirect=' + encodeURIComponent(window.location.pathname); + } + + fetch('appInfo') + .then(response => { + if (!response.ok) { + throw new Error('Network response was not ok'); + } + return response.json(); + }) + .then(data => { + if (data.applicationName) { + document.title = data.applicationName; + } + }) + .catch(error => { + console.error('There was a problem with the fetch operation:', error); + }); + + fetch('userInfo') + .then(response => { + if (!response.ok) { + throw new Error('Network response was not ok'); + } + return response.json(); + }) + .then(data => { + if (data.name && loginLink) { + loginLink.innerHTML = data.name; + loginLink.href = "/userSettings"; + } + }) + .catch(error => { + console.error('There was a problem with the fetch operation:', error); + }); +}); + diff --git a/webui/src/main/resources/welcome/chat.css b/webui/src/main/resources/welcome/chat.css index 830deeae..0be5f864 100644 --- a/webui/src/main/resources/welcome/chat.css +++ b/webui/src/main/resources/welcome/chat.css @@ -19,6 +19,11 @@ pre { height: 100vh; /* Adjust this value based on your preferred container height */ } +#applist { + top: 3em; + left: 10px; + position: absolute; +} #namebar { background-color: #f1f1f1; diff --git a/webui/src/test/kotlin/com/simiacryptus/skyenet/ActorTestAppServer.kt b/webui/src/test/kotlin/com/simiacryptus/skyenet/ActorTestAppServer.kt index be5a4083..95ca2f6f 100644 --- a/webui/src/test/kotlin/com/simiacryptus/skyenet/ActorTestAppServer.kt +++ b/webui/src/test/kotlin/com/simiacryptus/skyenet/ActorTestAppServer.kt @@ -24,7 +24,6 @@ object ActorTestAppServer : AppServerBase(port = 8082) { interface JokeParser : Function override val childWebApps by lazy { - val api = com.simiacryptus.openai.OpenAIClient() listOf( ChildWebApp("/test_simple", SimpleActorTestApp(SimpleActor("Translate the user's request into pig latin.", "PigLatin"))), ChildWebApp("/test_parsed_joke", ParsedActorTestApp(ParsedActor(JokeParser::class.java, "Tell me a joke"))),