diff --git a/Cargo.lock b/Cargo.lock index 43c34589..26b55915 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3440,7 +3440,7 @@ dependencies = [ "url", "utoipa", "uuid", - "webbrowser", + "webbrowser 0.8.15", "winapi", "wiremock", ] @@ -3475,8 +3475,10 @@ version = "1.0.24" dependencies = [ "anyhow", "async-trait", + "axum", "base64 0.22.1", "bat", + "bytes", "chrono", "clap 4.5.31", "cliclack", @@ -3486,6 +3488,7 @@ dependencies = [ "goose", "goose-bench", "goose-mcp", + "http 1.2.0", "indicatif", "mcp-client", "mcp-core", @@ -3506,9 +3509,12 @@ dependencies = [ "tempfile", "test-case", "tokio", + "tokio-stream", + "tower-http", "tracing", "tracing-appender", "tracing-subscriber", + "webbrowser 1.0.4", "winapi", ] @@ -3601,7 +3607,7 @@ dependencies = [ "url", "urlencoding", "utoipa", - "webbrowser", + "webbrowser 0.8.15", "xcap", ] @@ -3876,6 +3882,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + [[package]] name = "httparse" version = "1.10.1" @@ -5419,6 +5431,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "minijinja" version = "2.8.0" @@ -5813,6 +5835,31 @@ dependencies = [ "malloc_buf", ] +[[package]] +name = "objc2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88c6597e14493ab2e44ce58f2fdecf095a51f12ca57bec060a11c57332520551" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "900831247d2fe1a09a683278e5384cfb8c80c79fe6b166f9d14bfdde0ea1b03c" +dependencies = [ + "bitflags 2.9.0", + "objc2", +] + [[package]] name = "object" version = "0.36.7" @@ -8591,12 +8638,21 @@ checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" dependencies = [ "bitflags 2.9.0", "bytes", + "futures-util", "http 1.2.0", "http-body 1.0.1", "http-body-util", + "http-range-header", + "httpdate", + "mime", + "mime_guess", + "percent-encoding", "pin-project-lite", + "tokio", + "tokio-util", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -9249,6 +9305,23 @@ dependencies = [ "web-sys", ] +[[package]] +name = "webbrowser" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5df295f8451142f1856b1bd86a606dfe9587d439bc036e319c827700dbd555e" +dependencies = [ + "core-foundation 0.10.0", + "home", + "jni", + "log", + "ndk-context", + "objc2", + "objc2-foundation", + "url", + "web-sys", +] + [[package]] name = "webpki-roots" version = "0.26.8" diff --git a/crates/goose-cli/Cargo.toml b/crates/goose-cli/Cargo.toml index 3a73c44b..4f4294a6 100644 --- a/crates/goose-cli/Cargo.toml +++ b/crates/goose-cli/Cargo.toml @@ -55,6 +55,14 @@ regex = "1.11.1" minijinja = "2.8.0" nix = { version = "0.30.1", features = ["process", "signal"] } tar = "0.4" +# Web server dependencies +axum = { version = "0.8.1", features = ["ws", "macros"] } +tower-http = { version = "0.5", features = ["cors", "fs"] } +tokio-stream = "0.1" +bytes = "1.5" +http = "1.0" +webbrowser = "1.0" + indicatif = "0.17.11" [target.'cfg(target_os = "windows")'.dependencies] diff --git a/crates/goose-cli/WEB_INTERFACE.md b/crates/goose-cli/WEB_INTERFACE.md new file mode 100644 index 00000000..3665ef97 --- /dev/null +++ b/crates/goose-cli/WEB_INTERFACE.md @@ -0,0 +1,78 @@ +# Goose Web Interface + +The `goose web` command provides a (preview) web-based chat interface for interacting with Goose. +Do not expose this publicly - this is in a preview state as an option. + +## Usage + +```bash +# Start the web server on default port (3000) +goose web + +# Start on a specific port +goose web --port 8080 + +# Start and automatically open in browser +goose web --open + +# Bind to a specific host +goose web --host 0.0.0.0 --port 8080 +``` + +## Features + +- **Real-time chat interface**: Communicate with Goose through a clean web UI +- **WebSocket support**: Real-time message streaming +- **Session management**: Each browser tab maintains its own session +- **Responsive design**: Works on desktop and mobile devices + +## Architecture + +The web interface is built with: +- **Backend**: Rust with Axum web framework +- **Frontend**: Vanilla JavaScript with WebSocket communication +- **Styling**: CSS with dark/light mode support + +## Development Notes + +### Current Implementation + +The web interface provides: +1. A simple chat UI similar to the desktop Electron app +2. WebSocket-based real-time communication +3. Basic session management (messages are stored in memory) + +### Future Enhancements + +- [ ] Persistent session storage +- [ ] Tool call visualization +- [ ] File upload support +- [ ] Multiple session tabs +- [ ] Authentication/authorization +- [ ] Streaming responses with proper formatting +- [ ] Code syntax highlighting +- [ ] Export chat history + +### Integration with Goose Agent + +The web server creates an instance of the Goose Agent and processes messages through the same pipeline as the CLI. However, some features like: +- Extension management +- Tool confirmations +- File system interactions + +...may require additional UI components to be fully functional. + +## Security Considerations + +Currently, the web interface: +- Binds to localhost by default for security +- Does not include authentication (planned for future) +- Should not be exposed to the internet without proper security measures + +## Troubleshooting + +If you encounter issues: + +1. **Port already in use**: Try a different port with `--port` +2. **Cannot connect**: Ensure no firewall is blocking the port +3. **Agent not configured**: Run `goose configure` first to set up a provider \ No newline at end of file diff --git a/crates/goose-cli/src/cli.rs b/crates/goose-cli/src/cli.rs index 027d9656..7afc4f03 100644 --- a/crates/goose-cli/src/cli.rs +++ b/crates/goose-cli/src/cli.rs @@ -504,6 +504,31 @@ enum Command { #[command(subcommand)] cmd: BenchCommand, }, + + /// Start a web server with a chat interface + #[command(about = "Start a web server with a chat interface", hide = true)] + Web { + /// Port to run the web server on + #[arg( + short, + long, + default_value = "3000", + help = "Port to run the web server on" + )] + port: u16, + + /// Host to bind the web server to + #[arg( + long, + default_value = "127.0.0.1", + help = "Host to bind the web server to" + )] + host: String, + + /// Open browser automatically + #[arg(long, help = "Open browser automatically when server starts")] + open: bool, + }, } #[derive(clap::ValueEnum, Clone, Debug)] @@ -785,6 +810,10 @@ pub async fn cli() -> Result<()> { } return Ok(()); } + Some(Command::Web { port, host, open }) => { + crate::commands::web::handle_web(port, host, open).await?; + return Ok(()); + } None => { return if !Config::global().exists() { let _ = handle_configure().await; diff --git a/crates/goose-cli/src/commands/mod.rs b/crates/goose-cli/src/commands/mod.rs index bda22fbd..72ce9be2 100644 --- a/crates/goose-cli/src/commands/mod.rs +++ b/crates/goose-cli/src/commands/mod.rs @@ -7,3 +7,4 @@ pub mod recipe; pub mod schedule; pub mod session; pub mod update; +pub mod web; diff --git a/crates/goose-cli/src/commands/web.rs b/crates/goose-cli/src/commands/web.rs new file mode 100644 index 00000000..0ef06290 --- /dev/null +++ b/crates/goose-cli/src/commands/web.rs @@ -0,0 +1,640 @@ +use anyhow::Result; +use axum::{ + extract::{ + ws::{Message, WebSocket, WebSocketUpgrade}, + State, + }, + response::{Html, IntoResponse, Response}, + routing::get, + Json, Router, +}; +use futures::{sink::SinkExt, stream::StreamExt}; +use goose::agents::{Agent, AgentEvent}; +use goose::message::Message as GooseMessage; +use goose::session; +use serde::{Deserialize, Serialize}; +use std::{net::SocketAddr, sync::Arc}; +use tokio::sync::{Mutex, RwLock}; +use tower_http::cors::{Any, CorsLayer}; +use tracing::error; + +type SessionStore = Arc>>>>>; +type CancellationStore = Arc>>; + +#[derive(Clone)] +struct AppState { + agent: Arc, + sessions: SessionStore, + cancellations: CancellationStore, +} + +#[derive(Serialize, Deserialize)] +#[serde(tag = "type")] +enum WebSocketMessage { + #[serde(rename = "message")] + Message { + content: String, + session_id: String, + timestamp: i64, + }, + #[serde(rename = "cancel")] + Cancel { session_id: String }, + #[serde(rename = "response")] + Response { + content: String, + role: String, + timestamp: i64, + }, + #[serde(rename = "tool_request")] + ToolRequest { + id: String, + tool_name: String, + arguments: serde_json::Value, + }, + #[serde(rename = "tool_response")] + ToolResponse { + id: String, + result: serde_json::Value, + is_error: bool, + }, + #[serde(rename = "tool_confirmation")] + ToolConfirmation { + id: String, + tool_name: String, + arguments: serde_json::Value, + needs_confirmation: bool, + }, + #[serde(rename = "error")] + Error { message: String }, + #[serde(rename = "thinking")] + Thinking { message: String }, + #[serde(rename = "context_exceeded")] + ContextExceeded { message: String }, + #[serde(rename = "cancelled")] + Cancelled { message: String }, + #[serde(rename = "complete")] + Complete { message: String }, +} + +pub async fn handle_web(port: u16, host: String, open: bool) -> Result<()> { + // Setup logging + crate::logging::setup_logging(Some("goose-web"), None)?; + + // Load config and create agent just like the CLI does + let config = goose::config::Config::global(); + + let provider_name: String = match config.get_param("GOOSE_PROVIDER") { + Ok(p) => p, + Err(_) => { + eprintln!("No provider configured. Run 'goose configure' first"); + std::process::exit(1); + } + }; + + let model: String = match config.get_param("GOOSE_MODEL") { + Ok(m) => m, + Err(_) => { + eprintln!("No model configured. Run 'goose configure' first"); + std::process::exit(1); + } + }; + + let model_config = goose::model::ModelConfig::new(model.clone()); + + // Create the agent + let agent = Agent::new(); + let provider = goose::providers::create(&provider_name, model_config)?; + agent.update_provider(provider).await?; + + // Load and enable extensions from config + let extensions = goose::config::ExtensionConfigManager::get_all()?; + for ext_config in extensions { + if ext_config.enabled { + if let Err(e) = agent.add_extension(ext_config.config.clone()).await { + eprintln!( + "Warning: Failed to load extension {}: {}", + ext_config.config.name(), + e + ); + } + } + } + + let state = AppState { + agent: Arc::new(agent), + sessions: Arc::new(RwLock::new(std::collections::HashMap::new())), + cancellations: Arc::new(RwLock::new(std::collections::HashMap::new())), + }; + + // Build router + let app = Router::new() + .route("/", get(serve_index)) + .route("/session/{session_name}", get(serve_session)) + .route("/ws", get(websocket_handler)) + .route("/api/health", get(health_check)) + .route("/api/sessions", get(list_sessions)) + .route("/api/sessions/{session_id}", get(get_session)) + .route("/static/{*path}", get(serve_static)) + .layer( + CorsLayer::new() + .allow_origin(Any) + .allow_methods(Any) + .allow_headers(Any), + ) + .with_state(state); + + let addr: SocketAddr = format!("{}:{}", host, port).parse()?; + + println!("\n🪿 Starting Goose web server"); + println!(" Provider: {} | Model: {}", provider_name, model); + println!( + " Working directory: {}", + std::env::current_dir()?.display() + ); + println!(" Server: http://{}", addr); + println!(" Press Ctrl+C to stop\n"); + + if open { + // Open browser + let url = format!("http://{}", addr); + if let Err(e) = webbrowser::open(&url) { + eprintln!("Failed to open browser: {}", e); + } + } + + let listener = tokio::net::TcpListener::bind(addr).await?; + axum::serve(listener, app).await?; + + Ok(()) +} + +async fn serve_index() -> Html<&'static str> { + Html(include_str!("../../static/index.html")) +} + +async fn serve_session( + axum::extract::Path(session_name): axum::extract::Path, +) -> Html { + let html = include_str!("../../static/index.html"); + // Inject the session name into the HTML so JavaScript can use it + let html_with_session = html.replace( + "", + &format!( + "\n ", + session_name + ) + ); + Html(html_with_session) +} + +async fn serve_static(axum::extract::Path(path): axum::extract::Path) -> Response { + match path.as_str() { + "style.css" => ( + [("content-type", "text/css")], + include_str!("../../static/style.css"), + ) + .into_response(), + "script.js" => ( + [("content-type", "application/javascript")], + include_str!("../../static/script.js"), + ) + .into_response(), + _ => (axum::http::StatusCode::NOT_FOUND, "Not found").into_response(), + } +} + +async fn health_check() -> Json { + Json(serde_json::json!({ + "status": "ok", + "service": "goose-web" + })) +} + +async fn list_sessions() -> Json { + match session::list_sessions() { + Ok(sessions) => { + let session_info: Vec = sessions + .into_iter() + .map(|(name, path)| { + let metadata = session::read_metadata(&path).unwrap_or_default(); + serde_json::json!({ + "name": name, + "path": path, + "description": metadata.description, + "message_count": metadata.message_count, + "working_dir": metadata.working_dir + }) + }) + .collect(); + + Json(serde_json::json!({ + "sessions": session_info + })) + } + Err(e) => Json(serde_json::json!({ + "error": e.to_string() + })), + } +} + +async fn get_session( + axum::extract::Path(session_id): axum::extract::Path, +) -> Json { + let session_file = session::get_path(session::Identifier::Name(session_id)); + + match session::read_messages(&session_file) { + Ok(messages) => { + let metadata = session::read_metadata(&session_file).unwrap_or_default(); + Json(serde_json::json!({ + "metadata": metadata, + "messages": messages + })) + } + Err(e) => Json(serde_json::json!({ + "error": e.to_string() + })), + } +} + +async fn websocket_handler( + ws: WebSocketUpgrade, + State(state): State, +) -> impl IntoResponse { + ws.on_upgrade(|socket| handle_socket(socket, state)) +} + +async fn handle_socket(socket: WebSocket, state: AppState) { + let (sender, mut receiver) = socket.split(); + let sender = Arc::new(Mutex::new(sender)); + + while let Some(msg) = receiver.next().await { + if let Ok(msg) = msg { + match msg { + Message::Text(text) => { + match serde_json::from_str::(&text.to_string()) { + Ok(WebSocketMessage::Message { + content, + session_id, + .. + }) => { + // Get session file path from session_id + let session_file = + session::get_path(session::Identifier::Name(session_id.clone())); + + // Get or create session in memory (for fast access during processing) + let session_messages = { + let sessions = state.sessions.read().await; + if let Some(session) = sessions.get(&session_id) { + session.clone() + } else { + drop(sessions); + let mut sessions = state.sessions.write().await; + + // Load existing messages from JSONL file if it exists + let existing_messages = session::read_messages(&session_file) + .unwrap_or_else(|_| Vec::new()); + + let new_session = Arc::new(Mutex::new(existing_messages)); + sessions.insert(session_id.clone(), new_session.clone()); + new_session + } + }; + + // Clone sender for async processing + let sender_clone = sender.clone(); + let agent = state.agent.clone(); + + // Process message in a separate task to allow streaming + let task_handle = tokio::spawn(async move { + let result = process_message_streaming( + &agent, + session_messages, + session_file, + content, + sender_clone, + ) + .await; + + if let Err(e) = result { + error!("Error processing message: {}", e); + } + }); + + // Store the abort handle + { + let mut cancellations = state.cancellations.write().await; + cancellations + .insert(session_id.clone(), task_handle.abort_handle()); + } + + // Wait for task completion and handle abort + let sender_for_abort = sender.clone(); + let session_id_for_cleanup = session_id.clone(); + let cancellations_for_cleanup = state.cancellations.clone(); + + tokio::spawn(async move { + match task_handle.await { + Ok(_) => { + // Task completed normally + } + Err(e) if e.is_cancelled() => { + // Task was aborted + let mut sender = sender_for_abort.lock().await; + let _ = sender + .send(Message::Text( + serde_json::to_string( + &WebSocketMessage::Cancelled { + message: "Operation cancelled by user" + .to_string(), + }, + ) + .unwrap() + .into(), + )) + .await; + } + Err(e) => { + error!("Task error: {}", e); + } + } + + // Clean up cancellation token + { + let mut cancellations = cancellations_for_cleanup.write().await; + cancellations.remove(&session_id_for_cleanup); + } + }); + } + Ok(WebSocketMessage::Cancel { session_id }) => { + // Cancel the active operation for this session + let abort_handle = { + let mut cancellations = state.cancellations.write().await; + cancellations.remove(&session_id) + }; + + if let Some(handle) = abort_handle { + handle.abort(); + + // Send cancellation confirmation + let mut sender = sender.lock().await; + let _ = sender + .send(Message::Text( + serde_json::to_string(&WebSocketMessage::Cancelled { + message: "Operation cancelled".to_string(), + }) + .unwrap() + .into(), + )) + .await; + } + } + Ok(_) => { + // Ignore other message types + } + Err(e) => { + error!("Failed to parse WebSocket message: {}", e); + } + } + } + Message::Close(_) => break, + _ => {} + } + } else { + break; + } + } +} + +async fn process_message_streaming( + agent: &Agent, + session_messages: Arc>>, + session_file: std::path::PathBuf, + content: String, + sender: Arc>>, +) -> Result<()> { + use futures::StreamExt; + use goose::agents::SessionConfig; + use goose::message::MessageContent; + use goose::session; + + // Create a user message + let user_message = GooseMessage::user().with_text(content.clone()); + + // Get existing messages from session and add the new user message + let mut messages = { + let mut session_msgs = session_messages.lock().await; + session_msgs.push(user_message.clone()); + session_msgs.clone() + }; + + // Persist messages to JSONL file with provider for automatic description generation + let provider = agent.provider().await; + if provider.is_err() { + let error_msg = "I'm not properly configured yet. Please configure a provider through the CLI first using `goose configure`.".to_string(); + let mut sender = sender.lock().await; + let _ = sender + .send(Message::Text( + serde_json::to_string(&WebSocketMessage::Response { + content: error_msg, + role: "assistant".to_string(), + timestamp: chrono::Utc::now().timestamp_millis(), + }) + .unwrap() + .into(), + )) + .await; + return Ok(()); + } + + let provider = provider.unwrap(); + session::persist_messages(&session_file, &messages, Some(provider.clone())).await?; + + // Create a session config + let session_config = SessionConfig { + id: session::Identifier::Path(session_file.clone()), + working_dir: std::env::current_dir()?, + schedule_id: None, + }; + + // Get response from agent + match agent.reply(&messages, Some(session_config)).await { + Ok(mut stream) => { + while let Some(result) = stream.next().await { + match result { + Ok(AgentEvent::Message(message)) => { + // Add message to our session + { + let mut session_msgs = session_messages.lock().await; + session_msgs.push(message.clone()); + } + + // Persist messages to JSONL file (no provider needed for assistant messages) + let current_messages = { + let session_msgs = session_messages.lock().await; + session_msgs.clone() + }; + session::persist_messages(&session_file, ¤t_messages, None).await?; + // Handle different message content types + for content in &message.content { + match content { + MessageContent::Text(text) => { + // Send the text response + let mut sender = sender.lock().await; + let _ = sender + .send(Message::Text( + serde_json::to_string(&WebSocketMessage::Response { + content: text.text.clone(), + role: "assistant".to_string(), + timestamp: chrono::Utc::now().timestamp_millis(), + }) + .unwrap() + .into(), + )) + .await; + } + MessageContent::ToolRequest(req) => { + // Send tool request notification + let mut sender = sender.lock().await; + if let Ok(tool_call) = &req.tool_call { + let _ = sender + .send(Message::Text( + serde_json::to_string( + &WebSocketMessage::ToolRequest { + id: req.id.clone(), + tool_name: tool_call.name.clone(), + arguments: tool_call.arguments.clone(), + }, + ) + .unwrap() + .into(), + )) + .await; + } + } + MessageContent::ToolResponse(_resp) => { + // Tool responses are already included in the complete message stream + // and will be persisted to session history. No need to send separate + // WebSocket messages as this would cause duplicates. + } + MessageContent::ToolConfirmationRequest(confirmation) => { + // Send tool confirmation request + let mut sender = sender.lock().await; + let _ = sender + .send(Message::Text( + serde_json::to_string( + &WebSocketMessage::ToolConfirmation { + id: confirmation.id.clone(), + tool_name: confirmation.tool_name.clone(), + arguments: confirmation.arguments.clone(), + needs_confirmation: true, + }, + ) + .unwrap() + .into(), + )) + .await; + + // For now, auto-approve in web mode + // TODO: Implement proper confirmation UI + agent.handle_confirmation( + confirmation.id.clone(), + goose::permission::PermissionConfirmation { + principal_type: goose::permission::permission_confirmation::PrincipalType::Tool, + permission: goose::permission::Permission::AllowOnce, + } + ).await; + } + MessageContent::Thinking(thinking) => { + // Send thinking indicator + let mut sender = sender.lock().await; + let _ = sender + .send(Message::Text( + serde_json::to_string(&WebSocketMessage::Thinking { + message: thinking.thinking.clone(), + }) + .unwrap() + .into(), + )) + .await; + } + MessageContent::ContextLengthExceeded(msg) => { + // Send context exceeded notification + let mut sender = sender.lock().await; + let _ = sender + .send(Message::Text( + serde_json::to_string( + &WebSocketMessage::ContextExceeded { + message: msg.msg.clone(), + }, + ) + .unwrap() + .into(), + )) + .await; + + // For now, auto-summarize in web mode + // TODO: Implement proper UI for context handling + let (summarized_messages, _) = + agent.summarize_context(&messages).await?; + messages = summarized_messages; + } + _ => { + // Handle other message types as needed + } + } + } + } + Ok(AgentEvent::McpNotification(_notification)) => { + // Handle MCP notifications if needed + // For now, we'll just log them + tracing::info!("Received MCP notification in web interface"); + } + Err(e) => { + error!("Error in message stream: {}", e); + let mut sender = sender.lock().await; + let _ = sender + .send(Message::Text( + serde_json::to_string(&WebSocketMessage::Error { + message: format!("Error: {}", e), + }) + .unwrap() + .into(), + )) + .await; + break; + } + } + } + } + Err(e) => { + error!("Error calling agent: {}", e); + let mut sender = sender.lock().await; + let _ = sender + .send(Message::Text( + serde_json::to_string(&WebSocketMessage::Error { + message: format!("Error: {}", e), + }) + .unwrap() + .into(), + )) + .await; + } + } + + // Send completion message + let mut sender = sender.lock().await; + let _ = sender + .send(Message::Text( + serde_json::to_string(&WebSocketMessage::Complete { + message: "Response complete".to_string(), + }) + .unwrap() + .into(), + )) + .await; + + Ok(()) +} + +// Add webbrowser dependency for opening browser +use webbrowser; diff --git a/crates/goose-cli/static/index.html b/crates/goose-cli/static/index.html new file mode 100644 index 00000000..f52b03bf --- /dev/null +++ b/crates/goose-cli/static/index.html @@ -0,0 +1,46 @@ + + + + + + Goose Chat + + + +
+
+

Goose Chat

+
Connecting...
+
+ +
+
+
+

Welcome to Goose!

+

I'm your AI assistant. How can I help you today?

+ +
+
What can you do?
+
Demo writing and reading files
+
Make a snake game in a new folder
+
List files in my current directory
+
Take a screenshot and summarize
+
+
+
+ +
+ + +
+
+
+ + + + \ No newline at end of file diff --git a/crates/goose-cli/static/script.js b/crates/goose-cli/static/script.js new file mode 100644 index 00000000..3cc9aa99 --- /dev/null +++ b/crates/goose-cli/static/script.js @@ -0,0 +1,523 @@ +// WebSocket connection and chat functionality +let socket = null; +let sessionId = getSessionId(); +let isConnected = false; + +// DOM elements +const messagesContainer = document.getElementById('messages'); +const messageInput = document.getElementById('message-input'); +const sendButton = document.getElementById('send-button'); +const connectionStatus = document.getElementById('connection-status'); + +// Track if we're currently processing +let isProcessing = false; + +// Get session ID - either from URL parameter, injected session name, or generate new one +function getSessionId() { + // Check if session name was injected by server (for /session/:name routes) + if (window.GOOSE_SESSION_NAME) { + return window.GOOSE_SESSION_NAME; + } + + // Check URL parameters + const urlParams = new URLSearchParams(window.location.search); + const sessionParam = urlParams.get('session') || urlParams.get('name'); + if (sessionParam) { + return sessionParam; + } + + // Generate new session ID using CLI format + return generateSessionId(); +} + +// Generate a session ID using timestamp format (yyyymmdd_hhmmss) like CLI +function generateSessionId() { + const now = new Date(); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, '0'); + const day = String(now.getDate()).padStart(2, '0'); + const hour = String(now.getHours()).padStart(2, '0'); + const minute = String(now.getMinutes()).padStart(2, '0'); + const second = String(now.getSeconds()).padStart(2, '0'); + + return `${year}${month}${day}_${hour}${minute}${second}`; +} + +// Format timestamp +function formatTimestamp(date) { + return date.toLocaleTimeString('en-US', { + hour: '2-digit', + minute: '2-digit' + }); +} + +// Create message element +function createMessageElement(content, role, timestamp) { + const messageDiv = document.createElement('div'); + messageDiv.className = `message ${role}`; + + // Create content div + const contentDiv = document.createElement('div'); + contentDiv.className = 'message-content'; + contentDiv.innerHTML = formatMessageContent(content); + messageDiv.appendChild(contentDiv); + + // Add timestamp + const timestampDiv = document.createElement('div'); + timestampDiv.className = 'timestamp'; + timestampDiv.textContent = formatTimestamp(new Date(timestamp || Date.now())); + messageDiv.appendChild(timestampDiv); + + return messageDiv; +} + +// Format message content (handle markdown-like formatting) +function formatMessageContent(content) { + // Escape HTML + let formatted = content + .replace(/&/g, '&') + .replace(//g, '>'); + + // Handle code blocks + formatted = formatted.replace(/```(\w+)?\n([\s\S]*?)```/g, (match, lang, code) => { + return `
${code.trim()}
`; + }); + + // Handle inline code + formatted = formatted.replace(/`([^`]+)`/g, '$1'); + + // Handle line breaks + formatted = formatted.replace(/\n/g, '
'); + + return formatted; +} + +// Add message to chat +function addMessage(content, role, timestamp) { + // Remove welcome message if it exists + const welcomeMessage = messagesContainer.querySelector('.welcome-message'); + if (welcomeMessage) { + welcomeMessage.remove(); + } + + const messageElement = createMessageElement(content, role, timestamp); + messagesContainer.appendChild(messageElement); + + // Scroll to bottom + messagesContainer.scrollTop = messagesContainer.scrollHeight; +} + +// Add thinking indicator +function addThinkingIndicator() { + removeThinkingIndicator(); // Remove any existing one first + + const thinkingDiv = document.createElement('div'); + thinkingDiv.id = 'thinking-indicator'; + thinkingDiv.className = 'message thinking-message'; + thinkingDiv.innerHTML = ` +
+ + + +
+ Goose is thinking... + `; + messagesContainer.appendChild(thinkingDiv); + messagesContainer.scrollTop = messagesContainer.scrollHeight; +} + +// Remove thinking indicator +function removeThinkingIndicator() { + const thinking = document.getElementById('thinking-indicator'); + if (thinking) { + thinking.remove(); + } +} + +// Connect to WebSocket +function connectWebSocket() { + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const wsUrl = `${protocol}//${window.location.host}/ws`; + + socket = new WebSocket(wsUrl); + + socket.onopen = () => { + console.log('WebSocket connected'); + isConnected = true; + connectionStatus.textContent = 'Connected'; + connectionStatus.className = 'status connected'; + sendButton.disabled = false; + + // Check if this session exists and load history if it does + loadSessionIfExists(); + }; + + socket.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + handleServerMessage(data); + } catch (e) { + console.error('Failed to parse message:', e); + } + }; + + socket.onclose = () => { + console.log('WebSocket disconnected'); + isConnected = false; + connectionStatus.textContent = 'Disconnected'; + connectionStatus.className = 'status disconnected'; + sendButton.disabled = true; + + // Attempt to reconnect after 3 seconds + setTimeout(connectWebSocket, 3000); + }; + + socket.onerror = (error) => { + console.error('WebSocket error:', error); + }; +} + +// Handle messages from server +function handleServerMessage(data) { + switch (data.type) { + case 'response': + // For streaming responses, we need to handle partial messages + handleStreamingResponse(data); + break; + case 'tool_request': + handleToolRequest(data); + break; + case 'tool_response': + handleToolResponse(data); + break; + case 'tool_confirmation': + handleToolConfirmation(data); + break; + case 'thinking': + handleThinking(data); + break; + case 'context_exceeded': + handleContextExceeded(data); + break; + case 'cancelled': + handleCancelled(data); + break; + case 'complete': + handleComplete(data); + break; + case 'error': + removeThinkingIndicator(); + resetSendButton(); + addMessage(`Error: ${data.message}`, 'assistant', Date.now()); + break; + default: + console.log('Unknown message type:', data.type); + } +} + +// Track current streaming message +let currentStreamingMessage = null; + +// Handle streaming responses +function handleStreamingResponse(data) { + removeThinkingIndicator(); + + // If this is the first chunk of a new message, or we don't have a current streaming message + if (!currentStreamingMessage) { + // Create a new message element + const messageElement = createMessageElement(data.content, data.role || 'assistant', data.timestamp); + messageElement.setAttribute('data-streaming', 'true'); + messagesContainer.appendChild(messageElement); + + currentStreamingMessage = { + element: messageElement, + content: data.content, + role: data.role || 'assistant', + timestamp: data.timestamp + }; + } else { + // Append to existing streaming message + currentStreamingMessage.content += data.content; + + // Update the message content using the proper content div + const contentDiv = currentStreamingMessage.element.querySelector('.message-content'); + if (contentDiv) { + contentDiv.innerHTML = formatMessageContent(currentStreamingMessage.content); + } + } + + // Scroll to bottom + messagesContainer.scrollTop = messagesContainer.scrollHeight; +} + +// Handle tool requests +function handleToolRequest(data) { + removeThinkingIndicator(); // Remove thinking when tool starts + + // Reset streaming message so tool doesn't interfere with message flow + currentStreamingMessage = null; + + const toolDiv = document.createElement('div'); + toolDiv.className = 'message assistant tool-message'; + + const headerDiv = document.createElement('div'); + headerDiv.className = 'tool-header'; + headerDiv.innerHTML = `🔧 ${data.tool_name}`; + + const contentDiv = document.createElement('div'); + contentDiv.className = 'tool-content'; + + // Format the arguments + if (data.tool_name === 'developer__shell' && data.arguments.command) { + contentDiv.innerHTML = `
${escapeHtml(data.arguments.command)}
`; + } else if (data.tool_name === 'developer__text_editor') { + const action = data.arguments.command || 'unknown'; + const path = data.arguments.path || 'unknown'; + contentDiv.innerHTML = `
action: ${action}
`; + contentDiv.innerHTML += `
path: ${escapeHtml(path)}
`; + if (data.arguments.file_text) { + contentDiv.innerHTML += `
content:
${escapeHtml(data.arguments.file_text.substring(0, 200))}${data.arguments.file_text.length > 200 ? '...' : ''}
`; + } + } else { + contentDiv.innerHTML = `
${JSON.stringify(data.arguments, null, 2)}
`; + } + + toolDiv.appendChild(headerDiv); + toolDiv.appendChild(contentDiv); + + // Add a "running" indicator + const runningDiv = document.createElement('div'); + runningDiv.className = 'tool-running'; + runningDiv.innerHTML = '⏳ Running...'; + toolDiv.appendChild(runningDiv); + + messagesContainer.appendChild(toolDiv); + messagesContainer.scrollTop = messagesContainer.scrollHeight; +} + +// Handle tool responses +function handleToolResponse(data) { + // Remove the "running" indicator from the last tool message + const toolMessages = messagesContainer.querySelectorAll('.tool-message'); + if (toolMessages.length > 0) { + const lastToolMessage = toolMessages[toolMessages.length - 1]; + const runningIndicator = lastToolMessage.querySelector('.tool-running'); + if (runningIndicator) { + runningIndicator.remove(); + } + } + + if (data.is_error) { + const errorDiv = document.createElement('div'); + errorDiv.className = 'message tool-error'; + errorDiv.innerHTML = `Tool Error: ${escapeHtml(data.result.error || 'Unknown error')}`; + messagesContainer.appendChild(errorDiv); + } else { + // Handle successful tool response + if (Array.isArray(data.result)) { + data.result.forEach(content => { + if (content.type === 'text' && content.text) { + const responseDiv = document.createElement('div'); + responseDiv.className = 'message tool-result'; + responseDiv.innerHTML = `
${escapeHtml(content.text)}
`; + messagesContainer.appendChild(responseDiv); + } + }); + } + } + messagesContainer.scrollTop = messagesContainer.scrollHeight; + + // Reset streaming message so next assistant response creates a new message + currentStreamingMessage = null; + + // Show thinking indicator because assistant will likely follow up with explanation + // Only show if we're still processing (cancel button is active) + if (isProcessing) { + addThinkingIndicator(); + } +} + +// Handle tool confirmations +function handleToolConfirmation(data) { + const confirmDiv = document.createElement('div'); + confirmDiv.className = 'message tool-confirmation'; + confirmDiv.innerHTML = ` +
⚠️ Tool Confirmation Required
+
+ ${data.tool_name} wants to execute with: +
${JSON.stringify(data.arguments, null, 2)}
+
+
Auto-approved in web mode (UI coming soon)
+ `; + messagesContainer.appendChild(confirmDiv); + messagesContainer.scrollTop = messagesContainer.scrollHeight; +} + +// Handle thinking messages +function handleThinking(data) { + // For now, just log thinking messages + console.log('Thinking:', data.message); +} + +// Handle context exceeded +function handleContextExceeded(data) { + const contextDiv = document.createElement('div'); + contextDiv.className = 'message context-warning'; + contextDiv.innerHTML = ` +
⚠️ Context Length Exceeded
+
${escapeHtml(data.message)}
+
Auto-summarizing conversation...
+ `; + messagesContainer.appendChild(contextDiv); + messagesContainer.scrollTop = messagesContainer.scrollHeight; +} + +// Handle cancelled operation +function handleCancelled(data) { + removeThinkingIndicator(); + resetSendButton(); + + const cancelDiv = document.createElement('div'); + cancelDiv.className = 'message system-message cancelled'; + cancelDiv.innerHTML = `${escapeHtml(data.message)}`; + messagesContainer.appendChild(cancelDiv); + messagesContainer.scrollTop = messagesContainer.scrollHeight; +} + +// Handle completion of response +function handleComplete(data) { + removeThinkingIndicator(); + resetSendButton(); + // Finalize any streaming message + if (currentStreamingMessage) { + currentStreamingMessage = null; + } +} + +// Reset send button to normal state +function resetSendButton() { + isProcessing = false; + sendButton.textContent = 'Send'; + sendButton.classList.remove('cancel-mode'); +} + +// Escape HTML to prevent XSS +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +// Send message or cancel +function sendMessage() { + if (isProcessing) { + // Cancel the current operation + socket.send(JSON.stringify({ + type: 'cancel', + session_id: sessionId + })); + return; + } + + const message = messageInput.value.trim(); + if (!message || !isConnected) return; + + // Add user message to chat + addMessage(message, 'user', Date.now()); + + // Clear input + messageInput.value = ''; + messageInput.style.height = 'auto'; + + // Add thinking indicator + addThinkingIndicator(); + + // Update button to show cancel + isProcessing = true; + sendButton.textContent = 'Cancel'; + sendButton.classList.add('cancel-mode'); + + // Send message through WebSocket + socket.send(JSON.stringify({ + type: 'message', + content: message, + session_id: sessionId, + timestamp: Date.now() + })); +} + +// Handle suggestion pill clicks +function sendSuggestion(text) { + if (!isConnected || isProcessing) return; + + messageInput.value = text; + sendMessage(); +} + +// Load session history if the session exists (like --resume in CLI) +async function loadSessionIfExists() { + try { + const response = await fetch(`/api/sessions/${sessionId}`); + if (response.ok) { + const sessionData = await response.json(); + if (sessionData.messages && sessionData.messages.length > 0) { + // Remove welcome message since we're resuming + const welcomeMessage = messagesContainer.querySelector('.welcome-message'); + if (welcomeMessage) { + welcomeMessage.remove(); + } + + // Display session resumed message + const resumeDiv = document.createElement('div'); + resumeDiv.className = 'message system-message'; + resumeDiv.innerHTML = `Session resumed: ${sessionData.messages.length} messages loaded`; + messagesContainer.appendChild(resumeDiv); + + + // Update page title with session description if available + if (sessionData.metadata && sessionData.metadata.description) { + document.title = `Goose Chat - ${sessionData.metadata.description}`; + } + + messagesContainer.scrollTop = messagesContainer.scrollHeight; + } + } + } catch (error) { + console.log('No existing session found or error loading:', error); + // This is fine - just means it's a new session + } +} + + +// Event listeners +sendButton.addEventListener('click', sendMessage); + +messageInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + sendMessage(); + } +}); + +// Auto-resize textarea +messageInput.addEventListener('input', () => { + messageInput.style.height = 'auto'; + messageInput.style.height = messageInput.scrollHeight + 'px'; +}); + +// Initialize WebSocket connection +connectWebSocket(); + +// Focus on input +messageInput.focus(); + +// Update session title +function updateSessionTitle() { + const titleElement = document.getElementById('session-title'); + // Just show "Goose Chat" - no need to show session ID + titleElement.textContent = 'Goose Chat'; +} + +// Update title on load +updateSessionTitle(); \ No newline at end of file diff --git a/crates/goose-cli/static/style.css b/crates/goose-cli/static/style.css new file mode 100644 index 00000000..f2f1eb3e --- /dev/null +++ b/crates/goose-cli/static/style.css @@ -0,0 +1,480 @@ +:root { + /* Dark theme colors (matching the dark.png) */ + --bg-primary: #000000; + --bg-secondary: #0a0a0a; + --bg-tertiary: #1a1a1a; + --text-primary: #ffffff; + --text-secondary: #a0a0a0; + --text-muted: #666666; + --border-color: #333333; + --border-subtle: #1a1a1a; + --accent-color: #ffffff; + --accent-hover: #f0f0f0; + --user-bg: #1a1a1a; + --assistant-bg: #0a0a0a; + --input-bg: #0a0a0a; + --input-border: #333333; + --button-bg: #ffffff; + --button-text: #000000; + --button-hover: #e0e0e0; + --pill-bg: transparent; + --pill-border: #333333; + --pill-hover: #1a1a1a; + --tool-bg: #0f0f0f; + --code-bg: #0f0f0f; +} + +/* Light theme */ +@media (prefers-color-scheme: light) { + :root { + --bg-primary: #ffffff; + --bg-secondary: #fafafa; + --bg-tertiary: #f5f5f5; + --text-primary: #000000; + --text-secondary: #666666; + --text-muted: #999999; + --border-color: #e1e5e9; + --border-subtle: #f0f0f0; + --accent-color: #000000; + --accent-hover: #333333; + --user-bg: #f0f0f0; + --assistant-bg: #fafafa; + --input-bg: #ffffff; + --input-border: #e1e5e9; + --button-bg: #000000; + --button-text: #ffffff; + --button-hover: #333333; + --pill-bg: #f5f5f5; + --pill-border: #e1e5e9; + --pill-hover: #e8eaed; + --tool-bg: #f8f9fa; + --code-bg: #f5f5f5; + } +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + background-color: var(--bg-primary); + color: var(--text-primary); + line-height: 1.5; + height: 100vh; + overflow: hidden; + font-size: 14px; +} + +.container { + display: flex; + flex-direction: column; + height: 100vh; + max-width: 100%; + margin: 0 auto; +} + +header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem 1.5rem; + background-color: var(--bg-primary); + border-bottom: 1px solid var(--border-subtle); +} + +header h1 { + font-size: 1.25rem; + font-weight: 600; + display: flex; + align-items: center; + gap: 0.75rem; +} + +header h1::before { + content: "🪿"; + font-size: 1.5rem; +} + +.status { + font-size: 0.75rem; + color: var(--text-secondary); + padding: 0.25rem 0.75rem; + border-radius: 1rem; + background-color: var(--bg-secondary); + border: 1px solid var(--border-color); +} + +.status.connected { + color: #10b981; + border-color: #10b981; + background-color: rgba(16, 185, 129, 0.1); +} + +.status.disconnected { + color: #ef4444; + border-color: #ef4444; + background-color: rgba(239, 68, 68, 0.1); +} + +.chat-container { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.messages { + flex: 1; + overflow-y: auto; + padding: 2rem; + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.welcome-message { + text-align: center; + padding: 4rem 2rem; + color: var(--text-secondary); +} + +.welcome-message h2 { + font-size: 1.5rem; + margin-bottom: 1rem; + color: var(--text-primary); + font-weight: 600; +} + +.welcome-message p { + font-size: 1rem; + margin-bottom: 2rem; +} + +/* Suggestion pills like in the design */ +.suggestion-pills { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + justify-content: center; + margin-top: 2rem; +} + +.suggestion-pill { + padding: 0.75rem 1.25rem; + background-color: var(--pill-bg); + border: 1px solid var(--pill-border); + border-radius: 2rem; + color: var(--text-primary); + font-size: 0.875rem; + cursor: pointer; + transition: all 0.2s ease; + text-decoration: none; + display: inline-block; +} + +.suggestion-pill:hover { + background-color: var(--pill-hover); + border-color: var(--border-color); +} + +.message { + max-width: 80%; + padding: 1rem 1.25rem; + border-radius: 1rem; + word-wrap: break-word; + position: relative; +} + +.message.user { + align-self: flex-end; + background-color: var(--user-bg); + margin-left: auto; + border: 1px solid var(--border-subtle); +} + +.message.assistant { + align-self: flex-start; + background-color: var(--assistant-bg); + border: 1px solid var(--border-subtle); +} + +.message-content { + flex: 1; + margin-bottom: 0.5rem; +} + +.message .timestamp { + font-size: 0.6875rem; + color: var(--text-muted); + margin-top: 0.5rem; + opacity: 0.7; +} + +.message pre { + background-color: var(--code-bg); + padding: 0.75rem; + border-radius: 0.5rem; + overflow-x: auto; + margin: 0.75rem 0; + border: 1px solid var(--border-color); + font-size: 0.8125rem; +} + +.message code { + background-color: var(--code-bg); + padding: 0.125rem 0.375rem; + border-radius: 0.25rem; + font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', monospace; + font-size: 0.8125rem; + border: 1px solid var(--border-color); +} + +.input-container { + display: flex; + gap: 0.75rem; + padding: 1.5rem; + background-color: var(--bg-primary); + border-top: 1px solid var(--border-subtle); +} + +#message-input { + flex: 1; + padding: 0.875rem 1rem; + border: 1px solid var(--input-border); + border-radius: 0.75rem; + background-color: var(--input-bg); + color: var(--text-primary); + font-family: inherit; + font-size: 0.875rem; + resize: none; + min-height: 2.75rem; + max-height: 8rem; + outline: none; + transition: border-color 0.2s ease; +} + +#message-input:focus { + border-color: var(--accent-color); +} + +#message-input::placeholder { + color: var(--text-muted); +} + +#send-button { + padding: 0.875rem 1.5rem; + background-color: var(--button-bg); + color: var(--button-text); + border: none; + border-radius: 0.75rem; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + min-width: 4rem; +} + +#send-button:hover { + background-color: var(--button-hover); + transform: translateY(-1px); +} + +#send-button:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; +} + +#send-button.cancel-mode { + background-color: #ef4444; + color: #ffffff; +} + +#send-button.cancel-mode:hover { + background-color: #dc2626; +} + +/* Scrollbar styling */ +.messages::-webkit-scrollbar { + width: 6px; +} + +.messages::-webkit-scrollbar-track { + background: transparent; +} + +.messages::-webkit-scrollbar-thumb { + background: var(--border-color); + border-radius: 3px; +} + +.messages::-webkit-scrollbar-thumb:hover { + background: var(--text-secondary); +} + +/* Tool call styling */ +.tool-message, .tool-result, .tool-error, .tool-confirmation, .context-warning { + background-color: var(--tool-bg); + border: 1px solid var(--border-color); + border-radius: 0.75rem; + padding: 1rem; + margin: 0.75rem 0; + max-width: 90%; +} + +.tool-header, .tool-confirm-header, .context-header { + font-weight: 600; + color: var(--accent-color); + margin-bottom: 0.75rem; + font-size: 0.875rem; +} + +.tool-content { + font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', monospace; + font-size: 0.8125rem; + color: var(--text-secondary); +} + +.tool-param { + margin: 0.5rem 0; +} + +.tool-param strong { + color: var(--text-primary); +} + +.tool-running { + font-size: 0.8125rem; + color: var(--accent-color); + margin-top: 0.75rem; + font-style: italic; +} + +.tool-error { + border-color: #ef4444; + background-color: rgba(239, 68, 68, 0.05); +} + +.tool-error strong { + color: #ef4444; +} + +.tool-result { + background-color: var(--tool-bg); + border-left: 3px solid var(--accent-color); + margin-left: 1.5rem; + border-radius: 0.5rem; +} + +.tool-confirmation { + border-color: #f59e0b; + background-color: rgba(245, 158, 11, 0.05); +} + +.tool-confirm-note, .context-note { + font-size: 0.75rem; + color: var(--text-muted); + margin-top: 0.75rem; + font-style: italic; +} + +.context-warning { + border-color: #f59e0b; + background-color: rgba(245, 158, 11, 0.05); +} + +.context-header { + color: #f59e0b; +} + +.system-message { + text-align: center; + color: var(--text-secondary); + font-style: italic; + margin: 1rem 0; + font-size: 0.875rem; +} + +.cancelled { + color: #ef4444; +} + +/* Thinking indicator */ +.thinking-message { + display: flex; + align-items: center; + gap: 0.75rem; + color: var(--text-secondary); + font-style: italic; + padding: 1rem 1.25rem; + background-color: var(--bg-secondary); + border-radius: 1rem; + border: 1px solid var(--border-subtle); + max-width: 80%; + font-size: 0.875rem; +} + +.thinking-dots { + display: flex; + gap: 0.25rem; +} + +.thinking-dots span { + width: 6px; + height: 6px; + border-radius: 50%; + background-color: var(--text-secondary); + animation: thinking-bounce 1.4s infinite ease-in-out both; +} + +.thinking-dots span:nth-child(1) { + animation-delay: -0.32s; +} + +.thinking-dots span:nth-child(2) { + animation-delay: -0.16s; +} + +@keyframes thinking-bounce { + 0%, 80%, 100% { + transform: scale(0.6); + opacity: 0.5; + } + 40% { + transform: scale(1); + opacity: 1; + } +} + +/* Keep the old loading indicator for backwards compatibility */ +.loading-message { + display: none; +} + +/* Responsive design */ +@media (max-width: 768px) { + .messages { + padding: 1rem; + gap: 1rem; + } + + .message { + max-width: 90%; + padding: 0.875rem 1rem; + } + + .input-container { + padding: 1rem; + } + + header { + padding: 0.75rem 1rem; + } + + .welcome-message { + padding: 2rem 1rem; + } +} \ No newline at end of file diff --git a/test_web.sh b/test_web.sh new file mode 100644 index 00000000..adfd4d95 --- /dev/null +++ b/test_web.sh @@ -0,0 +1,26 @@ +#!/bin/bash +# Test script for Goose Web Interface + +echo "Testing Goose Web Interface..." +echo "================================" + +# Start the web server in the background +echo "Starting web server on port 8080..." +./target/debug/goose web --port 8080 & +SERVER_PID=$! + +# Wait for server to start +sleep 2 + +# Test the health endpoint +echo -e "\nTesting health endpoint:" +curl -s http://localhost:8080/api/health | jq . + +# Open browser (optional) +# open http://localhost:8080 + +echo -e "\nWeb server is running at http://localhost:8080" +echo "Press Ctrl+C to stop the server" + +# Wait for user to stop +wait $SERVER_PID \ No newline at end of file