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:
172
agent/src/chat/mod.rs
Normal file
172
agent/src/chat/mod.rs
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
//! Chat window for the agent
|
||||||
|
//!
|
||||||
|
//! Provides a simple chat interface for communication between
|
||||||
|
//! the technician and the end user.
|
||||||
|
|
||||||
|
use std::sync::mpsc::{self, Receiver, Sender};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::thread;
|
||||||
|
use tracing::{info, warn, error};
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
use windows::Win32::UI::WindowsAndMessaging::*;
|
||||||
|
#[cfg(windows)]
|
||||||
|
use windows::Win32::Foundation::*;
|
||||||
|
#[cfg(windows)]
|
||||||
|
use windows::Win32::Graphics::Gdi::*;
|
||||||
|
#[cfg(windows)]
|
||||||
|
use windows::Win32::System::LibraryLoader::GetModuleHandleW;
|
||||||
|
#[cfg(windows)]
|
||||||
|
use windows::core::PCWSTR;
|
||||||
|
|
||||||
|
/// A chat message
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ChatMessage {
|
||||||
|
pub id: String,
|
||||||
|
pub sender: String,
|
||||||
|
pub content: String,
|
||||||
|
pub timestamp: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Commands that can be sent to the chat window
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum ChatCommand {
|
||||||
|
Show,
|
||||||
|
Hide,
|
||||||
|
AddMessage(ChatMessage),
|
||||||
|
Close,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Controller for the chat window
|
||||||
|
pub struct ChatController {
|
||||||
|
command_tx: Sender<ChatCommand>,
|
||||||
|
message_rx: Arc<Mutex<Receiver<ChatMessage>>>,
|
||||||
|
_handle: thread::JoinHandle<()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ChatController {
|
||||||
|
/// Create a new chat controller (spawns chat window thread)
|
||||||
|
#[cfg(windows)]
|
||||||
|
pub fn new() -> Option<Self> {
|
||||||
|
let (command_tx, command_rx) = mpsc::channel::<ChatCommand>();
|
||||||
|
let (message_tx, message_rx) = mpsc::channel::<ChatMessage>();
|
||||||
|
|
||||||
|
let handle = thread::spawn(move || {
|
||||||
|
run_chat_window(command_rx, message_tx);
|
||||||
|
});
|
||||||
|
|
||||||
|
Some(Self {
|
||||||
|
command_tx,
|
||||||
|
message_rx: Arc::new(Mutex::new(message_rx)),
|
||||||
|
_handle: handle,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
pub fn new() -> Option<Self> {
|
||||||
|
warn!("Chat window not supported on this platform");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Show the chat window
|
||||||
|
pub fn show(&self) {
|
||||||
|
let _ = self.command_tx.send(ChatCommand::Show);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hide the chat window
|
||||||
|
pub fn hide(&self) {
|
||||||
|
let _ = self.command_tx.send(ChatCommand::Hide);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a message to the chat window
|
||||||
|
pub fn add_message(&self, msg: ChatMessage) {
|
||||||
|
let _ = self.command_tx.send(ChatCommand::AddMessage(msg));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check for outgoing messages from the user
|
||||||
|
pub fn poll_outgoing(&self) -> Option<ChatMessage> {
|
||||||
|
if let Ok(rx) = self.message_rx.lock() {
|
||||||
|
rx.try_recv().ok()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Close the chat window
|
||||||
|
pub fn close(&self) {
|
||||||
|
let _ = self.command_tx.send(ChatCommand::Close);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn run_chat_window(command_rx: Receiver<ChatCommand>, message_tx: Sender<ChatMessage>) {
|
||||||
|
use std::ffi::OsStr;
|
||||||
|
use std::os::windows::ffi::OsStrExt;
|
||||||
|
|
||||||
|
info!("Starting chat window thread");
|
||||||
|
|
||||||
|
// For now, we'll use a simple message box approach
|
||||||
|
// A full implementation would create a proper window with a text input
|
||||||
|
|
||||||
|
// Process commands
|
||||||
|
loop {
|
||||||
|
match command_rx.recv() {
|
||||||
|
Ok(ChatCommand::Show) => {
|
||||||
|
info!("Chat window: Show requested");
|
||||||
|
// Show a simple notification that chat is available
|
||||||
|
}
|
||||||
|
Ok(ChatCommand::Hide) => {
|
||||||
|
info!("Chat window: Hide requested");
|
||||||
|
}
|
||||||
|
Ok(ChatCommand::AddMessage(msg)) => {
|
||||||
|
info!("Chat message received: {} - {}", msg.sender, msg.content);
|
||||||
|
|
||||||
|
// Show the message to the user via a message box (simple implementation)
|
||||||
|
let title = format!("Message from {}", msg.sender);
|
||||||
|
let content = msg.content.clone();
|
||||||
|
|
||||||
|
// Spawn a thread to show the message box (non-blocking)
|
||||||
|
thread::spawn(move || {
|
||||||
|
show_message_box_internal(&title, &content);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(ChatCommand::Close) => {
|
||||||
|
info!("Chat window: Close requested");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
// Channel closed
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn show_message_box_internal(title: &str, message: &str) {
|
||||||
|
use std::ffi::OsStr;
|
||||||
|
use std::os::windows::ffi::OsStrExt;
|
||||||
|
|
||||||
|
let title_wide: Vec<u16> = OsStr::new(title)
|
||||||
|
.encode_wide()
|
||||||
|
.chain(std::iter::once(0))
|
||||||
|
.collect();
|
||||||
|
let message_wide: Vec<u16> = OsStr::new(message)
|
||||||
|
.encode_wide()
|
||||||
|
.chain(std::iter::once(0))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
MessageBoxW(
|
||||||
|
None,
|
||||||
|
PCWSTR(message_wide.as_ptr()),
|
||||||
|
PCWSTR(title_wide.as_ptr()),
|
||||||
|
MB_OK | MB_ICONINFORMATION | MB_TOPMOST | MB_SETFOREGROUND,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
fn run_chat_window(_command_rx: Receiver<ChatCommand>, _message_tx: Sender<ChatMessage>) {
|
||||||
|
// No-op on non-Windows
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
//! for a one-time support session.
|
//! for a one-time support session.
|
||||||
|
|
||||||
mod capture;
|
mod capture;
|
||||||
|
mod chat;
|
||||||
mod config;
|
mod config;
|
||||||
mod encoder;
|
mod encoder;
|
||||||
mod input;
|
mod input;
|
||||||
@@ -198,6 +199,12 @@ async fn run_agent(config: config::Config) -> Result<()> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Create chat controller
|
||||||
|
let chat_ctrl = chat::ChatController::new();
|
||||||
|
if chat_ctrl.is_some() {
|
||||||
|
info!("Chat controller created");
|
||||||
|
}
|
||||||
|
|
||||||
// Connect to server and run main loop
|
// Connect to server and run main loop
|
||||||
loop {
|
loop {
|
||||||
info!("Connecting to server...");
|
info!("Connecting to server...");
|
||||||
@@ -219,8 +226,8 @@ async fn run_agent(config: config::Config) -> Result<()> {
|
|||||||
t.update_status("Status: Connected");
|
t.update_status("Status: Connected");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run session until disconnect, passing tray for event processing
|
// Run session until disconnect, passing tray and chat for event processing
|
||||||
if let Err(e) = session.run_with_tray(tray.as_ref()).await {
|
if let Err(e) = session.run_with_tray(tray.as_ref(), chat_ctrl.as_ref()).await {
|
||||||
let error_msg = e.to_string();
|
let error_msg = e.to_string();
|
||||||
|
|
||||||
// Check if this is a user-initiated exit
|
// Check if this is a user-initiated exit
|
||||||
|
|||||||
@@ -7,10 +7,11 @@
|
|||||||
//! - Input event handling
|
//! - Input event handling
|
||||||
|
|
||||||
use crate::capture::{self, Capturer, Display};
|
use crate::capture::{self, Capturer, Display};
|
||||||
|
use crate::chat::{ChatController, ChatMessage as ChatMsg};
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
use crate::encoder::{self, Encoder};
|
use crate::encoder::{self, Encoder};
|
||||||
use crate::input::InputController;
|
use crate::input::InputController;
|
||||||
use crate::proto::{Message, message};
|
use crate::proto::{Message, message, ChatMessage};
|
||||||
use crate::transport::WebSocketTransport;
|
use crate::transport::WebSocketTransport;
|
||||||
use crate::tray::{TrayController, TrayAction};
|
use crate::tray::{TrayController, TrayAction};
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
@@ -148,8 +149,8 @@ impl SessionManager {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run the session main loop with tray event processing
|
/// Run the session main loop with tray and chat event processing
|
||||||
pub async fn run_with_tray(&mut self, tray: Option<&TrayController>) -> Result<()> {
|
pub async fn run_with_tray(&mut self, tray: Option<&TrayController>, chat: Option<&ChatController>) -> Result<()> {
|
||||||
if self.transport.is_none() {
|
if self.transport.is_none() {
|
||||||
anyhow::bail!("Not connected");
|
anyhow::bail!("Not connected");
|
||||||
}
|
}
|
||||||
@@ -215,9 +216,38 @@ impl SessionManager {
|
|||||||
};
|
};
|
||||||
|
|
||||||
for msg in messages {
|
for msg in messages {
|
||||||
|
// Handle chat messages specially
|
||||||
|
if let Some(message::Payload::ChatMessage(chat_msg)) = &msg.payload {
|
||||||
|
if let Some(c) = chat {
|
||||||
|
c.add_message(ChatMsg {
|
||||||
|
id: chat_msg.id.clone(),
|
||||||
|
sender: chat_msg.sender.clone(),
|
||||||
|
content: chat_msg.content.clone(),
|
||||||
|
timestamp: chat_msg.timestamp,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
continue; // Don't pass to handle_message
|
||||||
|
}
|
||||||
self.handle_message(&mut input, msg)?;
|
self.handle_message(&mut input, msg)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check for outgoing chat messages from user
|
||||||
|
if let Some(c) = chat {
|
||||||
|
if let Some(outgoing) = c.poll_outgoing() {
|
||||||
|
let chat_proto = ChatMessage {
|
||||||
|
id: outgoing.id,
|
||||||
|
sender: "client".to_string(),
|
||||||
|
content: outgoing.content,
|
||||||
|
timestamp: outgoing.timestamp,
|
||||||
|
};
|
||||||
|
let msg = Message {
|
||||||
|
payload: Some(message::Payload::ChatMessage(chat_proto)),
|
||||||
|
};
|
||||||
|
let transport = self.transport.as_mut().unwrap();
|
||||||
|
transport.send(msg).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Capture and send frame if interval elapsed
|
// Capture and send frame if interval elapsed
|
||||||
if last_frame_time.elapsed() >= frame_interval {
|
if last_frame_time.elapsed() >= frame_interval {
|
||||||
last_frame_time = Instant::now();
|
last_frame_time = Instant::now();
|
||||||
|
|||||||
@@ -229,6 +229,17 @@ message LatencyReport {
|
|||||||
int32 bitrate_kbps = 3;
|
int32 bitrate_kbps = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Chat Messages
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
message ChatMessage {
|
||||||
|
string id = 1; // Unique message ID
|
||||||
|
string sender = 2; // "technician" or "client"
|
||||||
|
string content = 3; // Message text
|
||||||
|
int64 timestamp = 4; // Unix timestamp
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Control Messages
|
// Control Messages
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -282,5 +293,8 @@ message Message {
|
|||||||
Heartbeat heartbeat = 50;
|
Heartbeat heartbeat = 50;
|
||||||
HeartbeatAck heartbeat_ack = 51;
|
HeartbeatAck heartbeat_ack = 51;
|
||||||
Disconnect disconnect = 52;
|
Disconnect disconnect = 52;
|
||||||
|
|
||||||
|
// Chat
|
||||||
|
ChatMessage chat_message = 60;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -161,10 +161,18 @@ async fn handle_agent_connection(
|
|||||||
// Try to decode as protobuf message
|
// Try to decode as protobuf message
|
||||||
match proto::Message::decode(data.as_ref()) {
|
match proto::Message::decode(data.as_ref()) {
|
||||||
Ok(proto_msg) => {
|
Ok(proto_msg) => {
|
||||||
if let Some(proto::message::Payload::VideoFrame(_)) = &proto_msg.payload {
|
match &proto_msg.payload {
|
||||||
|
Some(proto::message::Payload::VideoFrame(_)) => {
|
||||||
// Broadcast frame to all viewers
|
// Broadcast frame to all viewers
|
||||||
let _ = frame_tx.send(data.to_vec());
|
let _ = frame_tx.send(data.to_vec());
|
||||||
}
|
}
|
||||||
|
Some(proto::message::Payload::ChatMessage(chat)) => {
|
||||||
|
// Broadcast chat message to all viewers
|
||||||
|
info!("Chat from client: {}", chat.content);
|
||||||
|
let _ = frame_tx.send(data.to_vec());
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!("Failed to decode agent message: {}", e);
|
warn!("Failed to decode agent message: {}", e);
|
||||||
@@ -255,6 +263,11 @@ async fn handle_viewer_connection(
|
|||||||
// Forward input to agent
|
// Forward input to agent
|
||||||
let _ = input_tx.send(data.to_vec()).await;
|
let _ = input_tx.send(data.to_vec()).await;
|
||||||
}
|
}
|
||||||
|
Some(proto::message::Payload::ChatMessage(chat)) => {
|
||||||
|
// Forward chat message to agent
|
||||||
|
info!("Chat from technician: {}", chat.content);
|
||||||
|
let _ = input_tx.send(data.to_vec()).await;
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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-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-label { color: hsl(var(--muted-foreground)); }
|
||||||
.detail-value { color: hsl(var(--foreground)); text-align: right; }
|
.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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -388,7 +512,29 @@
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</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">×</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>
|
<script>
|
||||||
|
// Chat state
|
||||||
|
let chatSocket = null;
|
||||||
|
let chatSessionId = null;
|
||||||
|
let chatMessages = [];
|
||||||
|
|
||||||
// Tab switching
|
// Tab switching
|
||||||
document.querySelectorAll(".tab").forEach(tab => {
|
document.querySelectorAll(".tab").forEach(tab => {
|
||||||
tab.addEventListener("click", () => {
|
tab.addEventListener("click", () => {
|
||||||
@@ -456,7 +602,9 @@
|
|||||||
? '<div style="font-size: 12px; color: hsl(var(--muted-foreground));">' + code.client_name + '</div>'
|
? '<div style="font-size: 12px; color: hsl(var(--muted-foreground));">' + code.client_name + '</div>'
|
||||||
: '';
|
: '';
|
||||||
const actionBtn = code.status === "connected"
|
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>';
|
: '<button class="btn btn-outline" onclick="cancelCode(\'' + code.code + '\')">Cancel</button>';
|
||||||
return '<tr>' +
|
return '<tr>' +
|
||||||
'<td><strong>' + code.code + '</strong>' + clientInfo + '</td>' +
|
'<td><strong>' + code.code + '</strong>' + clientInfo + '</td>' +
|
||||||
@@ -573,6 +721,307 @@
|
|||||||
// Refresh machines every 5 seconds
|
// Refresh machines every 5 seconds
|
||||||
loadMachines();
|
loadMachines();
|
||||||
setInterval(loadMachines, 5000);
|
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>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user