Merge pull request #7 from russellballestrini/user-list

create grid column for room user list
This commit is contained in:
Russell 2024-11-23 12:18:17 -05:00 committed by GitHub
commit c937c7b2c3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 346 additions and 66 deletions

115
app.py
View file

@ -25,7 +25,7 @@ from flask import (
Response,
)
from flask_socketio import SocketIO, emit, join_room
from flask_socketio import SocketIO, emit, join_room, leave_room
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy.exc import InvalidRequestError
@ -170,6 +170,29 @@ class Room(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(128), nullable=False, unique=True)
title = db.Column(db.String(128), nullable=True)
# Store as a comma-separated string
active_users = db.Column(db.Text, default="")
def add_user(self, username):
users = set(self.active_users.split(",")) if self.active_users else set()
users.add(username)
self.active_users = ",".join(sorted(users))
def remove_user(self, username):
users = set(self.active_users.split(",")) if self.active_users else set()
users.discard(username)
self.active_users = ",".join(sorted(users))
def get_active_users(self):
return self.active_users.split(",") if self.active_users else []
class UserSession(db.Model):
id = db.Column(db.Integer, primary_key=True)
session_id = db.Column(db.String(128), unique=True, nullable=False)
username = db.Column(db.String(128))
room_name = db.Column(db.String(128))
room_id = db.Column(db.Integer)
class Message(db.Model):
@ -427,11 +450,31 @@ def search_messages(keywords):
return search_results_list
# Handle user joining a room
@socketio.on("join")
def on_join(data):
room_name = data["room_name"]
username = data["username"]
room = get_room(room_name)
room.add_user(username)
# Store session data in the database
user_session = UserSession(
session_id=request.sid, username=username, room_name=room_name, room_id=room.id
)
db.session.add(user_session)
# Emit the active users list to the new joiner
emit("active_users", {"users": room.get_active_users()}, room=request.sid)
# Emit the active users list to everyone in the room
emit(
"active_users",
{"users": room.get_active_users()},
room=room_name,
include_self=False,
)
# this makes the client start listening for new events for this room.
join_room(room_name)
@ -446,9 +489,6 @@ def on_join(data):
total_token_count = 0
# Send the history of messages only to the newly connected client.
# The reason for using `request.sid` here is to target the specific session (or client) that
# just connected, so only they receive the backlog of messages, rather than broadcasting
# this information to all clients in the room.
for message in previous_messages:
if not message.is_base64_image():
total_token_count += message.token_count
@ -466,17 +506,18 @@ def on_join(data):
if room.title is None and message_count >= 6:
room.title = gpt_generate_room_title(previous_messages)
db.session.add(room)
db.session.commit()
socketio.emit("update_room_title", {"title": room.title}, room=room.name)
# Emit an event to update this rooms title in the sidebar for all users.
# Emit an event to update this room's title in the sidebar for all users.
updated_room_data = {"id": room.id, "name": room.name, "title": room.title}
socketio.emit("update_room_list", updated_room_data, room=None)
# commit session & active user list and title to database.
db.session.commit()
# Broadcast to all clients in the room that a new user has joined.
# Here, `room=room` ensures the message is sent to everyone in that specific room.
emit(
"chat_message",
{"id": None, "content": f"{data['username']} has joined the room."},
{"id": None, "content": f"{username} has joined the room."},
room=room.name,
)
emit(
@ -489,6 +530,24 @@ def on_join(data):
)
# Handle user leaving a room
@socketio.on("disconnect")
def on_disconnect():
sid = request.sid
user_session = UserSession.query.filter_by(session_id=sid).first()
if user_session:
room_name = user_session.room_name
username = user_session.username
room = Room.query.filter_by(name=room_name).first()
room.remove_user(username)
leave_room(room_name)
emit("active_users", {"users": room.get_active_users()}, room=room_name)
# Remove session data from the database
db.session.delete(user_session)
db.session.commit()
@socketio.on("chat_message")
def handle_message(data):
room_name = data["room_name"]
@ -1017,6 +1076,12 @@ def chat_claude(
db.session.add(new_message)
db.session.commit()
socketio.emit(
"message_chunk",
{"id": msg_id, "content": "", "is_complete": True},
room=room.name,
)
socketio.emit("delete_processing_message", msg_id, room=room.name)
@ -1037,7 +1102,9 @@ def get_openai_client_and_model(model_name="NousResearch/Hermes-3-Llama-3.1-8B")
if is_vllm_model:
openai_client = OpenAI(base_url=vllm_endpoint, api_key=vllm_api_key)
elif is_ollama_model:
openai_client = OpenAI(base_url="http://127.0.0.1:11434/v1", api_key=vllm_api_key)
openai_client = OpenAI(
base_url="http://127.0.0.1:11434/v1", api_key=vllm_api_key
)
elif is_xai_model:
openai_client = OpenAI(base_url="https://api.x.ai/v1", api_key=xai_api_key)
elif is_google_model:
@ -1163,6 +1230,12 @@ def chat_gpt(username, room_name, model_name="gpt-4o-mini"):
db.session.add(new_message)
db.session.commit()
socketio.emit(
"message_chunk",
{"id": msg_id, "content": "", "is_complete": True},
room=room.name,
)
socketio.emit("delete_processing_message", msg_id, room=room.name)
@ -1269,6 +1342,12 @@ def chat_mistral(username, room_name, model_name="mistral-tiny"):
db.session.add(new_message)
db.session.commit()
socketio.emit(
"message_chunk",
{"id": msg_id, "content": "", "is_complete": True},
room=room.name,
)
socketio.emit("delete_processing_message", msg_id, room=room.name)
@ -1393,6 +1472,12 @@ def chat_together(
db.session.add(new_message)
db.session.commit()
socketio.emit(
"message_chunk",
{"id": msg_id, "content": "", "is_complete": True},
room=room.name,
)
socketio.emit("delete_processing_message", msg_id, room=room.name)
@ -1499,6 +1584,12 @@ def chat_groq(username, room_name, model_name="mixtral-8x7b-32768"):
db.session.add(new_message)
db.session.commit()
socketio.emit(
"message_chunk",
{"id": msg_id, "content": "", "is_complete": True},
room=room.name,
)
socketio.emit("delete_processing_message", msg_id, room=room.name)
@ -1607,6 +1698,12 @@ def chat_llama(username, room_name, model_name="mistral-7b-instruct-v0.2.Q3_K_L.
db.session.add(new_message)
db.session.commit()
socketio.emit(
"message_chunk",
{"id": msg_id, "content": "", "is_complete": True},
room=room.name,
)
socketio.emit("delete_processing_message", msg_id, room=room.name)

View file

@ -0,0 +1,32 @@
"""user session table
Revision ID: 1ac5a8e0f577
Revises: 38a330686a17
Create Date: 2024-11-23 11:25:01.723169
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import sqlite
# revision identifiers, used by Alembic.
revision = '1ac5a8e0f577'
down_revision = '38a330686a17'
branch_labels = None
depends_on = None
def upgrade():
op.create_table('user_session',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('session_id', sa.String(length=128), nullable=False),
sa.Column('username', sa.String(length=128), nullable=True),
sa.Column('room_name', sa.String(length=128), nullable=True),
sa.Column('room_id', sa.Integer(), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('session_id')
)
def downgrade():
op.drop_table('user_session')

View file

@ -0,0 +1,25 @@
"""room active users
Revision ID: 38a330686a17
Revises: d737de68d6fa
Create Date: 2024-11-23 09:52:50.824162
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import sqlite
# revision identifiers, used by Alembic.
revision = '38a330686a17'
down_revision = 'd737de68d6fa'
branch_labels = None
depends_on = None
def upgrade():
with op.batch_alter_table('room', schema=None) as batch_op:
batch_op.add_column(sa.Column('active_users', sa.Text(), nullable=True))
def downgrade():
with op.batch_alter_table('room', schema=None) as batch_op:
batch_op.drop_column('active_users')

View file

@ -62,7 +62,7 @@
overflow-y: auto;
border: 1px solid #e1e1e1;
border-radius: 5px;
padding: 10px;
padding-left: 10px;
margin-bottom: 10px;
width: 100%; /* Allow chat window to fill available space */
}
@ -112,7 +112,7 @@
/* Styling for the main container that holds the rooms list and chat */
.main-container {
display: grid;
grid-template-columns: 20% 80%; /* Adjust the 20% as needed */
grid-template-columns: 15% 70% 15%;
width: 100%;
height: 90vh;
}
@ -174,16 +174,7 @@
padding-right: 0.8em; /* Adjust the padding as needed */
}
.download-links {
position: absolute;
right: 10px;
top: 10px;
z-index: 1000;
display: flex;
flex-direction: column;
}
.download-links a {
margin-bottom: 5px;
text-align: center;
}
/* Hamburger button styling */
@ -249,6 +240,10 @@
background-color: #555; /* Darken on hover */
}
.utility-belt {
padding: 10px;
}
/* Media query for mobile devices */
@media (max-width: 768px) {
.main-container {
@ -295,7 +290,7 @@
<ul id="rooms-list-ul">
<!-- Loop through rooms and create list items for each room -->
{% for room in rooms %}
<a href="{{ url_for('chat', room_name=room.name) }}?username={{ username }}">
<a href="{{ url_for('chat', room_name=room.name) }}?{{ request.query_string.decode('utf-8')|safe }}">
<li data-room-id="{{ room.id }}">
<!-- Display the room title if available, otherwise the room name -->
<b>{{ room.name }}</b> {% if room.title %}<br />{{ room.title }} {% endif %}

View file

@ -3,11 +3,6 @@
{% block title %}Chatroom{% endblock %}
{% block content %}
<div class="download-links">
<div>
Chat History: <a href="/download_chat_history?room_name={{ room_name }}" download="{{ room_name }}.json">JSON</a> or <a href="/download_chat_history_md?room_name={{ room_name }}" download="{{ room_name }}.md">Markdown</a>
</div>
</div>
<div id="chat-container">
<!-- Chat area where messages will be displayed -->
@ -18,6 +13,50 @@
</form>
</div>
<div class="utility-belt">
<div class="download-links">
History
<a href="/download_chat_history?room_name={{ room_name }}" download="{{ room_name }}.json">JSON</a> or
<a href="/download_chat_history_md?room_name={{ room_name }}" download="{{ room_name }}.md">Markdown</a>
</div>
<br>
<div>
<label for="model-select">Model</label>
<select id="model-select">
<option value="None">None</option>
<option value="gemini-flash">gemini-flash</option>
<option value="gemini-flash-8b">gemini-flash-8b</option>
<option value="gemini-pro">gemini-pro</option>
<option value="grok-beta">grok-beta</option>
<option value="vllm/hermes-llama-3">vllm/hermes-llama-3</option>
<option value="gpt-4">gpt-4</option>
<option value="gpt-4o-2024-08-06">gpt-4o-2024-08-06</option>
<option value="gpt-mini">gpt-mini</option>
<option value="gpt-o1-mini">gpt-o1-mini</option>
<option value="claude-haiku">claude-haiku</option>
<option value="claude-sonnet">claude-sonnet</option>
<option value="claude-opus">claude-opus</option>
</select>
</div>
<div>
<label for="voice-select">Voice</label>
<select id="voice-select">
<option value="onyx">Onyx</option>
<option value="alloy">Alloy</option>
<option value="echo">Echo</option>
<option value="fable">Fable</option>
<option value="nova">Nova</option>
<option value="shimmer">Shimmer</option>
</select>
</div>
<div>
<h3>Active Users</h3>
<ul id="active-users">
<!-- Active users will be dynamically populated here -->
</ul>
</div>
</div>
<script>
// Constants
const API_KEY = "dummy-api-key";
@ -26,6 +65,14 @@ const urlParams = new URLSearchParams(window.location.search);
const username = urlParams.get("username");
const room_name = "{{ room_name }}";
// Global constants for valid voices and models
const VALID_VOICES = ['alloy', 'echo', 'fable', 'onyx', 'nova', 'shimmer'];
const VALID_MODELS = [
'None', 'gemini-flash', 'gemini-flash-8b', 'gemini-pro', 'grok-beta',
'vllm/hermes-llama-3', 'gpt-4', 'gpt-4o-2024-08-06', 'gpt-mini',
'gpt-o1-mini', 'claude-haiku', 'claude-sonnet', 'claude-opus'
];
// Configuration for DOMPurify to specify which tags and attributes are allowed
const dompurify_config = {
ADD_TAGS: ["iframe", "img"],
@ -48,13 +95,73 @@ document.addEventListener('DOMContentLoaded', (event) => {
const distanceFromBottom = chatContainer.scrollHeight - chatContainer.scrollTop - chatContainer.clientHeight;
userHasScrolledUp = distanceFromBottom > 5;
});
// Set initial model and voice from query string
const modelSelect = document.getElementById("model-select");
const voiceSelect = document.getElementById("voice-select");
const initialModel = urlParams.get("model") || "None";
const initialVoice = urlParams.get("voice") || "onyx";
modelSelect.value = initialModel;
voiceSelect.value = initialVoice;
// Update query string when model or voice changes
modelSelect.addEventListener("change", updateQueryString);
voiceSelect.addEventListener("change", updateQueryString);
});
// Function to validate and update the query string
function updateQueryString() {
const sanitizedUsername = sanitizeUsername(username);
const modelSelect = document.getElementById("model-select");
const voiceSelect = document.getElementById("voice-select");
// Validate model and voice
const model = VALID_MODELS.includes(modelSelect.value) ? modelSelect.value : 'None';
const voice = VALID_VOICES.includes(voiceSelect.value) ? voiceSelect.value : 'onyx';
const newUrl = new URL(window.location.href);
newUrl.searchParams.set("username", sanitizedUsername);
newUrl.searchParams.set("model", model);
newUrl.searchParams.set("voice", voice);
window.history.replaceState({}, '', newUrl);
}
// Object to keep track of active users
let activeUsers = {};
// Function to update the active user list in the DOM
function updateActiveUserList(users) {
const userListElement = document.getElementById("active-users");
userListElement.innerHTML = ''; // Clear the current list
// Populate the list with active users
users.forEach(username => {
const userItem = document.createElement("li");
userItem.textContent = username;
userListElement.appendChild(userItem);
});
}
// Update active users list whenever the event is received
socket.on("active_users", (data) => {
updateActiveUserList(data.users);
});
// Function to handle sending the message
function sendMessage() {
const message = document.getElementById("message").value;
if (message.trim() !== "") { // Ensure we're not sending empty messages
socket.emit("chat_message", {"username": username, "message": message, "room_name": room_name});
const model = document.getElementById("model-select").value;
let messageToSend = message.trim();
if (model !== "None") {
messageToSend = `${model} ${messageToSend}`;
}
if (messageToSend !== "") { // Ensure we're not sending empty messages
socket.emit("chat_message", {"username": username, "message": messageToSend, "room_name": room_name});
document.getElementById("message").value = "";
}
}
@ -94,17 +201,34 @@ socket.on('update_room_list', function(updatedRoom) {
}
});
// Function to sanitize the username
function sanitizeUsername(username) {
// Split the username on commas and take the first part.
// The backend denormalizes the user list in the room table via csv.
return username.split(',')[0].trim();
}
// Socket event when the user connects
socket.on("connect", () => {
socket.emit("join", {"username": username, "room_name": room_name});
// Sanitize the username before joining
const sanitizedUsername = sanitizeUsername(username);
socket.emit("join", {"username": sanitizedUsername, "room_name": room_name});
// Update the query string with the sanitized username
updateQueryString();
});
// Function to read text using TTS
async function speakText(text, playButton, messageId, voice = 'onyx', rate = 0.9) {
async function speakText(text, playButton, messageId) {
const voice = document.getElementById("voice-select").value;
const cacheKey = `${messageId}-${voice}`; // Unique cache key for each message and voice
// Clean the text to include only alphanumeric characters, spaces, and key punctuation
const cleanText = text.replace(/[^a-zA-Z0-9\s.,!?]/g, '');
try {
// Check if the audio is already cached
if (audioCache[messageId]) {
const audio = audioCache[messageId];
if (audioCache[cacheKey]) {
const audio = audioCache[cacheKey];
toggleAudioPlayback(audio, playButton);
return;
}
@ -122,7 +246,7 @@ async function speakText(text, playButton, messageId, voice = 'onyx', rate = 0.9
body: JSON.stringify({
model: 'tts-1',
voice: voice,
input: text
input: cleanText // Use the cleaned text
})
});
@ -133,10 +257,10 @@ async function speakText(text, playButton, messageId, voice = 'onyx', rate = 0.9
const audioBlob = await response.blob();
const audioUrl = URL.createObjectURL(audioBlob);
const audio = new Audio(audioUrl);
audio.playbackRate = rate;
audio.playbackRate = 0.9;
// Cache the audio
audioCache[messageId] = audio;
// Cache the audio only after it is successfully created
audioCache[cacheKey] = audio;
// Enable button and change text to "Pause"
playButton.disabled = false;
@ -327,44 +451,20 @@ socket.on("message_chunk", (data) => {
let messageWrapper = document.getElementById(wrapperId);
let targetMessageElement;
// If the message wrapper doesn't exist, it's an initial chunk
// If the message wrapper doesn't exist, create it
if (!messageWrapper) {
messageWrapper = document.createElement("div");
messageWrapper.className = "message-wrapper";
messageWrapper.id = wrapperId;
document.getElementById("chat").appendChild(messageWrapper);
}
// Create the div element to hold the message content
// If the message-content div doesn't exist, create it
if (!messageWrapper.querySelector(".message-content")) {
targetMessageElement = document.createElement("div");
targetMessageElement.className = "message-content";
// Create a container for the buttons
const buttonContainer = document.createElement("div");
buttonContainer.className = "button-container";
// Create the "x" button for deletion
const deleteButton = document.createElement("button");
deleteButton.innerHTML = "x";
deleteButton.onclick = () => deleteMessage(data.id, room_name);
buttonContainer.appendChild(deleteButton);
// Create the edit button
const editButton = document.createElement("button");
editButton.textContent = "Edit";
editButton.className = "edit-button";
editButton.onclick = () => editMessage(data.id, targetMessageElement);
buttonContainer.appendChild(editButton);
// Create the play button for TTS
const playButton = document.createElement("button");
playButton.textContent = "Play";
playButton.onclick = () => speakText(data.content, playButton, data.id);
buttonContainer.appendChild(playButton);
messageWrapper.appendChild(buttonContainer);
messageWrapper.appendChild(targetMessageElement);
} else {
// If the wrapper already exists, get the message-content div inside it
targetMessageElement = messageWrapper.querySelector(".message-content");
}
@ -394,6 +494,38 @@ socket.on("message_chunk", (data) => {
if (!userHasScrolledUp) {
document.getElementById("chat").scrollTop = document.getElementById("chat").scrollHeight;
}
// Check if the message is complete and add buttons if they haven't been added
if (data.is_complete && !messageWrapper.querySelector(".button-container")) {
// Create a container for the buttons
const buttonContainer = document.createElement("div");
buttonContainer.className = "button-container";
// Create the delete button
const deleteButton = document.createElement("button");
deleteButton.innerHTML = "x";
deleteButton.onclick = () => deleteMessage(data.id, room_name);
buttonContainer.appendChild(deleteButton);
// Create the edit button
const editButton = document.createElement("button");
editButton.textContent = "Edit";
editButton.className = "edit-button";
editButton.onclick = () => editMessage(data.id, targetMessageElement);
buttonContainer.appendChild(editButton);
// Create the play button for TTS
const playButton = document.createElement("button");
playButton.textContent = "Play";
playButton.onclick = () => {
const fullText = targetMessageElement.textContent || targetMessageElement.innerText;
speakText(fullText, playButton, data.id);
};
buttonContainer.appendChild(playButton);
// Append the button container before the message content
messageWrapper.insertBefore(buttonContainer, targetMessageElement);
}
});
@ -405,7 +537,6 @@ socket.on("message_deleted", (data) => {
}
});
// Socket event for when a message is updated
socket.on("message_updated", (data) => {
// Find the existing message wrapper by ID