Add chat functionality between technician and client

- Add ChatMessage to protobuf definitions
- Server relays chat messages between agent and viewer
- Agent chat module shows messages via MessageBox
- Dashboard chat modal with WebSocket connection
- Simplified protobuf encoder/decoder in JavaScript

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-28 16:31:16 -07:00
parent 0dcbae69a0
commit aa03a87c7c
6 changed files with 694 additions and 9 deletions

View File

@@ -206,6 +206,130 @@
.detail-row { display: flex; justify-content: space-between; padding: 8px 0; font-size: 14px; border-bottom: 1px solid hsl(var(--border)); }
.detail-label { color: hsl(var(--muted-foreground)); }
.detail-value { color: hsl(var(--foreground)); text-align: right; }
/* Chat Modal */
.modal-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
z-index: 1000;
justify-content: center;
align-items: center;
}
.modal-overlay.active { display: flex; }
.modal {
background: hsl(var(--card));
border: 1px solid hsl(var(--border));
border-radius: 12px;
width: 90%;
max-width: 500px;
max-height: 80vh;
display: flex;
flex-direction: column;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid hsl(var(--border));
}
.modal-title { font-size: 18px; font-weight: 600; }
.modal-close {
background: transparent;
border: none;
color: hsl(var(--muted-foreground));
font-size: 24px;
cursor: pointer;
padding: 4px;
}
.modal-close:hover { color: hsl(var(--foreground)); }
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 16px 20px;
min-height: 300px;
max-height: 400px;
}
.chat-message {
margin-bottom: 12px;
padding: 10px 14px;
border-radius: 8px;
max-width: 80%;
}
.chat-message.technician {
background: hsl(var(--primary));
color: hsl(var(--primary-foreground));
margin-left: auto;
}
.chat-message.client {
background: hsl(var(--muted));
color: hsl(var(--foreground));
}
.chat-message-sender {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
opacity: 0.8;
margin-bottom: 4px;
}
.chat-message-content { font-size: 14px; line-height: 1.4; }
.chat-empty {
text-align: center;
color: hsl(var(--muted-foreground));
padding: 40px 20px;
}
.chat-input-container {
display: flex;
gap: 8px;
padding: 16px 20px;
border-top: 1px solid hsl(var(--border));
}
.chat-input {
flex: 1;
padding: 10px 14px;
font-size: 14px;
background: hsl(var(--input));
border: 1px solid hsl(var(--border));
border-radius: 6px;
color: hsl(var(--foreground));
outline: none;
}
.chat-input:focus {
border-color: hsl(var(--ring));
box-shadow: 0 0 0 3px hsla(var(--ring), 0.3);
}
.chat-send {
padding: 10px 20px;
font-size: 14px;
font-weight: 500;
background: hsl(var(--primary));
color: hsl(var(--primary-foreground));
border: none;
border-radius: 6px;
cursor: pointer;
}
.chat-send:hover { opacity: 0.9; }
.chat-send:disabled { opacity: 0.5; cursor: not-allowed; }
</style>
</head>
<body>
@@ -388,7 +512,29 @@
</div>
</main>
<!-- Chat Modal -->
<div class="modal-overlay" id="chatModal">
<div class="modal">
<div class="modal-header">
<div class="modal-title">Chat with <span id="chatClientName">Client</span></div>
<button class="modal-close" id="chatClose">&times;</button>
</div>
<div class="chat-messages" id="chatMessages">
<div class="chat-empty">No messages yet. Start the conversation!</div>
</div>
<div class="chat-input-container">
<input type="text" class="chat-input" id="chatInput" placeholder="Type a message..." />
<button class="chat-send" id="chatSend">Send</button>
</div>
</div>
</div>
<script>
// Chat state
let chatSocket = null;
let chatSessionId = null;
let chatMessages = [];
// Tab switching
document.querySelectorAll(".tab").forEach(tab => {
tab.addEventListener("click", () => {
@@ -456,7 +602,9 @@
? '<div style="font-size: 12px; color: hsl(var(--muted-foreground));">' + code.client_name + '</div>'
: '';
const actionBtn = code.status === "connected"
? '<button class="btn btn-primary" onclick="joinSession(\'' + code.session_id + '\')">Join</button>'
? '<button class="btn btn-primary" onclick="joinSession(\'' + code.session_id + '\')">Join</button>' +
'<button class="btn btn-outline" style="margin-left: 8px;" onclick="openChat(\'' + code.session_id + '\', \'' + (code.client_name || 'Client').replace(/'/g, "\\'") + '\')">Chat</button>' +
'<button class="btn btn-outline" style="margin-left: 8px;" onclick="cancelCode(\'' + code.code + '\')">End</button>'
: '<button class="btn btn-outline" onclick="cancelCode(\'' + code.code + '\')">Cancel</button>';
return '<tr>' +
'<td><strong>' + code.code + '</strong>' + clientInfo + '</td>' +
@@ -573,6 +721,307 @@
// Refresh machines every 5 seconds
loadMachines();
setInterval(loadMachines, 5000);
// ========== Chat Functions ==========
// Chat modal elements
const chatModal = document.getElementById("chatModal");
const chatMessagesEl = document.getElementById("chatMessages");
const chatInput = document.getElementById("chatInput");
const chatSend = document.getElementById("chatSend");
const chatClose = document.getElementById("chatClose");
const chatClientName = document.getElementById("chatClientName");
// Close chat modal
chatClose.addEventListener("click", () => {
closeChat();
});
// Click outside to close
chatModal.addEventListener("click", (e) => {
if (e.target === chatModal) {
closeChat();
}
});
// Send message on button click
chatSend.addEventListener("click", () => {
sendChatMessage();
});
// Send message on Enter key
chatInput.addEventListener("keypress", (e) => {
if (e.key === "Enter") {
sendChatMessage();
}
});
function openChat(sessionId, clientName) {
chatSessionId = sessionId;
chatMessages = [];
chatClientName.textContent = clientName || "Client";
renderChatMessages();
chatModal.classList.add("active");
chatInput.focus();
// Connect to viewer WebSocket
connectChatSocket(sessionId);
}
function closeChat() {
chatModal.classList.remove("active");
chatSessionId = null;
if (chatSocket) {
chatSocket.close();
chatSocket = null;
}
}
function connectChatSocket(sessionId) {
if (chatSocket) {
chatSocket.close();
}
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsUrl = `${protocol}//${window.location.host}/ws/viewer?session_id=${sessionId}`;
console.log("Connecting chat to:", wsUrl);
chatSocket = new WebSocket(wsUrl);
chatSocket.onopen = () => {
console.log("Chat WebSocket connected");
addSystemMessage("Connected to session");
};
chatSocket.onclose = () => {
console.log("Chat WebSocket closed");
addSystemMessage("Disconnected from session");
};
chatSocket.onerror = (err) => {
console.error("Chat WebSocket error:", err);
addSystemMessage("Connection error");
};
chatSocket.onmessage = async (event) => {
try {
// Messages are binary protobuf
const data = await event.data.arrayBuffer();
handleChatMessage(new Uint8Array(data));
} catch (err) {
console.error("Failed to process message:", err);
}
};
}
function handleChatMessage(data) {
// Simple protobuf parsing for ChatMessage
// The Message wrapper has payload oneof, ChatMessage is field 60
// For now, we'll try to extract chat content from the binary data
// This is a simplified approach - full protobuf.js would be better
// Look for string content in the message
// ChatMessage structure: id(1), sender(2), content(3), timestamp(4)
try {
// Try to decode as text to find message content
const text = new TextDecoder().decode(data);
// Check if this contains chat data (look for "client" or "technician" sender)
if (text.includes("client") || text.includes("technician")) {
// Parse protobuf manually (simplified)
const parsed = parseSimpleProtobuf(data);
if (parsed && parsed.content) {
addReceivedMessage(parsed.sender || "Client", parsed.content);
}
}
// Other message types (video frames, etc.) are ignored for chat
} catch (err) {
// Not a chat message, ignore
}
}
// Simplified protobuf parser for ChatMessage
function parseSimpleProtobuf(data) {
// This is a very basic parser - just extracts string fields
// For production, use protobuf.js library
let result = {};
let i = 0;
while (i < data.length) {
const tag = data[i];
const fieldNum = tag >> 3;
const wireType = tag & 0x07;
i++;
if (wireType === 2) { // Length-delimited (string)
let len = 0;
let shift = 0;
while (i < data.length && data[i] & 0x80) {
len |= (data[i] & 0x7f) << shift;
shift += 7;
i++;
}
if (i < data.length) {
len |= data[i] << shift;
i++;
}
if (i + len <= data.length) {
const strBytes = data.slice(i, i + len);
const str = new TextDecoder().decode(strBytes);
i += len;
// Map field numbers to ChatMessage fields
// Within the ChatMessage (nested in Message.payload):
// 1=id, 2=sender, 3=content
if (fieldNum === 2) result.sender = str;
else if (fieldNum === 3) result.content = str;
else if (str.length < 50) result.id = str; // id is short
}
} else if (wireType === 0) { // Varint
while (i < data.length && data[i] & 0x80) i++;
i++;
} else {
// Unknown wire type, skip
break;
}
}
return result.content ? result : null;
}
function sendChatMessage() {
const content = chatInput.value.trim();
if (!content || !chatSocket || chatSocket.readyState !== WebSocket.OPEN) {
return;
}
// Create ChatMessage protobuf
const chatMsg = encodeChatMessage({
id: generateId(),
sender: "technician",
content: content,
timestamp: Date.now()
});
chatSocket.send(chatMsg);
addSentMessage(content);
chatInput.value = "";
}
// Simple protobuf encoder for ChatMessage wrapped in Message
function encodeChatMessage(msg) {
const encoder = new TextEncoder();
// Encode ChatMessage fields
const id = encoder.encode(msg.id);
const sender = encoder.encode(msg.sender);
const content = encoder.encode(msg.content);
// ChatMessage: id(1), sender(2), content(3), timestamp(4)
const chatMsgBytes = [];
// Field 1: id (string)
chatMsgBytes.push(0x0a); // field 1, wire type 2
chatMsgBytes.push(id.length);
chatMsgBytes.push(...id);
// Field 2: sender (string)
chatMsgBytes.push(0x12); // field 2, wire type 2
chatMsgBytes.push(sender.length);
chatMsgBytes.push(...sender);
// Field 3: content (string)
chatMsgBytes.push(0x1a); // field 3, wire type 2
chatMsgBytes.push(content.length);
chatMsgBytes.push(...content);
// Field 4: timestamp (int64) - simplified varint encoding
chatMsgBytes.push(0x20); // field 4, wire type 0
let ts = msg.timestamp;
while (ts > 0x7f) {
chatMsgBytes.push((ts & 0x7f) | 0x80);
ts >>>= 7;
}
chatMsgBytes.push(ts);
// Wrap in Message with payload field 60 (ChatMessage)
const wrapper = [];
const fieldTag = (60 << 3) | 2; // field 60, wire type 2
// Encode tag as varint (60 = 0x3c, which fits in 2 bytes as varint)
wrapper.push((fieldTag & 0x7f) | 0x80);
wrapper.push(fieldTag >> 7);
// Length of ChatMessage
wrapper.push(chatMsgBytes.length);
// ChatMessage bytes
wrapper.push(...chatMsgBytes);
return new Uint8Array(wrapper);
}
function generateId() {
return 'msg_' + Math.random().toString(36).substr(2, 9);
}
function addSentMessage(content) {
chatMessages.push({
sender: "technician",
content: content,
timestamp: Date.now()
});
renderChatMessages();
}
function addReceivedMessage(sender, content) {
chatMessages.push({
sender: sender,
content: content,
timestamp: Date.now()
});
renderChatMessages();
}
function addSystemMessage(content) {
chatMessages.push({
sender: "system",
content: content,
timestamp: Date.now()
});
renderChatMessages();
}
function renderChatMessages() {
if (chatMessages.length === 0) {
chatMessagesEl.innerHTML = '<div class="chat-empty">No messages yet. Start the conversation!</div>';
return;
}
chatMessagesEl.innerHTML = chatMessages.map(msg => {
if (msg.sender === "system") {
return '<div style="text-align: center; padding: 8px; color: hsl(var(--muted-foreground)); font-size: 12px;">' + escapeHtml(msg.content) + '</div>';
}
const msgClass = msg.sender === "technician" ? "technician" : "client";
const senderName = msg.sender === "technician" ? "You" : msg.sender;
return '<div class="chat-message ' + msgClass + '">' +
'<div class="chat-message-sender">' + escapeHtml(senderName) + '</div>' +
'<div class="chat-message-content">' + escapeHtml(msg.content) + '</div>' +
'</div>';
}).join("");
// Scroll to bottom
chatMessagesEl.scrollTop = chatMessagesEl.scrollHeight;
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
</script>
</body>
</html>