mirror of
https://github.com/aljazceru/goose.git
synced 2025-12-17 06:04:23 +01:00
feat: goose web for local terminal alternative (#2718)
This commit is contained in:
77
Cargo.lock
generated
77
Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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]
|
||||
|
||||
78
crates/goose-cli/WEB_INTERFACE.md
Normal file
78
crates/goose-cli/WEB_INTERFACE.md
Normal file
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -7,3 +7,4 @@ pub mod recipe;
|
||||
pub mod schedule;
|
||||
pub mod session;
|
||||
pub mod update;
|
||||
pub mod web;
|
||||
|
||||
640
crates/goose-cli/src/commands/web.rs
Normal file
640
crates/goose-cli/src/commands/web.rs
Normal file
@@ -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<RwLock<std::collections::HashMap<String, Arc<Mutex<Vec<GooseMessage>>>>>>;
|
||||
type CancellationStore = Arc<RwLock<std::collections::HashMap<String, tokio::task::AbortHandle>>>;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct AppState {
|
||||
agent: Arc<Agent>,
|
||||
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<String>,
|
||||
) -> Html<String> {
|
||||
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(
|
||||
"<script src=\"/static/script.js\"></script>",
|
||||
&format!(
|
||||
"<script>window.GOOSE_SESSION_NAME = '{}';</script>\n <script src=\"/static/script.js\"></script>",
|
||||
session_name
|
||||
)
|
||||
);
|
||||
Html(html_with_session)
|
||||
}
|
||||
|
||||
async fn serve_static(axum::extract::Path(path): axum::extract::Path<String>) -> 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<serde_json::Value> {
|
||||
Json(serde_json::json!({
|
||||
"status": "ok",
|
||||
"service": "goose-web"
|
||||
}))
|
||||
}
|
||||
|
||||
async fn list_sessions() -> Json<serde_json::Value> {
|
||||
match session::list_sessions() {
|
||||
Ok(sessions) => {
|
||||
let session_info: Vec<serde_json::Value> = 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<String>,
|
||||
) -> Json<serde_json::Value> {
|
||||
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<AppState>,
|
||||
) -> 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::<WebSocketMessage>(&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<Mutex<Vec<GooseMessage>>>,
|
||||
session_file: std::path::PathBuf,
|
||||
content: String,
|
||||
sender: Arc<Mutex<futures::stream::SplitSink<WebSocket, Message>>>,
|
||||
) -> 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;
|
||||
46
crates/goose-cli/static/index.html
Normal file
46
crates/goose-cli/static/index.html
Normal file
@@ -0,0 +1,46 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Goose Chat</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1 id="session-title">Goose Chat</h1>
|
||||
<div class="status" id="connection-status">Connecting...</div>
|
||||
</header>
|
||||
|
||||
<div class="chat-container">
|
||||
<div class="messages" id="messages">
|
||||
<div class="welcome-message">
|
||||
<h2>Welcome to Goose!</h2>
|
||||
<p>I'm your AI assistant. How can I help you today?</p>
|
||||
|
||||
<div class="suggestion-pills">
|
||||
<div class="suggestion-pill" onclick="sendSuggestion('What can you do?')">What can you do?</div>
|
||||
<div class="suggestion-pill" onclick="sendSuggestion('Demo writing and reading files')">Demo writing and reading files</div>
|
||||
<div class="suggestion-pill" onclick="sendSuggestion('Make a snake game in a new folder')">Make a snake game in a new folder</div>
|
||||
<div class="suggestion-pill" onclick="sendSuggestion('List files in my current directory')">List files in my current directory</div>
|
||||
<div class="suggestion-pill" onclick="sendSuggestion('Take a screenshot and summarize')">Take a screenshot and summarize</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="input-container">
|
||||
<textarea
|
||||
id="message-input"
|
||||
placeholder="Type your message here..."
|
||||
rows="3"
|
||||
autofocus
|
||||
></textarea>
|
||||
<button id="send-button" type="button">Send</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/script.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
523
crates/goose-cli/static/script.js
Normal file
523
crates/goose-cli/static/script.js
Normal file
@@ -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, '<')
|
||||
.replace(/>/g, '>');
|
||||
|
||||
// Handle code blocks
|
||||
formatted = formatted.replace(/```(\w+)?\n([\s\S]*?)```/g, (match, lang, code) => {
|
||||
return `<pre><code class="language-${lang || 'plaintext'}">${code.trim()}</code></pre>`;
|
||||
});
|
||||
|
||||
// Handle inline code
|
||||
formatted = formatted.replace(/`([^`]+)`/g, '<code>$1</code>');
|
||||
|
||||
// Handle line breaks
|
||||
formatted = formatted.replace(/\n/g, '<br>');
|
||||
|
||||
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 = `
|
||||
<div class="thinking-dots">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
<span class="thinking-text">Goose is thinking...</span>
|
||||
`;
|
||||
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 = `🔧 <strong>${data.tool_name}</strong>`;
|
||||
|
||||
const contentDiv = document.createElement('div');
|
||||
contentDiv.className = 'tool-content';
|
||||
|
||||
// Format the arguments
|
||||
if (data.tool_name === 'developer__shell' && data.arguments.command) {
|
||||
contentDiv.innerHTML = `<pre><code>${escapeHtml(data.arguments.command)}</code></pre>`;
|
||||
} else if (data.tool_name === 'developer__text_editor') {
|
||||
const action = data.arguments.command || 'unknown';
|
||||
const path = data.arguments.path || 'unknown';
|
||||
contentDiv.innerHTML = `<div class="tool-param"><strong>action:</strong> ${action}</div>`;
|
||||
contentDiv.innerHTML += `<div class="tool-param"><strong>path:</strong> ${escapeHtml(path)}</div>`;
|
||||
if (data.arguments.file_text) {
|
||||
contentDiv.innerHTML += `<div class="tool-param"><strong>content:</strong> <pre><code>${escapeHtml(data.arguments.file_text.substring(0, 200))}${data.arguments.file_text.length > 200 ? '...' : ''}</code></pre></div>`;
|
||||
}
|
||||
} else {
|
||||
contentDiv.innerHTML = `<pre><code>${JSON.stringify(data.arguments, null, 2)}</code></pre>`;
|
||||
}
|
||||
|
||||
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 = `<strong>Tool Error:</strong> ${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 = `<pre>${escapeHtml(content.text)}</pre>`;
|
||||
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 = `
|
||||
<div class="tool-confirm-header">⚠️ Tool Confirmation Required</div>
|
||||
<div class="tool-confirm-content">
|
||||
<strong>${data.tool_name}</strong> wants to execute with:
|
||||
<pre><code>${JSON.stringify(data.arguments, null, 2)}</code></pre>
|
||||
</div>
|
||||
<div class="tool-confirm-note">Auto-approved in web mode (UI coming soon)</div>
|
||||
`;
|
||||
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 = `
|
||||
<div class="context-header">⚠️ Context Length Exceeded</div>
|
||||
<div class="context-content">${escapeHtml(data.message)}</div>
|
||||
<div class="context-note">Auto-summarizing conversation...</div>
|
||||
`;
|
||||
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 = `<em>${escapeHtml(data.message)}</em>`;
|
||||
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 = `<em>Session resumed: ${sessionData.messages.length} messages loaded</em>`;
|
||||
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();
|
||||
480
crates/goose-cli/static/style.css
Normal file
480
crates/goose-cli/static/style.css
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
26
test_web.sh
Normal file
26
test_web.sh
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user