delete goose web (#7696)

Co-authored-by: Douwe Osinga <douwe@squareup.com>
This commit is contained in:
Douwe Osinga
2026-03-07 12:09:05 -05:00
committed by GitHub
parent d7ffedaa6e
commit a7fb7e199c
13 changed files with 0 additions and 2036 deletions
Generated
-16
View File
@@ -4428,7 +4428,6 @@ dependencies = [
"anstream",
"anyhow",
"async-trait",
"axum 0.8.8",
"base64 0.22.1",
"bat",
"bzip2 0.5.2",
@@ -4445,7 +4444,6 @@ dependencies = [
"goose",
"goose-acp",
"goose-mcp",
"http 1.4.0",
"indicatif 0.18.3",
"open",
"rand 0.8.5",
@@ -4462,7 +4460,6 @@ dependencies = [
"test-case",
"tokio",
"tokio-util",
"tower-http",
"tracing",
"tracing-appender",
"tracing-subscriber",
@@ -4880,12 +4877,6 @@ 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"
@@ -10847,7 +10838,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
dependencies = [
"async-compression",
"base64 0.22.1",
"bitflags 2.10.0",
"bytes",
"futures-core",
@@ -10855,19 +10845,13 @@ dependencies = [
"http 1.4.0",
"http-body 1.0.1",
"http-body-util",
"http-range-header",
"httpdate",
"iri-string",
"mime",
"mime_guess",
"percent-encoding",
"pin-project-lite",
"tokio",
"tokio-util",
"tower",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
-4
View File
@@ -52,10 +52,6 @@ tar = "0.4"
reqwest = { workspace = true, features = ["blocking", "rustls"], default-features = false }
zip = { version = "^8.0", default-features = false, features = ["deflate"] }
bzip2 = "0.5"
# Web server dependencies
axum = { workspace = true, features = ["ws", "macros"] }
tower-http = { workspace = true, features = ["cors", "fs", "auth"] }
http = { workspace = true }
webbrowser = { workspace = true }
indicatif = "0.18.1"
tokio-util = { workspace = true, features = ["compat", "rt"] }
-78
View File
@@ -1,78 +0,0 @@
# 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
-44
View File
@@ -841,42 +841,6 @@ enum Command {
reconfigure: bool,
},
/// Start a web server with a chat interface
#[command(about = "Experimental: Start a web server with a chat interface")]
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,
/// Authentication token for both Basic Auth (password) and Bearer token
#[arg(long, help = "Authentication token to secure the web interface")]
auth_token: Option<String>,
/// Allow running without authentication when exposed on the network (unsafe)
#[arg(
long,
help = "Skip auth requirement when exposed on the network (unsafe)"
)]
no_auth: bool,
},
/// Terminal-integrated session (one session per terminal)
#[command(
about = "Terminal-integrated goose session",
@@ -1041,7 +1005,6 @@ fn get_command_name(command: &Option<Command>) -> &'static str {
Some(Command::Schedule { .. }) => "schedule",
Some(Command::Update { .. }) => "update",
Some(Command::Recipe { .. }) => "recipe",
Some(Command::Web { .. }) => "web",
Some(Command::Term { .. }) => "term",
Some(Command::LocalModels { .. }) => "local-models",
Some(Command::Completion { .. }) => "completion",
@@ -1780,13 +1743,6 @@ pub async fn cli() -> anyhow::Result<()> {
Ok(())
}
Some(Command::Recipe { command }) => handle_recipe_subcommand(command),
Some(Command::Web {
port,
host,
open,
auth_token,
no_auth,
}) => crate::commands::web::handle_web(port, host, open, auth_token, no_auth).await,
Some(Command::Term { command }) => handle_term_subcommand(command).await,
Some(Command::LocalModels { command }) => handle_local_models_command(command).await,
Some(Command::ValidateExtensions { file }) => {
-1
View File
@@ -7,4 +7,3 @@ pub mod schedule;
pub mod session;
pub mod term;
pub mod update;
pub mod web;
-721
View File
@@ -1,721 +0,0 @@
use anyhow::Result;
use axum::response::Redirect;
use axum::{
extract::{
ws::{Message, WebSocket, WebSocketUpgrade},
Query, Request, State,
},
http::{StatusCode, Uri},
middleware::{self, Next},
response::{Html, IntoResponse, Response},
routing::get,
Json, Router,
};
use base64::Engine;
use futures::{sink::SinkExt, stream::StreamExt};
use goose::agents::{Agent, AgentEvent};
use goose::conversation::message::Message as GooseMessage;
use goose::session::session_manager::SessionType;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::{net::ToSocketAddrs, sync::Arc};
use tokio::sync::{Mutex, RwLock};
use tower_http::cors::{AllowOrigin, Any, CorsLayer};
use tracing::error;
use webbrowser;
type CancellationStore = Arc<RwLock<std::collections::HashMap<String, tokio::task::AbortHandle>>>;
#[derive(Clone)]
struct AppState {
agent: Arc<Agent>,
cancellations: CancellationStore,
auth_token: Option<String>,
ws_token: String,
}
#[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 },
}
async fn auth_middleware(
State(state): State<AppState>,
req: Request,
next: Next,
) -> Result<Response, StatusCode> {
if req.uri().path() == "/api/health" {
return Ok(next.run(req).await);
}
let Some(ref expected_token) = state.auth_token else {
return Ok(next.run(req).await);
};
if let Some(auth_header) = req.headers().get("authorization") {
if let Ok(auth_str) = auth_header.to_str() {
if let Some(token) = auth_str.strip_prefix("Bearer ") {
if token == expected_token {
return Ok(next.run(req).await);
}
}
if let Some(basic_token) = auth_str.strip_prefix("Basic ") {
if let Ok(decoded) = base64::engine::general_purpose::STANDARD.decode(basic_token) {
if let Ok(credentials) = String::from_utf8(decoded) {
if credentials.ends_with(expected_token) {
return Ok(next.run(req).await);
}
}
}
}
}
}
let mut response = Response::new("Authentication required".into());
*response.status_mut() = StatusCode::UNAUTHORIZED;
response.headers_mut().insert(
"WWW-Authenticate",
"Basic realm=\"Goose Web Interface\"".parse().unwrap(),
);
Ok(response)
}
fn is_loopback_address(host: &str) -> bool {
(host, 0)
.to_socket_addrs()
.map(|mut addrs| addrs.any(|addr| addr.ip().is_loopback()))
.unwrap_or(false)
}
fn validate_network_auth(host: &str, auth_token: &Option<String>, no_auth: bool) {
if !is_loopback_address(host) && auth_token.is_none() && !no_auth {
eprintln!(
"Error: --auth-token is required when the server is exposed on the network ({}).",
host
);
eprintln!(
"For security, use --auth-token <TOKEN> or bind to a local address (e.g., localhost)."
);
eprintln!("To skip this check, use --no-auth (unsafe).");
std::process::exit(1);
}
}
fn get_provider_and_model() -> (String, String) {
let config = goose::config::Config::global();
let provider_name: String = match config.get_goose_provider() {
Ok(p) => p,
Err(_) => {
eprintln!("No provider configured. Run 'goose configure' first");
std::process::exit(1);
}
};
let model: String = match config.get_goose_model() {
Ok(m) => m,
Err(_) => {
eprintln!("No model configured. Run 'goose configure' first");
std::process::exit(1);
}
};
(provider_name, model)
}
async fn create_agent(provider_name: &str, model: &str) -> Result<Agent> {
let model_config = goose::model::ModelConfig::new(model)?.with_canonical_limits(provider_name);
let agent = Agent::new();
let session_manager = agent.config.session_manager.clone();
let init_session = session_manager
.create_session(
std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")),
"Web Agent Initialization".to_string(),
SessionType::Hidden,
)
.await?;
let enabled_configs = goose::config::get_enabled_extensions();
for config in &enabled_configs {
if let Err(e) = agent.add_extension(config.clone(), &init_session.id).await {
eprintln!("Warning: Failed to load extension {}: {}", config.name(), e);
}
}
let provider = goose::providers::create(provider_name, model_config, enabled_configs).await?;
agent.update_provider(provider, &init_session.id).await?;
Ok(agent)
}
fn build_cors_layer(auth_token: &Option<String>, host: &str, port: u16) -> CorsLayer {
if auth_token.is_none() {
let allowed_origins = [
"http://localhost:3000".parse().unwrap(),
"http://127.0.0.1:3000".parse().unwrap(),
format!("http://{}:{}", host, port).parse().unwrap(),
];
CorsLayer::new()
.allow_origin(AllowOrigin::list(allowed_origins))
.allow_methods(Any)
.allow_headers(Any)
} else {
CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any)
}
}
fn build_router(state: AppState, cors_layer: CorsLayer) -> Router {
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(middleware::from_fn_with_state(
state.clone(),
auth_middleware,
))
.layer(cors_layer)
.with_state(state)
}
pub async fn handle_web(
port: u16,
host: String,
open: bool,
auth_token: Option<String>,
no_auth: bool,
) -> Result<()> {
validate_network_auth(&host, &auth_token, no_auth);
crate::logging::setup_logging(Some("goose-web"))?;
let (provider_name, model) = get_provider_and_model();
let agent = create_agent(&provider_name, &model).await?;
let ws_token = if auth_token.is_none() {
uuid::Uuid::new_v4().to_string()
} else {
String::new()
};
let state = AppState {
agent: Arc::new(agent),
cancellations: Arc::new(RwLock::new(std::collections::HashMap::new())),
auth_token: auth_token.clone(),
ws_token,
};
let cors_layer = build_cors_layer(&auth_token, &host, port);
let app = build_router(state, cors_layer);
let addr = (host.as_str(), port)
.to_socket_addrs()?
.next()
.ok_or_else(|| anyhow::anyhow!("Could not resolve address: {}", host))?;
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 {
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(
State(state): State<AppState>,
uri: Uri,
) -> Result<Redirect, (http::StatusCode, String)> {
let session = state
.agent
.config
.session_manager
.create_session(
std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")),
"Web session".to_string(),
SessionType::User,
)
.await
.map_err(|err| (http::StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))?;
let redirect_url = if let Some(query) = uri.query() {
format!("/session/{}?{}", session.id, query)
} else {
format!("/session/{}", session.id)
};
Ok(Redirect::to(&redirect_url))
}
async fn serve_session(
axum::extract::Path(session_name): axum::extract::Path<String>,
State(state): State<AppState>,
) -> Html<String> {
let html = include_str!("../../static/index.html");
let html_with_session = html.replace(
"<script src=\"/static/script.js\"></script>",
&format!(
"<script>window.GOOSE_SESSION_NAME = '{}'; window.GOOSE_WS_TOKEN = '{}';</script>\n <script src=\"/static/script.js\"></script>",
session_name,
state.ws_token
)
);
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(),
"img/logo_dark.png" => (
[("content-type", "image/png")],
include_bytes!("../../../../documentation/static/img/logo_dark.png").to_vec(),
)
.into_response(),
"img/logo_light.png" => (
[("content-type", "image/png")],
include_bytes!("../../../../documentation/static/img/logo_light.png").to_vec(),
)
.into_response(),
_ => (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(State(state): State<AppState>) -> Json<serde_json::Value> {
match state.agent.config.session_manager.list_sessions().await {
Ok(sessions) => {
let mut session_info = Vec::new();
for session in sessions {
session_info.push(serde_json::json!({
"name": session.id,
"path": session.id,
"description": session.name,
"message_count": session.message_count,
"working_dir": session.working_dir
}));
}
Json(serde_json::json!({
"sessions": session_info
}))
}
Err(e) => Json(serde_json::json!({
"error": e.to_string()
})),
}
}
async fn get_session(
State(state): State<AppState>,
axum::extract::Path(session_id): axum::extract::Path<String>,
) -> Json<serde_json::Value> {
match state
.agent
.config
.session_manager
.get_session(&session_id, true)
.await
{
Ok(session) => Json(serde_json::json!({
"metadata": session,
"messages": session.conversation.unwrap_or_default().messages()
})),
Err(e) => Json(serde_json::json!({
"error": e.to_string()
})),
}
}
#[derive(Deserialize)]
struct WsQuery {
token: Option<String>,
}
async fn websocket_handler(
ws: WebSocketUpgrade,
State(state): State<AppState>,
Query(query): Query<WsQuery>,
) -> Result<impl IntoResponse, StatusCode> {
if state.auth_token.is_none() {
let provided_token = query.token.as_deref().unwrap_or("");
if provided_token != state.ws_token {
tracing::warn!("WebSocket connection rejected: invalid token");
return Err(StatusCode::FORBIDDEN);
}
}
Ok(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) => {
handle_text_message(&text.to_string(), &sender, &state).await;
}
Message::Close(_) => break,
_ => {}
}
} else {
break;
}
}
}
async fn handle_text_message(
text: &str,
sender: &Arc<Mutex<futures::stream::SplitSink<WebSocket, Message>>>,
state: &AppState,
) {
match serde_json::from_str::<WebSocketMessage>(text) {
Ok(WebSocketMessage::Message {
content,
session_id,
..
}) => {
handle_user_message(content, session_id, sender.clone(), state).await;
}
Ok(WebSocketMessage::Cancel { session_id }) => {
handle_cancel_message(session_id, sender, state).await;
}
Ok(_) => {}
Err(e) => {
error!("Failed to parse WebSocket message: {}", e);
}
}
}
async fn handle_user_message(
content: String,
session_id: String,
sender: Arc<Mutex<futures::stream::SplitSink<WebSocket, Message>>>,
state: &AppState,
) {
let agent = state.agent.clone();
let session_id_clone = session_id.clone();
let task_handle = tokio::spawn(async move {
let result = process_message_streaming(&agent, session_id_clone, content, sender).await;
if let Err(e) = result {
error!("Error processing message: {}", e);
}
});
{
let mut cancellations = state.cancellations.write().await;
cancellations.insert(session_id.clone(), task_handle.abort_handle());
}
let cancellations_for_cleanup = state.cancellations.clone();
let session_id_for_cleanup = session_id;
tokio::spawn(async move {
if let Err(e) = task_handle.await {
if e.is_cancelled() {
tracing::debug!("Task was cancelled");
} else {
error!("Task error: {}", e);
}
}
let mut cancellations = cancellations_for_cleanup.write().await;
cancellations.remove(&session_id_for_cleanup);
});
}
async fn handle_cancel_message(
session_id: String,
sender: &Arc<Mutex<futures::stream::SplitSink<WebSocket, Message>>>,
state: &AppState,
) {
let abort_handle = {
let mut cancellations = state.cancellations.write().await;
cancellations.remove(&session_id)
};
if let Some(handle) = abort_handle {
handle.abort();
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;
}
}
async fn process_message_streaming(
agent: &Agent,
session_id: String,
content: String,
sender: Arc<Mutex<futures::stream::SplitSink<WebSocket, Message>>>,
) -> Result<()> {
use goose::agents::SessionConfig;
let user_message = GooseMessage::user().with_text(content.clone());
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 session = agent
.config
.session_manager
.get_session(&session_id, true)
.await?;
let mut messages = session.conversation.unwrap_or_default();
messages.push(user_message.clone());
let session_config = SessionConfig {
id: session.id.clone(),
schedule_id: None,
max_turns: None,
retry_config: None,
};
match agent.reply(user_message, session_config, None).await {
Ok(mut stream) => {
while let Some(result) = stream.next().await {
match result {
Ok(AgentEvent::Message(message)) => {
process_agent_message(&message, &sender, agent).await;
}
Ok(AgentEvent::HistoryReplaced(_)) => {
tracing::info!("History replaced, compacting happened in reply");
}
Ok(AgentEvent::McpNotification(_)) => {
tracing::info!("Received MCP notification in web interface");
}
Ok(AgentEvent::ModelChange { model, mode }) => {
tracing::info!("Model changed to {} in {} mode", model, mode);
}
Err(e) => {
error!("Error in message stream: {}", e);
send_error(&sender, &format!("Error: {}", e)).await;
break;
}
}
}
}
Err(e) => {
error!("Error calling agent: {}", e);
send_error(&sender, &format!("Error: {}", e)).await;
}
}
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(())
}
async fn process_agent_message(
message: &GooseMessage,
sender: &Arc<Mutex<futures::stream::SplitSink<WebSocket, Message>>>,
agent: &Agent,
) {
use goose::conversation::message::MessageContent;
for content in &message.content {
match content {
MessageContent::Text(text) => {
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) => {
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.to_string(),
arguments: Value::from(tool_call.arguments.clone()),
})
.unwrap()
.into(),
))
.await;
}
}
MessageContent::ToolResponse(_) => {}
MessageContent::ToolConfirmationRequest(confirmation) => {
{
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.to_string(),
arguments: Value::from(confirmation.arguments.clone()),
needs_confirmation: true,
})
.unwrap()
.into(),
))
.await;
}
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) => {
let mut sender = sender.lock().await;
let _ = sender
.send(Message::Text(
serde_json::to_string(&WebSocketMessage::Thinking {
message: thinking.thinking.clone(),
})
.unwrap()
.into(),
))
.await;
}
_ => {}
}
}
}
async fn send_error(
sender: &Arc<Mutex<futures::stream::SplitSink<WebSocket, Message>>>,
message: &str,
) {
let mut sender = sender.lock().await;
let _ = sender
.send(Message::Text(
serde_json::to_string(&WebSocketMessage::Error {
message: message.to_string(),
})
.unwrap()
.into(),
))
.await;
}
-46
View File
@@ -1,46 +0,0 @@
<!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>
-548
View File
@@ -1,548 +0,0 @@
// 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
// 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 token = window.GOOSE_WS_TOKEN || '';
const wsUrl = `${protocol}//${window.location.host}/ws?token=${encodeURIComponent(token)}`;
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';
const isShellTool = data.tool_name === 'shell';
const isDeveloperFileTool = [
'read',
'write',
'edit'
].includes(data.tool_name);
// Format the arguments
if (isShellTool && data.arguments.command) {
contentDiv.innerHTML = `<pre><code>${escapeHtml(data.arguments.command)}</code></pre>`;
} else if (isDeveloperFileTool) {
const action = data.arguments.command || data.tool_name;
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();
// Read 'q' parameter from URL and set it to the message input
function getQueryParam() {
const urlParams = new URLSearchParams(window.location.search);
const queryParam = urlParams.get('q');
if (queryParam) {
messageInput.value = queryParam;
urlParams.delete('q');
let newUrl = window.location.pathname;
if (urlParams.toString()) {
newUrl = `${window.location.pathname}?${urlParams.toString()}`;
}
window.history.replaceState({}, '', newUrl);
}
}
getQueryParam();
// 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();
-490
View File
@@ -1,490 +0,0 @@
: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;
}
header h1::before {
background-image: url('/static/img/logo_light.png');
}
}
* {
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: "";
width: 32px;
height: 32px;
background-image: url('/static/img/logo_dark.png');
background-size: contain;
background-repeat: no-repeat;
background-position: center;
display: inline-block;
}
.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;
}
}
@@ -591,57 +591,6 @@ Choose one of your projects to start working on.
goose projects
```
---
### Interface
#### web
Start a new session in goose Web, a lightweight web-based interface launched via the CLI that mirrors the desktop app's chat experience.
goose Web is particularly useful when:
- You want to access goose with a graphical interface without installing the desktop app
- You need to use goose from different devices, including mobile
- You're working in an environment where installing desktop apps isn't practical
:::warning
Don't expose the web interface to the internet without proper security measures.
:::
**Options:**
- **`-p, --port <PORT>`**: Port number to run the web server on. Default is `3000`
- **`--host <HOST>`**: Host to bind the web server to. Default is `127.0.0.1`
- **`--open`**: Automatically open the browser when the server starts
- **`--auth-token <TOKEN>`**: Require a password to access the web interface
**Usage:**
```bash
# Start web interface at `http://127.0.0.1:3000` and open the browser
goose web --open
# Start web interface at `http://127.0.0.1:8080`
goose web --port 8080
# Start web interface accessible from local network at `http://192.168.1.7:8080`
goose web --host 192.168.1.7 --port 8080
# Start web interface with authentication required
goose web --auth-token <TOKEN>
```
:::info
Use `Ctrl+C` to stop the server.
:::
**Limitations:**
While the web interface provides most core features, be aware of these limitations:
- Some file system operations may require additional confirmation
- Extension management must be done through the CLI
- Certain tool interactions might need extra setup
- Configuration changes require a server restart
---
### Terminal Integration
@@ -76,10 +76,6 @@ In your first session, goose prompts you to [set up an LLM (Large Language Model
goose session
```
If you want to interact with goose in a web-based chat interface, start a session with the [`web`](/docs/guides/goose-cli-commands#web) command:
```sh
goose web --open
```
</TabItem>
</Tabs>
-7
View File
@@ -230,13 +230,6 @@ Sessions are single, continuous conversations between you and goose. Let's start
goose session
```
:::tip goose Web
CLI users can also start a session in [goose Web](/docs/guides/goose-cli-commands#web), a web-based chat interface:
```sh
goose web --open
```
:::
</TabItem>
</Tabs>
-26
View File
@@ -1,26 +0,0 @@
#!/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