feat: sessions api, view & resume prev sessions (#1453)

* Centralize session files to goose::session module
* Write session metadata and messages in jsonl
* Refactor CLI build_session to use goose::session functions
* Track session's token usage by adding optional session_id in agent.reply(...)
* NOTE: Only sessions saved through the updates goose::session functions will show up in GUI

Co-authored-by: Bradley Axen <baxen@squareup.com>
This commit is contained in:
Salman Mohammed
2025-03-03 11:49:15 -05:00
committed by GitHub
parent 68b8c5d19d
commit 9ae9045584
25 changed files with 1413 additions and 257 deletions

View File

@@ -2,16 +2,16 @@ use console::style;
use goose::agents::extension::ExtensionError;
use goose::agents::AgentFactory;
use goose::config::{Config, ExtensionManager};
use goose::session;
use goose::session::Identifier;
use mcp_client::transport::Error as McpClientError;
use std::path::PathBuf;
use std::process;
use super::output;
use super::storage;
use super::Session;
pub async fn build_session(
identifier: Option<storage::Identifier>,
identifier: Option<Identifier>,
resume: bool,
extensions: Vec<String>,
builtins: Vec<String>,
@@ -65,7 +65,7 @@ pub async fn build_session(
// Handle session file resolution and resuming
let session_file = if resume {
if let Some(identifier) = identifier {
let session_file = storage::get_path(identifier);
let session_file = session::get_path(identifier);
if !session_file.exists() {
output::render_error(&format!(
"Cannot resume session {} - no such session exists",
@@ -76,7 +76,7 @@ pub async fn build_session(
session_file
} else {
// Try to resume most recent session
match storage::get_most_recent_session() {
match session::get_most_recent_session() {
Ok(file) => file,
Err(_) => {
output::render_error("Cannot resume - no previous sessions found");
@@ -88,10 +88,11 @@ pub async fn build_session(
// Create new session with provided name/path or generated name
let id = match identifier {
Some(identifier) => identifier,
None => storage::Identifier::Name(generate_session_name()),
None => Identifier::Name(session::generate_session_id()),
};
let session_file = storage::get_path(id);
create_new_session_file(session_file)
// Just get the path - file will be created when needed
session::get_path(id)
};
// Create new session
@@ -130,20 +131,3 @@ pub async fn build_session(
output::display_session_info(resume, &provider_name, &model, &session_file);
session
}
fn generate_session_name() -> String {
use rand::{distributions::Alphanumeric, Rng};
rand::thread_rng()
.sample_iter(&Alphanumeric)
.take(8)
.map(char::from)
.collect()
}
fn create_new_session_file(session_file: PathBuf) -> PathBuf {
if session_file.exists() {
eprintln!("Session '{:?}' already exists", session_file);
process::exit(1);
}
session_file
}

View File

@@ -3,11 +3,10 @@ mod completion;
mod input;
mod output;
mod prompt;
mod storage;
mod thinking;
pub use builder::build_session;
pub use storage::Identifier;
pub use goose::session::Identifier;
use anyhow::Result;
use completion::GooseCompleter;
@@ -16,6 +15,7 @@ use etcetera::AppStrategy;
use goose::agents::extension::{Envs, ExtensionConfig};
use goose::agents::Agent;
use goose::message::{Message, MessageContent};
use goose::session;
use mcp_core::handler::ToolError;
use mcp_core::prompt::PromptMessage;
@@ -56,7 +56,7 @@ impl CompletionCache {
impl Session {
pub fn new(agent: Box<dyn Agent>, session_file: PathBuf) -> Self {
let messages = match storage::read_messages(&session_file) {
let messages = match session::read_messages(&session_file) {
Ok(msgs) => msgs,
Err(e) => {
eprintln!("Warning: Failed to load message history: {}", e);
@@ -196,7 +196,13 @@ impl Session {
/// Process a single message and get the response
async fn process_message(&mut self, message: String) -> Result<()> {
self.messages.push(Message::user().with_text(&message));
storage::persist_messages(&self.session_file, &self.messages)?;
// Get the provider from the agent for description generation
let provider = self.agent.provider().await;
// Persist messages with provider for automatic description generation
session::persist_messages(&self.session_file, &self.messages, Some(provider)).await?;
self.process_agent_response(false).await?;
Ok(())
}
@@ -260,7 +266,13 @@ impl Session {
save_history(&mut editor);
self.messages.push(Message::user().with_text(&content));
storage::persist_messages(&self.session_file, &self.messages)?;
// Get the provider from the agent for description generation
let provider = self.agent.provider().await;
// Persist messages with provider for automatic description generation
session::persist_messages(&self.session_file, &self.messages, Some(provider))
.await?;
output::show_thinking();
self.process_agent_response(true).await?;
@@ -399,7 +411,8 @@ impl Session {
}
async fn process_agent_response(&mut self, interactive: bool) -> Result<()> {
let mut stream = self.agent.reply(&self.messages).await?;
let session_id = session::Identifier::Path(self.session_file.clone());
let mut stream = self.agent.reply(&self.messages, Some(session_id)).await?;
use futures::StreamExt;
loop {
@@ -421,7 +434,10 @@ impl Session {
// otherwise we have a model/tool to render
else {
self.messages.push(message.clone());
storage::persist_messages(&self.session_file, &self.messages)?;
// No need to update description on assistant messages
session::persist_messages(&self.session_file, &self.messages, None).await?;
if interactive {output::hide_thinking()};
output::render_message(&message);
if interactive {output::show_thinking()};
@@ -430,7 +446,9 @@ impl Session {
Some(Err(e)) => {
eprintln!("Error: {}", e);
drop(stream);
self.handle_interrupted_messages(false);
if let Err(e) = self.handle_interrupted_messages(false).await {
eprintln!("Error handling interruption: {}", e);
}
output::render_error(
"The error above was an exception we were not able to handle.\n\
These errors are often related to connection or authentication\n\
@@ -444,7 +462,9 @@ impl Session {
}
_ = tokio::signal::ctrl_c() => {
drop(stream);
self.handle_interrupted_messages(true);
if let Err(e) = self.handle_interrupted_messages(true).await {
eprintln!("Error handling interruption: {}", e);
}
break;
}
}
@@ -452,7 +472,7 @@ impl Session {
Ok(())
}
fn handle_interrupted_messages(&mut self, interrupt: bool) {
async fn handle_interrupted_messages(&mut self, interrupt: bool) -> Result<()> {
// First, get any tool requests from the last message if it exists
let tool_requests = self
.messages
@@ -493,11 +513,18 @@ impl Session {
}
self.messages.push(response_message);
// No need for description update here
session::persist_messages(&self.session_file, &self.messages, None).await?;
let prompt = format!(
"The existing call to {} was interrupted. How would you like to proceed?",
last_tool_name
);
self.messages.push(Message::assistant().with_text(&prompt));
// No need for description update here
session::persist_messages(&self.session_file, &self.messages, None).await?;
output::render_message(&Message::assistant().with_text(&prompt));
} else {
// An interruption occurred outside of a tool request-response.
@@ -508,6 +535,11 @@ impl Session {
// Interruption occurred after a tool had completed but not assistant reply
let prompt = "The tool calling loop was interrupted. How would you like to proceed?";
self.messages.push(Message::assistant().with_text(prompt));
// No need for description update here
session::persist_messages(&self.session_file, &self.messages, None)
.await?;
output::render_message(&Message::assistant().with_text(prompt));
}
Some(_) => {
@@ -521,6 +553,7 @@ impl Session {
}
}
}
Ok(())
}
pub fn session_file(&self) -> PathBuf {

View File

@@ -1,180 +0,0 @@
use anyhow::Result;
use etcetera::{choose_app_strategy, AppStrategy};
use goose::message::Message;
use std::fs::{self, File};
use std::io::{self, BufRead, Write};
use std::path::{Path, PathBuf};
pub enum Identifier {
Name(String),
Path(PathBuf),
}
pub fn get_path(id: Identifier) -> PathBuf {
match id {
Identifier::Name(name) => {
let session_dir = ensure_session_dir().expect("Failed to create session directory");
session_dir.join(format!("{}.jsonl", name))
}
Identifier::Path(path) => path,
}
}
/// Ensure the session directory exists and return its path
pub fn ensure_session_dir() -> Result<PathBuf> {
let data_dir = choose_app_strategy(crate::APP_STRATEGY.clone())
.expect("goose requires a home dir")
.data_dir()
.join("sessions");
if !data_dir.exists() {
fs::create_dir_all(&data_dir)?;
}
Ok(data_dir)
}
/// Get the path to the most recently modified session file
pub fn get_most_recent_session() -> Result<PathBuf> {
let session_dir = ensure_session_dir()?;
let mut entries = fs::read_dir(&session_dir)?
.filter_map(|entry| entry.ok())
.filter(|entry| entry.path().extension().is_some_and(|ext| ext == "jsonl"))
.collect::<Vec<_>>();
if entries.is_empty() {
return Err(anyhow::anyhow!("No session files found"));
}
// Sort by modification time, most recent first
entries.sort_by(|a, b| {
b.metadata()
.and_then(|m| m.modified())
.unwrap_or(std::time::SystemTime::UNIX_EPOCH)
.cmp(
&a.metadata()
.and_then(|m| m.modified())
.unwrap_or(std::time::SystemTime::UNIX_EPOCH),
)
});
Ok(entries[0].path())
}
/// Read messages from a session file
///
/// Creates the file if it doesn't exist, reads and deserializes all messages if it does.
pub fn read_messages(session_file: &Path) -> Result<Vec<Message>> {
let file = fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(session_file)?;
let reader = io::BufReader::new(file);
let mut messages = Vec::new();
for line in reader.lines() {
messages.push(serde_json::from_str::<Message>(&line?)?);
}
Ok(messages)
}
/// Write messages to a session file
///
/// Overwrites the file with all messages in JSONL format.
pub fn persist_messages(session_file: &Path, messages: &[Message]) -> Result<()> {
let file = File::create(session_file).expect("The path specified does not exist");
let mut writer = io::BufWriter::new(file);
for message in messages {
serde_json::to_writer(&mut writer, &message)?;
writeln!(writer)?;
}
writer.flush()?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use goose::message::MessageContent;
use tempfile::tempdir;
#[test]
fn test_read_write_messages() -> Result<()> {
let dir = tempdir()?;
let file_path = dir.path().join("test.jsonl");
// Create some test messages
let messages = vec![
Message::user().with_text("Hello"),
Message::assistant().with_text("Hi there"),
];
// Write messages
persist_messages(&file_path, &messages)?;
// Read them back
let read_messages = read_messages(&file_path)?;
// Compare
assert_eq!(messages.len(), read_messages.len());
for (orig, read) in messages.iter().zip(read_messages.iter()) {
assert_eq!(orig.role, read.role);
assert_eq!(orig.content.len(), read.content.len());
// Compare first text content
if let (Some(MessageContent::Text(orig_text)), Some(MessageContent::Text(read_text))) =
(orig.content.first(), read.content.first())
{
assert_eq!(orig_text.text, read_text.text);
} else {
panic!("Messages don't match expected structure");
}
}
Ok(())
}
#[test]
fn test_empty_file() -> Result<()> {
let dir = tempdir()?;
let file_path = dir.path().join("empty.jsonl");
// Reading an empty file should return empty vec
let messages = read_messages(&file_path)?;
assert!(messages.is_empty());
Ok(())
}
#[test]
fn test_get_most_recent() -> Result<()> {
let dir = tempdir()?;
let base_path = dir.path().join("sessions");
fs::create_dir_all(&base_path)?;
// Create a few session files with different timestamps
let old_file = base_path.join("old.jsonl");
let new_file = base_path.join("new.jsonl");
// Create files with some delay to ensure different timestamps
fs::write(&old_file, "dummy content")?;
std::thread::sleep(std::time::Duration::from_secs(1));
fs::write(&new_file, "dummy content")?;
// Override the home directory for testing
// This is a bit hacky but works for testing
std::env::set_var("HOME", dir.path());
if let Ok(most_recent) = get_most_recent_session() {
assert_eq!(most_recent.file_name().unwrap(), "new.jsonl");
}
Ok(())
}
}

View File

@@ -5,6 +5,7 @@ pub mod configs;
pub mod extension;
pub mod health;
pub mod reply;
pub mod session;
use axum::Router;
@@ -16,5 +17,6 @@ pub fn configure(state: crate::state::AppState) -> Router {
.merge(agent::routes(state.clone()))
.merge(extension::routes(state.clone()))
.merge(configs::routes(state.clone()))
.merge(config_management::routes(state))
.merge(config_management::routes(state.clone()))
.merge(session::routes(state))
}

View File

@@ -9,6 +9,7 @@ use axum::{
use bytes::Bytes;
use futures::{stream::StreamExt, Stream};
use goose::message::{Message, MessageContent};
use goose::session;
use mcp_core::role::Role;
use serde::{Deserialize, Serialize};
@@ -27,6 +28,7 @@ use tokio_stream::wrappers::ReceiverStream;
#[derive(Debug, Deserialize)]
struct ChatRequest {
messages: Vec<Message>,
session_id: Option<String>,
}
// Custom SSE response type for streaming messages
@@ -109,6 +111,11 @@ async fn handler(
// Get messages directly from the request
let messages = request.messages;
// Generate a new session ID if not provided in the request
let session_id = request
.session_id
.unwrap_or_else(session::generate_session_id);
// Get a lock on the shared agent
let agent = state.agent.clone();
@@ -136,7 +143,16 @@ async fn handler(
}
};
let mut stream = match agent.reply(&messages).await {
// Get the provider first, before starting the reply stream
let provider = agent.provider().await;
let mut stream = match agent
.reply(
&messages,
Some(session::Identifier::Name(session_id.clone())),
)
.await
{
Ok(stream) => stream,
Err(e) => {
tracing::error!("Failed to start reply stream: {:?}", e);
@@ -158,11 +174,16 @@ async fn handler(
}
};
// Collect all messages for storage
let mut all_messages = messages.clone();
let session_path = session::get_path(session::Identifier::Name(session_id.clone()));
loop {
tokio::select! {
response = timeout(Duration::from_millis(500), stream.next()) => {
match response {
Ok(Some(Ok(message))) => {
all_messages.push(message.clone());
if let Err(e) = stream_event(MessageEvent::Message { message }, &tx).await {
tracing::error!("Error sending message through channel: {}", e);
let _ = stream_event(
@@ -173,6 +194,16 @@ async fn handler(
).await;
break;
}
// Store messages and generate description in background
let session_path = session_path.clone();
let messages = all_messages.clone();
let provider = provider.clone();
tokio::spawn(async move {
if let Err(e) = session::persist_messages(&session_path, &messages, Some(provider)).await {
tracing::error!("Failed to store session history: {:?}", e);
}
});
}
Ok(Some(Err(e))) => {
tracing::error!("Error processing message: {}", e);
@@ -214,6 +245,7 @@ async fn handler(
#[derive(Debug, Deserialize, Serialize)]
struct AskRequest {
prompt: String,
session_id: Option<String>,
}
#[derive(Debug, Serialize)]
@@ -237,16 +269,30 @@ async fn ask_handler(
return Err(StatusCode::UNAUTHORIZED);
}
// Generate a new session ID if not provided in the request
let session_id = request
.session_id
.unwrap_or_else(session::generate_session_id);
let agent = state.agent.clone();
let agent = agent.write().await;
let agent = agent.as_ref().ok_or(StatusCode::NOT_FOUND)?;
// Get the provider first, before starting the reply stream
let provider = agent.provider().await;
// Create a single message for the prompt
let messages = vec![Message::user().with_text(request.prompt)];
// Get response from agent
let mut response_text = String::new();
let mut stream = match agent.reply(&messages).await {
let mut stream = match agent
.reply(
&messages,
Some(session::Identifier::Name(session_id.clone())),
)
.await
{
Ok(stream) => stream,
Err(e) => {
tracing::error!("Failed to start reply stream: {:?}", e);
@@ -254,15 +300,20 @@ async fn ask_handler(
}
};
// Collect all messages for storage
let mut all_messages = messages.clone();
let mut response_message = Message::assistant();
while let Some(response) = stream.next().await {
match response {
Ok(message) => {
if message.role == Role::Assistant {
for content in message.content {
for content in &message.content {
if let MessageContent::Text(text) = content {
response_text.push_str(&text.text);
response_text.push('\n');
}
response_message.content.push(content.clone());
}
}
}
@@ -273,6 +324,24 @@ async fn ask_handler(
}
}
// Add the complete response message to the conversation history
if !response_message.content.is_empty() {
all_messages.push(response_message);
}
// Get the session path - file will be created when needed
let session_path = session::get_path(session::Identifier::Name(session_id.clone()));
// Store messages and generate description in background
let session_path = session_path.clone();
let messages = all_messages.clone();
let provider = provider.clone();
tokio::spawn(async move {
if let Err(e) = session::persist_messages(&session_path, &messages, Some(provider)).await {
tracing::error!("Failed to store session history: {:?}", e);
}
});
Ok(Json(AskResponse {
response: response_text.trim().to_string(),
}))
@@ -394,6 +463,7 @@ mod tests {
.body(Body::from(
serde_json::to_string(&AskRequest {
prompt: "test prompt".to_string(),
session_id: Some("test-session".to_string()),
})
.unwrap(),
))

View File

@@ -0,0 +1,128 @@
use crate::state::AppState;
use axum::{
extract::{Path, State},
http::{HeaderMap, StatusCode},
routing::get,
Json, Router,
};
use goose::message::Message;
use goose::session;
use serde::Serialize;
#[derive(Serialize)]
struct SessionInfo {
id: String,
path: String,
modified: String,
metadata: session::SessionMetadata,
}
#[derive(Serialize)]
struct SessionListResponse {
sessions: Vec<SessionInfo>,
}
#[derive(Serialize)]
struct SessionHistoryResponse {
session_id: String,
metadata: session::SessionMetadata,
messages: Vec<Message>,
}
// List all available sessions
async fn list_sessions(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Json<SessionListResponse>, StatusCode> {
// Verify secret key
let secret_key = headers
.get("X-Secret-Key")
.and_then(|value| value.to_str().ok())
.ok_or(StatusCode::UNAUTHORIZED)?;
if secret_key != state.secret_key {
return Err(StatusCode::UNAUTHORIZED);
}
let sessions = match session::list_sessions() {
Ok(sessions) => sessions,
Err(e) => {
tracing::error!("Failed to list sessions: {:?}", e);
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
};
let session_infos = sessions
.into_iter()
.map(|(id, path)| {
// Get last modified time as string
let modified = path
.metadata()
.and_then(|m| m.modified())
.map(|time| {
chrono::DateTime::<chrono::Utc>::from(time)
.format("%Y-%m-%d %H:%M:%S UTC")
.to_string()
})
.unwrap_or_else(|_| "Unknown".to_string());
// Get session description
let metadata = session::read_metadata(&path).expect("Failed to read session metadata");
SessionInfo {
id,
path: path.to_string_lossy().to_string(),
modified,
metadata,
}
})
.collect();
Ok(Json(SessionListResponse {
sessions: session_infos,
}))
}
// Get a specific session's history
async fn get_session_history(
State(state): State<AppState>,
headers: HeaderMap,
Path(session_id): Path<String>,
) -> Result<Json<SessionHistoryResponse>, StatusCode> {
// Verify secret key
let secret_key = headers
.get("X-Secret-Key")
.and_then(|value| value.to_str().ok())
.ok_or(StatusCode::UNAUTHORIZED)?;
if secret_key != state.secret_key {
return Err(StatusCode::UNAUTHORIZED);
}
let session_path = session::get_path(session::Identifier::Name(session_id.clone()));
// Read metadata
let metadata = session::read_metadata(&session_path).map_err(|_| StatusCode::NOT_FOUND)?;
let messages = match session::read_messages(&session_path) {
Ok(messages) => messages,
Err(e) => {
tracing::error!("Failed to read session messages: {:?}", e);
return Err(StatusCode::NOT_FOUND);
}
};
Ok(Json(SessionHistoryResponse {
session_id,
metadata,
messages,
}))
}
// Configure routes for this module
pub fn routes(state: AppState) -> Router {
Router::new()
.route("/sessions", get(list_sessions))
.route("/sessions/:session_id", get(get_session_history))
.with_state(state)
}

View File

@@ -30,7 +30,7 @@ async fn main() {
let messages = vec![Message::user()
.with_text("can you summarize the readme.md in this dir using just a haiku?")];
let mut stream = agent.reply(&messages).await.unwrap();
let mut stream = agent.reply(&messages, None).await.unwrap();
while let Some(message) = stream.next().await {
println!(
"{}",

View File

@@ -4,10 +4,12 @@ use anyhow::Result;
use async_trait::async_trait;
use futures::stream::BoxStream;
use serde_json::Value;
use std::sync::Arc;
use super::extension::{ExtensionConfig, ExtensionResult};
use crate::message::Message;
use crate::providers::base::ProviderUsage;
use crate::providers::base::{Provider, ProviderUsage};
use crate::session;
use mcp_core::prompt::Prompt;
use mcp_core::protocol::GetPromptResult;
@@ -15,7 +17,11 @@ use mcp_core::protocol::GetPromptResult;
#[async_trait]
pub trait Agent: Send + Sync {
/// Create a stream that yields each message as it's generated by the agent
async fn reply(&self, messages: &[Message]) -> Result<BoxStream<'_, Result<Message>>>;
async fn reply(
&self,
messages: &[Message],
session_id: Option<session::Identifier>,
) -> Result<BoxStream<'_, Result<Message>>>;
/// Add a new MCP client to the agent
async fn add_extension(&mut self, config: ExtensionConfig) -> ExtensionResult<()>;
@@ -48,4 +54,7 @@ pub trait Agent: Send + Sync {
/// Get a prompt result with the given name and arguments
/// Returns the prompt text that would be used as user input
async fn get_prompt(&self, name: &str, arguments: Value) -> Result<GetPromptResult>;
/// Get a reference to the provider used by this agent
async fn provider(&self) -> Arc<Box<dyn Provider>>;
}

View File

@@ -30,7 +30,7 @@ pub struct Capabilities {
clients: HashMap<String, McpClientBox>,
instructions: HashMap<String, String>,
resource_capable_extensions: HashSet<String>,
provider: Box<dyn Provider>,
provider: Arc<Box<dyn Provider>>,
provider_usage: Mutex<Vec<ProviderUsage>>,
system_prompt_override: Option<String>,
system_prompt_extensions: Vec<String>,
@@ -90,7 +90,7 @@ impl Capabilities {
clients: HashMap::new(),
instructions: HashMap::new(),
resource_capable_extensions: HashSet::new(),
provider,
provider: Arc::new(provider),
provider_usage: Mutex::new(Vec::new()),
system_prompt_override: None,
system_prompt_extensions: Vec::new(),
@@ -202,8 +202,8 @@ impl Capabilities {
}
/// Get a reference to the provider
pub fn provider(&self) -> &dyn Provider {
&*self.provider
pub fn provider(&self) -> Arc<Box<dyn Provider>> {
Arc::clone(&self.provider)
}
/// Record provider usage

View File

@@ -3,6 +3,7 @@
use async_trait::async_trait;
use futures::stream::BoxStream;
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::Mutex;
use tracing::{debug, instrument};
@@ -12,8 +13,8 @@ use crate::agents::extension::{ExtensionConfig, ExtensionResult};
use crate::message::{Message, ToolRequest};
use crate::providers::base::Provider;
use crate::providers::base::ProviderUsage;
use crate::register_agent;
use crate::token_counter::TokenCounter;
use crate::{register_agent, session};
use anyhow::{anyhow, Result};
use indoc::indoc;
use mcp_core::prompt::Prompt;
@@ -73,6 +74,7 @@ impl Agent for ReferenceAgent {
async fn reply(
&self,
messages: &[Message],
session_id: Option<session::Identifier>,
) -> anyhow::Result<BoxStream<'_, anyhow::Result<Message>>> {
let mut messages = messages.to_vec();
let reply_span = tracing::Span::current();
@@ -143,7 +145,19 @@ impl Agent for ReferenceAgent {
&messages,
&tools,
).await?;
capabilities.record_usage(usage).await;
capabilities.record_usage(usage.clone()).await;
// record usage for the session in the session file
if let Some(session_id) = session_id.clone() {
// TODO: track session_id in langfuse tracing
let session_file = session::get_path(session_id);
let mut metadata = session::read_metadata(&session_file)?;
metadata.total_tokens = usage.usage.total_tokens;
// The message count is the number of messages in the session + 1 for the response
// The message count does not include the tool response till next iteration
metadata.message_count = messages.len() + 1;
session::update_metadata(&session_file, &metadata).await?;
}
// Yield the assistant's response
yield response.clone();
@@ -233,6 +247,11 @@ impl Agent for ReferenceAgent {
Err(anyhow!("Prompt '{}' not found", name))
}
async fn provider(&self) -> Arc<Box<dyn Provider>> {
let capabilities = self.capabilities.lock().await;
capabilities.provider()
}
}
register_agent!("reference", ReferenceAgent);

View File

@@ -3,6 +3,7 @@
use async_trait::async_trait;
use futures::stream::BoxStream;
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::mpsc;
use tokio::sync::Mutex;
use tracing::{debug, error, instrument, warn};
@@ -18,6 +19,7 @@ use crate::providers::base::Provider;
use crate::providers::base::ProviderUsage;
use crate::providers::errors::ProviderError;
use crate::register_agent;
use crate::session;
use crate::token_counter::TokenCounter;
use crate::truncate::{truncate_messages, OldestFirstTruncation};
use anyhow::{anyhow, Result};
@@ -143,10 +145,11 @@ impl Agent for TruncateAgent {
}
}
#[instrument(skip(self, messages), fields(user_message))]
#[instrument(skip(self, messages, session_id), fields(user_message))]
async fn reply(
&self,
messages: &[Message],
session_id: Option<session::Identifier>,
) -> anyhow::Result<BoxStream<'_, anyhow::Result<Message>>> {
let mut messages = messages.to_vec();
let reply_span = tracing::Span::current();
@@ -223,7 +226,19 @@ impl Agent for TruncateAgent {
&tools,
).await {
Ok((response, usage)) => {
capabilities.record_usage(usage).await;
capabilities.record_usage(usage.clone()).await;
// record usage for the session in the session file
if let Some(session_id) = session_id.clone() {
// TODO: track session_id in langfuse tracing
let session_file = session::get_path(session_id);
let mut metadata = session::read_metadata(&session_file)?;
metadata.total_tokens = usage.usage.total_tokens;
// The message count is the number of messages in the session + 1 for the response
// The message count does not include the tool response till next iteration
metadata.message_count = messages.len() + 1;
session::update_metadata(&session_file, &metadata).await?;
}
// Reset truncation attempt
truncation_attempt = 0;
@@ -435,6 +450,11 @@ impl Agent for TruncateAgent {
Err(anyhow!("Prompt '{}' not found", name))
}
async fn provider(&self) -> Arc<Box<dyn Provider>> {
let capabilities = self.capabilities.lock().await;
capabilities.provider()
}
}
register_agent!("truncate", TruncateAgent);

View File

@@ -4,6 +4,7 @@ pub mod message;
pub mod model;
pub mod prompt_template;
pub mod providers;
pub mod session;
pub mod token_counter;
pub mod tracing;
pub mod truncate;

View File

@@ -0,0 +1,8 @@
pub mod storage;
// Re-export common session types and functions
pub use storage::{
ensure_session_dir, generate_description, generate_session_id, get_most_recent_session,
get_path, list_sessions, persist_messages, read_messages, read_metadata, update_metadata,
Identifier, SessionMetadata,
};

View File

@@ -0,0 +1,411 @@
use crate::message::Message;
use crate::providers::base::Provider;
use anyhow::Result;
use chrono::Local;
use etcetera::{choose_app_strategy, AppStrategy, AppStrategyArgs};
use serde::{Deserialize, Serialize};
use std::fs::{self, File};
use std::io::{self, BufRead, Write};
use std::path::{Path, PathBuf};
use std::sync::Arc;
/// Metadata for a session, stored as the first line in the session file
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionMetadata {
/// A short description of the session, typically 3 words or less
pub description: String,
/// Number of messages in the session
pub message_count: usize,
/// The total number of tokens used in the session. Retrieved from the provider's last usage.
pub total_tokens: Option<i32>,
}
impl SessionMetadata {
pub fn new() -> Self {
Self {
description: String::new(),
message_count: 0,
total_tokens: None,
}
}
}
impl Default for SessionMetadata {
fn default() -> Self {
Self::new()
}
}
// The single app name used for all Goose applications
const APP_NAME: &str = "goose";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum Identifier {
Name(String),
Path(PathBuf),
}
pub fn get_path(id: Identifier) -> PathBuf {
match id {
Identifier::Name(name) => {
let session_dir = ensure_session_dir().expect("Failed to create session directory");
session_dir.join(format!("{}.jsonl", name))
}
Identifier::Path(path) => path,
}
}
/// Ensure the session directory exists and return its path
pub fn ensure_session_dir() -> Result<PathBuf> {
let app_strategy = AppStrategyArgs {
top_level_domain: "Block".to_string(),
author: "Block".to_string(),
app_name: APP_NAME.to_string(),
};
let data_dir = choose_app_strategy(app_strategy)
.expect("goose requires a home dir")
.data_dir()
.join("sessions");
if !data_dir.exists() {
fs::create_dir_all(&data_dir)?;
}
Ok(data_dir)
}
/// Get the path to the most recently modified session file
pub fn get_most_recent_session() -> Result<PathBuf> {
let session_dir = ensure_session_dir()?;
let mut entries = fs::read_dir(&session_dir)?
.filter_map(|entry| entry.ok())
.filter(|entry| entry.path().extension().is_some_and(|ext| ext == "jsonl"))
.collect::<Vec<_>>();
if entries.is_empty() {
return Err(anyhow::anyhow!("No session files found"));
}
// Sort by modification time, most recent first
entries.sort_by(|a, b| {
b.metadata()
.and_then(|m| m.modified())
.unwrap_or(std::time::SystemTime::UNIX_EPOCH)
.cmp(
&a.metadata()
.and_then(|m| m.modified())
.unwrap_or(std::time::SystemTime::UNIX_EPOCH),
)
});
Ok(entries[0].path())
}
/// List all available session files
pub fn list_sessions() -> Result<Vec<(String, PathBuf)>> {
let session_dir = ensure_session_dir()?;
let entries = fs::read_dir(&session_dir)?
.filter_map(|entry| {
let entry = entry.ok()?;
let path = entry.path();
if path.extension().is_some_and(|ext| ext == "jsonl") {
let name = path.file_stem()?.to_string_lossy().to_string();
Some((name, path))
} else {
None
}
})
.collect::<Vec<_>>();
Ok(entries)
}
/// Generate a session ID using timestamp format (yyyymmdd_hhmmss)
pub fn generate_session_id() -> String {
Local::now().format("%Y%m%d_%H%M%S").to_string()
}
/// Read messages from a session file
///
/// Creates the file if it doesn't exist, reads and deserializes all messages if it does.
/// The first line of the file is expected to be metadata, and the rest are messages.
pub fn read_messages(session_file: &Path) -> Result<Vec<Message>> {
let file = fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(session_file)?;
let reader = io::BufReader::new(file);
let mut lines = reader.lines();
let mut messages = Vec::new();
// Read the first line as metadata or create default if empty/missing
if let Some(line) = lines.next() {
let line = line?;
// Try to parse as metadata, but if it fails, treat it as a message
if let Ok(_metadata) = serde_json::from_str::<SessionMetadata>(&line) {
// Metadata successfully parsed, continue with the rest of the lines as messages
} else {
// This is not metadata, it's a message
messages.push(serde_json::from_str::<Message>(&line)?);
}
}
// Read the rest of the lines as messages
for line in lines {
messages.push(serde_json::from_str::<Message>(&line?)?);
}
Ok(messages)
}
/// Read session metadata from a session file
///
/// Returns default empty metadata if the file doesn't exist or has no metadata.
pub fn read_metadata(session_file: &Path) -> Result<SessionMetadata> {
if !session_file.exists() {
return Ok(SessionMetadata::new());
}
let file = fs::File::open(session_file)?;
let mut reader = io::BufReader::new(file);
let mut first_line = String::new();
// Read just the first line
if reader.read_line(&mut first_line)? > 0 {
// Try to parse as metadata
match serde_json::from_str::<SessionMetadata>(&first_line) {
Ok(metadata) => Ok(metadata),
Err(_) => {
// If the first line isn't metadata, return default
Ok(SessionMetadata::new())
}
}
} else {
// Empty file, return default
Ok(SessionMetadata::new())
}
}
/// Write messages to a session file with metadata
///
/// Overwrites the file with metadata as the first line, followed by all messages in JSONL format.
/// If a provider is supplied, it will automatically generate a description when appropriate.
pub async fn persist_messages(
session_file: &Path,
messages: &[Message],
provider: Option<Arc<Box<dyn Provider>>>,
) -> Result<()> {
// Read existing metadata
let mut metadata = read_metadata(session_file)?;
// Count user messages
let user_message_count = messages
.iter()
.filter(|m| m.role == mcp_core::role::Role::User)
.filter(|m| !m.as_concat_text().trim().is_empty())
.count();
// Check if we need to update the description (after 1st or 3rd user message)
if let Some(provider) = provider {
if user_message_count < 4 {
// Generate description
let mut description_prompt = "Based on the conversation so far, provide a concise header for this session in 4 words or less. This will be used for finding the session later in a UI with limited space - reply *ONLY* with the header. Avoid filler words such as help, summary, exchange, request etc that do not help distinguish different conversations.".to_string();
// get context from messages so far
let context: Vec<String> = messages.iter().map(|m| m.as_concat_text()).collect();
if !context.is_empty() {
description_prompt = format!(
"Here are the first few user messages:\n{}\n\n{}",
context.join("\n"),
description_prompt
);
}
// Generate the description
let message = Message::user().with_text(&description_prompt);
match provider
.complete(
"Reply with only a description in four words or less.",
&[message],
&[],
)
.await
{
Ok((response, _)) => {
metadata.description = response.as_concat_text();
}
Err(e) => {
tracing::error!("Failed to generate session description: {:?}", e);
}
}
}
}
// Write the file with metadata and messages
save_messages_with_metadata(session_file, &metadata, messages)
}
/// Write messages to a session file with the provided metadata
///
/// Overwrites the file with metadata as the first line, followed by all messages in JSONL format.
pub fn save_messages_with_metadata(
session_file: &Path,
metadata: &SessionMetadata,
messages: &[Message],
) -> Result<()> {
let file = File::create(session_file).expect("The path specified does not exist");
let mut writer = io::BufWriter::new(file);
// Write metadata as the first line
serde_json::to_writer(&mut writer, &metadata)?;
writeln!(writer)?;
// Write all messages
for message in messages {
serde_json::to_writer(&mut writer, &message)?;
writeln!(writer)?;
}
writer.flush()?;
Ok(())
}
/// Generate a description for the session using the provider
///
/// This function is called when appropriate to generate a short description
/// of the session based on the conversation history.
pub async fn generate_description(
session_file: &Path,
messages: &[Message],
provider: &dyn Provider,
) -> Result<()> {
// Create a special message asking for a 3-word description
let mut description_prompt = "Based on the conversation so far, provide a concise description of this session in 4 words or less. This will be used for finding the session later in a UI with limited space - reply *ONLY* with the description".to_string();
// get context from messages so far
let context: Vec<String> = messages
.iter()
.filter(|m| m.role == mcp_core::role::Role::User)
.take(3) // Use up to first 3 user messages for context
.map(|m| m.as_concat_text())
.collect();
if !context.is_empty() {
description_prompt = format!(
"Here are the first few user messages:\n{}\n\n{}",
context.join("\n"),
description_prompt
);
}
// Generate the description
let message = Message::user().with_text(&description_prompt);
let result = provider
.complete(
"Reply with only a description in four words or less",
&[message],
&[],
)
.await?;
let description = result.0.as_concat_text();
// Read current metadata
let mut metadata = read_metadata(session_file)?;
// Update description
metadata.description = description;
// Update the file with the new metadata and existing messages
update_metadata(session_file, &metadata).await?;
Ok(())
}
/// Update only the metadata in a session file, preserving all messages
pub async fn update_metadata(session_file: &Path, metadata: &SessionMetadata) -> Result<()> {
// Read all messages from the file
let messages = read_messages(session_file)?;
// Rewrite the file with the new metadata and existing messages
save_messages_with_metadata(session_file, metadata, &messages)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::message::MessageContent;
use tempfile::tempdir;
#[tokio::test]
async fn test_read_write_messages() -> Result<()> {
let dir = tempdir()?;
let file_path = dir.path().join("test.jsonl");
// Create some test messages
let messages = vec![
Message::user().with_text("Hello"),
Message::assistant().with_text("Hi there"),
];
// Write messages
persist_messages(&file_path, &messages, None).await?;
// Read them back
let read_messages = read_messages(&file_path)?;
// Compare
assert_eq!(messages.len(), read_messages.len());
for (orig, read) in messages.iter().zip(read_messages.iter()) {
assert_eq!(orig.role, read.role);
assert_eq!(orig.content.len(), read.content.len());
// Compare first text content
if let (Some(MessageContent::Text(orig_text)), Some(MessageContent::Text(read_text))) =
(orig.content.first(), read.content.first())
{
assert_eq!(orig_text.text, read_text.text);
} else {
panic!("Messages don't match expected structure");
}
}
Ok(())
}
#[test]
fn test_empty_file() -> Result<()> {
let dir = tempdir()?;
let file_path = dir.path().join("empty.jsonl");
// Reading an empty file should return empty vec
let messages = read_messages(&file_path)?;
assert!(messages.is_empty());
Ok(())
}
#[test]
fn test_generate_session_id() {
let id = generate_session_id();
// Check that it follows the timestamp format (yyyymmdd_hhmmss)
assert_eq!(id.len(), 15); // 8 chars for date + 1 for underscore + 6 for time
assert!(id.contains('_'));
// Split by underscore and check parts
let parts: Vec<&str> = id.split('_').collect();
assert_eq!(parts.len(), 2);
// Date part should be 8 digits
assert_eq!(parts[0].len(), 8);
// Time part should be 6 digits
assert_eq!(parts[1].len(), 6);
}
}

View File

@@ -123,7 +123,7 @@ async fn run_truncate_test(
),
];
let reply_stream = agent.reply(&messages).await?;
let reply_stream = agent.reply(&messages, None).await?;
tokio::pin!(reply_stream);
let mut responses = Vec::new();

View File

@@ -1,14 +1,83 @@
You are an expert programmer in electron, with typescript, electron forge and vite and vercel AI sdk, who is teaching another developer who is experienced but not always familiar with these technologies for this desktop app.
## Project Overview
The Goose Desktop App is an Electron application built with TypeScript, React, and modern web technologies. It's a chat interface application that connects to various AI providers and allows users to interact with AI models.
Key Principles
- Write clear, concise, and idiomatic code with accurate examples.
- Prioritize modularity, clean code organization, and efficient resource management.
- ask the user to verify the UI, and try to test it as well.
## Key Technologies
Look at package.json for how to build and run (eg npm start if checking with user)
./src has most of the code
- **Electron**: For cross-platform desktop app functionality
- **React**: For UI components and state management
- **TypeScript**: For type-safe code
- **Tailwind CSS**: For styling components
- **Vite**: For fast development and bundling
- **Electron Forge**: For packaging and distribution
To validate changes:
## Project Structure
`npm run test-e2e` is a good way to get a feedback loop
- `/src`: Main source code directory
- `/main.ts`: Electron main process entry point
- `/preload.ts`: Preload script for secure renderer access
- `/renderer.tsx`: React entry point for the renderer process
- `/App.tsx`: Main React component that manages views
- `/components`: React components (page views, UI components, icons)
- `/api`: API client code
- `/utils`: Utility functions
- `/hooks`: React hooks
- `/types`: TypeScript type definitions
- `/styles`: CSS and styling
- `/images`: Image assets
## Getting Started
1. **Understand the application flow**:
- `main.ts` is the Electron entry point that creates windows and handles IPC
- `renderer.tsx` bootstraps the React application
- `App.tsx` manages the different views (chat, settings, etc.)
- The app uses a view-based navigation system with components conditionally rendered based on the current view
2. **Adding a new feature**:
- Create a new component in the `/components` directory
- Add the component to the view system in `App.tsx` by:
- Adding a new view type to the `View` type
- Importing your component
- Adding a conditional render in the App component
3. **Building and testing**:
- Use `npm run start-gui` to run the app in development mode
- Changes to React components will hot reload
- Changes to main process code require a restart
## Adding a New View/Component
1. Create a new directory under `/src/components` for your feature
2. Create a main component file (e.g., `YourFeatureView.tsx`)
3. Add your view type to the `View` type in `App.tsx`
4. Import and add your component to the render section in `App.tsx`
5. Add navigation to your view from other components (e.g., adding a button in `BottomMenu.tsx` or `MoreMenu.tsx`)
## State Management
- The app uses React's Context API for global state
- Look at existing contexts like `ConfigContext.tsx` and `ModelContext.tsx` for examples
- For local state, use React hooks like `useState` and `useEffect`
## Styling
- The app uses Tailwind CSS for styling
- Custom UI components are in `/components/ui`
- Follow the existing design patterns for consistency
## IPC Communication
- The app uses Electron's IPC for communication between main and renderer processes
- The `window.electron` object (defined in preload.ts) provides access to IPC methods
- Use existing patterns for adding new IPC functionality
## Best Practices
1. Use TypeScript types for all props and state
2. Follow the existing component structure and patterns
3. Use existing UI components when possible
4. Handle errors gracefully
5. Test your changes in both development and production builds
By following these instructions, you should be able to navigate the codebase, understand its structure, and start contributing new features or modifications.

View File

@@ -17,6 +17,7 @@ import SettingsView, { type SettingsViewOptions } from './components/settings/Se
import SettingsViewV2 from './components/settings_v2/SettingsView';
import MoreModelsView from './components/settings/models/MoreModelsView';
import ConfigureProvidersView from './components/settings/providers/ConfigureProvidersView';
import SessionsView from './components/sessions/SessionsView';
import ProviderSettings from './components/settings_v2/providers/ProviderSettingsPage';
import 'react-toastify/dist/ReactToastify.css';
@@ -30,7 +31,8 @@ export type View =
| 'configureProviders'
| 'configPage'
| 'alphaConfigureProviders'
| 'settingsV2';
| 'settingsV2'
| 'sessions';
export type ViewConfig = {
view: View;
@@ -246,7 +248,8 @@ export default function App() {
{view === 'alphaConfigureProviders' && (
<ProviderSettings onClose={() => setView('chat')} />
)}
{view === 'chat' && <ChatView setView={setView} />}
{view === 'chat' && <ChatView setView={setView} viewOptions={viewOptions} />}
{view === 'sessions' && <SessionsView setView={setView} />}
</div>
</div>
</>

View File

@@ -1,6 +1,6 @@
import React, { useEffect, useRef, useState } from 'react';
import { getApiUrl } from '../config';
import { v4 as uuidv4 } from 'uuid';
import { generateSessionId } from '../sessions';
import BottomMenu from './BottomMenu';
import FlappyGoose from './FlappyGoose';
import GooseMessage from './GooseMessage';
@@ -23,23 +23,58 @@ export interface ChatType {
messages: Message[];
}
export default function ChatView({ setView }: { setView: (view: View) => void }) {
// Generate or retrieve a unique window ID
const [windowId] = useState(() => {
// Check if we already have a window ID in sessionStorage
const existingId = window.sessionStorage.getItem('goose-window-id');
export default function ChatView({
setView,
viewOptions,
}: {
setView: (view: View, viewOptions?: Record<any, any>) => void;
viewOptions?: Record<any, any>;
}) {
// Check if we're resuming a session
const resumedSession = viewOptions?.resumedSession;
// Generate or retrieve session ID
const [sessionId] = useState(() => {
// If resuming a session, use that session ID
if (resumedSession?.session_id) {
return resumedSession.session_id;
}
const existingId = window.sessionStorage.getItem('goose-session-id');
if (existingId) {
return existingId;
}
// Create a new ID if none exists
const newId = uuidv4();
window.sessionStorage.setItem('goose-window-id', newId);
const newId = generateSessionId();
window.sessionStorage.setItem('goose-session-id', newId);
return newId;
});
const [chat, setChat] = useState<ChatType>(() => {
// If resuming a session, convert the session messages to our format
if (resumedSession) {
try {
// Convert the resumed session messages to the expected format
const convertedMessages = resumedSession.messages.map((msg): Message => {
return {
id: `${msg.role}-${msg.created}`,
role: msg.role,
created: msg.created,
content: msg.content,
};
});
return {
id: Date.now(),
title: resumedSession.description || `Chat ${resumedSession.session_id}`,
messages: convertedMessages,
};
} catch (e) {
console.error('Failed to parse resumed session:', e);
}
}
// Try to load saved chat from sessionStorage
const savedChat = window.sessionStorage.getItem(`goose-chat-${windowId}`);
const savedChat = window.sessionStorage.getItem(`goose-chat-${sessionId}`);
if (savedChat) {
try {
return JSON.parse(savedChat);
@@ -75,6 +110,7 @@ export default function ChatView({ setView }: { setView: (view: View) => void })
} = useMessageStream({
api: getApiUrl('/reply'),
initialMessages: chat?.messages || [],
body: { session_id: sessionId },
onFinish: async (message, _reason) => {
window.electron.stopPowerSaveBlocker();
@@ -106,13 +142,13 @@ export default function ChatView({ setView }: { setView: (view: View) => void })
const updatedChat = { ...prevChat, messages };
// Save to sessionStorage
try {
window.sessionStorage.setItem(`goose-chat-${windowId}`, JSON.stringify(updatedChat));
window.sessionStorage.setItem(`goose-chat-${sessionId}`, JSON.stringify(updatedChat));
} catch (e) {
console.error('Failed to save chat to sessionStorage:', e);
}
return updatedChat;
});
}, [messages, windowId]);
}, [messages, sessionId]);
useEffect(() => {
if (messages.length > 0) {

View File

@@ -220,6 +220,14 @@ export default function MoreMenu({ setView }: { setView: (view: View) => void })
</>
)}
{/* View Previous Sessions */}
<button
className="w-full text-left p-2 text-sm hover:bg-bgSubtle transition-colors"
onClick={() => setView('sessions')}
>
<span>Previous Sessions</span>
</button>
{/* Settings Menu */}
<button
onClick={() => {

View File

@@ -0,0 +1,196 @@
import React from 'react';
import { Clock, MessageSquare, ArrowLeft, AlertCircle } from 'lucide-react';
import { type SessionDetails } from '../../sessions';
import { Card } from '../ui/card';
import { Button } from '../ui/button';
import BackButton from '../ui/BackButton';
import { ScrollArea } from '../ui/scroll-area';
import MarkdownContent from '../MarkdownContent';
import ToolCallWithResponse from '../ToolCallWithResponse';
import { ToolRequestMessageContent, ToolResponseMessageContent } from '../../types/message';
interface SessionHistoryViewProps {
session: SessionDetails;
isLoading: boolean;
error: string | null;
onBack: () => void;
onResume: () => void;
onRetry: () => void;
}
export const getToolResponsesMap = (
session: SessionDetails,
messageIndex: number,
toolRequests: ToolRequestMessageContent[]
) => {
const responseMap = new Map();
if (messageIndex >= 0) {
for (let i = messageIndex + 1; i < session.messages.length; i++) {
const responses = session.messages[i].content
.filter((c) => c.type === 'toolResponse')
.map((c) => c as ToolResponseMessageContent);
for (const response of responses) {
const matchingRequest = toolRequests.find((req) => req.id === response.id);
if (matchingRequest) {
responseMap.set(response.id, response);
}
}
}
}
return responseMap;
};
const SessionHistoryView: React.FC<SessionHistoryViewProps> = ({
session,
isLoading,
error,
onBack,
onResume,
onRetry,
}) => {
return (
<div className="h-screen w-full">
<div className="relative flex items-center h-[36px] w-full bg-bgSubtle"></div>
{/* Top Row - back, info, reopen thread (fixed) */}
<Card className="px-8 pt-6 pb-4 bg-bgSecondary flex items-center">
<BackButton showText={false} onClick={onBack} className="text-textStandard" />
{/* Session info row */}
<div className="ml-8">
<h1 className="text-lg font-bold text-textStandard">
{session.metadata.description || session.session_id}
</h1>
<div className="flex items-center text-sm text-textSubtle mt-2 space-x-4">
<span className="flex items-center">
<Clock className="w-4 h-4 mr-1" />
{new Date(session.messages[0]?.created * 1000).toLocaleString()}
</span>
<span className="flex items-center">
<MessageSquare className="w-4 h-4 mr-1" />
{session.metadata.message_count} messages
</span>
{session.metadata.total_tokens !== null && (
<span className="flex items-center">
{session.metadata.total_tokens.toLocaleString()} tokens
</span>
)}
</div>
</div>
<span
onClick={onResume}
className="ml-auto text-md cursor-pointer text-textStandard hover:font-bold hover:scale-105 transition-all duration-150"
>
Resume Session
</span>
</Card>
<ScrollArea className="h-[calc(100vh-120px)] w-full">
{/* Content */}
<div className="p-4">
<div className="flex flex-col space-y-4">
<div className="space-y-4 mb-6">
{isLoading ? (
<div className="flex justify-center items-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-textStandard"></div>
</div>
) : error ? (
<div className="flex flex-col items-center justify-center py-8 text-textSubtle">
<div className="text-red-500 mb-4">
<AlertCircle size={32} />
</div>
<p className="text-md mb-2">Error Loading Session Details</p>
<p className="text-sm text-center mb-4">{error}</p>
<Button onClick={onRetry} variant="default">
Try Again
</Button>
</div>
) : session?.messages?.length > 0 ? (
session.messages
.map((message, index) => {
// Extract text content from the message
const textContent = message.content
.filter((c) => c.type === 'text')
.map((c) => c.text)
.join('\n');
// Get tool requests from the message
const toolRequests = message.content
.filter((c) => c.type === 'toolRequest')
.map((c) => c as ToolRequestMessageContent);
// Get tool responses map using the helper function
const toolResponsesMap = getToolResponsesMap(session, index, toolRequests);
// Skip pure tool response messages for cleaner display
const isOnlyToolResponse =
message.content.length > 0 &&
message.content.every((c) => c.type === 'toolResponse');
if (message.role === 'user' && isOnlyToolResponse) {
return null;
}
return (
<Card
key={index}
className={`p-4 ${
message.role === 'user'
? 'bg-bgSecondary border border-borderSubtle'
: 'bg-bgSubtle'
}`}
>
<div className="flex justify-between items-center mb-2">
<span className="font-medium text-textStandard">
{message.role === 'user' ? 'You' : 'Goose'}
</span>
<span className="text-xs text-textSubtle">
{new Date(message.created * 1000).toLocaleTimeString()}
</span>
</div>
<div className="flex flex-col w-full">
{/* Text content */}
{textContent && (
<div className={`${toolRequests.length > 0 ? 'mb-4' : ''}`}>
<MarkdownContent content={textContent} />
</div>
)}
{/* Tool requests and responses */}
{toolRequests.length > 0 && (
<div className="goose-message-tool bg-bgApp border border-borderSubtle dark:border-gray-700 rounded-b-2xl px-4 pt-4 pb-2 mt-1">
{toolRequests.map((toolRequest) => (
<ToolCallWithResponse
key={toolRequest.id}
toolRequest={toolRequest}
toolResponse={toolResponsesMap.get(toolRequest.id)}
/>
))}
</div>
)}
</div>
</Card>
);
})
.filter(Boolean) // Filter out null entries
) : (
<div className="flex flex-col items-center justify-center py-8 text-textSubtle">
<MessageSquare className="w-12 h-12 mb-4" />
<p className="text-lg mb-2">No messages found</p>
<p className="text-sm">This session doesn't contain any messages</p>
</div>
)}
</div>
</div>
</div>
</ScrollArea>
</div>
);
};
export default SessionHistoryView;

View File

@@ -0,0 +1,150 @@
import React, { useEffect, useState } from 'react';
import { ViewConfig } from '../../App';
import { MessageSquare, Loader, AlertCircle, Calendar, ChevronRight } from 'lucide-react';
import { fetchSessions, type Session } from '../../sessions';
import { Card } from '../ui/card';
import { Button } from '../ui/button';
import BackButton from '../ui/BackButton';
import { ScrollArea } from '../ui/scroll-area';
interface SessionListViewProps {
setView: (view: ViewConfig['view'], viewOptions?: Record<any, any>) => void;
onSelectSession: (sessionId: string) => void;
}
const SessionListView: React.FC<SessionListViewProps> = ({ setView, onSelectSession }) => {
const [sessions, setSessions] = useState<Session[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
// Load sessions on component mount
loadSessions();
}, []);
const loadSessions = async () => {
setIsLoading(true);
setError(null);
try {
const response = await fetchSessions();
setSessions(response.sessions);
} catch (err) {
console.error('Failed to load sessions:', err);
setError('Failed to load sessions. Please try again later.');
setSessions([]);
} finally {
setIsLoading(false);
}
};
// Format date to be more readable
// eg. "10:39 PM, Feb 28, 2025"
const formatDate = (dateString: string) => {
try {
const date = new Date(dateString);
const time = new Intl.DateTimeFormat('en-US', {
hour: 'numeric',
minute: 'numeric',
hour12: true,
}).format(date);
const dateStr = new Intl.DateTimeFormat('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
}).format(date);
return `${time}, ${dateStr}`;
} catch (e) {
return dateString;
}
};
return (
<div className="h-screen w-full">
<div className="relative flex items-center h-[36px] w-full bg-bgSubtle"></div>
<ScrollArea className="h-full w-full">
<div className="flex flex-col pb-24">
<div className="px-8 pt-6 pb-4">
<BackButton onClick={() => setView('chat')} />
</div>
{/* Content Area */}
<div className="flex flex-col mb-6 px-8">
<h1 className="text-3xl font-medium text-textStandard">Previous goose sessions</h1>
<h3 className="text-sm text-textSubtle mt-2">
View previous goose sessions and their contents to pick up where you left off.
</h3>
</div>
<div className="flex-1 overflow-y-auto p-4">
{isLoading ? (
<div className="flex justify-center items-center h-full">
<Loader className="h-8 w-8 animate-spin text-textPrimary" />
</div>
) : error ? (
<div className="flex flex-col items-center justify-center h-full text-textSubtle">
<AlertCircle className="h-12 w-12 text-red-500 mb-4" />
<p className="text-lg mb-2">Error Loading Sessions</p>
<p className="text-sm text-center mb-4">{error}</p>
<Button onClick={loadSessions} variant="default">
Try Again
</Button>
</div>
) : sessions.length > 0 ? (
<div className="grid gap-2">
{sessions.map((session) => (
<Card
key={session.id}
onClick={() => onSelectSession(session.id)}
className="p-2 bg-bgSecondary hover:bg-bgSubtle cursor-pointer transition-all duration-150"
>
<div className="flex justify-between items-start">
<div className="w-full">
<h3 className="text-base font-medium text-textStandard truncate">
{session.metadata.description || session.id}
</h3>
<div className="flex items-center mt-1 text-textSubtle text-sm">
<Calendar className="w-3 h-3 mr-1 flex-shrink-0" />
<span className="truncate">{formatDate(session.modified)}</span>
</div>
</div>
<div className="flex items-center gap-3">
<div className="flex flex-col items-end">
<div className="flex items-center text-sm text-textSubtle">
<span>{session.path.split('/').pop() || session.path}</span>
</div>
<div className="flex items-center mt-1 space-x-3 text-sm text-textSubtle">
<div className="flex items-center">
<MessageSquare className="w-3 h-3 mr-1" />
<span>{session.metadata.message_count}</span>
</div>
{session.metadata.total_tokens !== null && (
<div className="flex items-center">
<span>{session.metadata.total_tokens.toLocaleString()} tokens</span>
</div>
)}
</div>
</div>
<ChevronRight className="w-8 h-5 text-textSubtle" />
</div>
</div>
</Card>
))}
</div>
) : (
<div className="flex flex-col items-center justify-center h-full text-textSubtle">
<MessageSquare className="h-12 w-12 mb-4" />
<p className="text-lg mb-2">No chat sessions found</p>
<p className="text-sm">Your chat history will appear here</p>
</div>
)}
</div>
</div>
</ScrollArea>
</div>
);
};
export default SessionListView;

View File

@@ -0,0 +1,72 @@
import React, { useState } from 'react';
import { ViewConfig } from '../../App';
import { fetchSessionDetails, type SessionDetails } from '../../sessions';
import SessionListView from './SessionListView';
import SessionHistoryView from './SessionHistoryView';
interface SessionsViewProps {
setView: (view: ViewConfig['view'], viewOptions?: Record<any, any>) => void;
}
const SessionsView: React.FC<SessionsViewProps> = ({ setView }) => {
const [selectedSession, setSelectedSession] = useState<SessionDetails | null>(null);
const [isLoadingSession, setIsLoadingSession] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSelectSession = async (sessionId: string) => {
await loadSessionDetails(sessionId);
};
const loadSessionDetails = async (sessionId: string) => {
setIsLoadingSession(true);
setError(null);
try {
const sessionDetails = await fetchSessionDetails(sessionId);
setSelectedSession(sessionDetails);
} catch (err) {
console.error(`Failed to load session details for ${sessionId}:`, err);
setError('Failed to load session details. Please try again later.');
// Keep the selected session null if there's an error
setSelectedSession(null);
} finally {
setIsLoadingSession(false);
}
};
const handleBackToSessions = () => {
setSelectedSession(null);
setError(null);
};
const handleResumeSession = () => {
if (selectedSession) {
// Pass the session to ChatView for resuming
setView('chat', {
resumedSession: selectedSession,
});
}
};
const handleRetryLoadSession = () => {
if (selectedSession) {
loadSessionDetails(selectedSession.session_id);
}
};
// If a session is selected, show the session history view
// Otherwise, show the sessions list view
return selectedSession ? (
<SessionHistoryView
session={selectedSession}
isLoading={isLoadingSession}
error={error}
onBack={handleBackToSessions}
onResume={handleResumeSession}
onRetry={handleRetryLoadSession}
/>
) : (
<SessionListView setView={setView} onSelectSession={handleSelectSession} />
);
};
export default SessionsView;

View File

@@ -4,9 +4,16 @@ import Back from '../icons/Back';
interface BackButtonProps {
onClick?: () => void; // Mark onClick as optional
className?: string;
textSize?: 'sm' | 'base' | 'md' | 'lg';
showText?: boolean; // Add new prop
}
const BackButton: React.FC<BackButtonProps> = ({ onClick, className = '' }) => {
const BackButton: React.FC<BackButtonProps> = ({
onClick,
className = '',
textSize = 'sm',
showText = true,
}) => {
const handleExit = () => {
if (onClick) {
onClick(); // Custom onClick handler passed via props
@@ -20,10 +27,10 @@ const BackButton: React.FC<BackButtonProps> = ({ onClick, className = '' }) => {
return (
<button
onClick={handleExit}
className={`flex items-center text-sm text-textSubtle group hover:text-textStandard ${className}`}
className={`flex items-center text-${textSize} text-textSubtle group hover:text-textStandard ${className}`}
>
<Back className="w-3 h-3 group-hover:-translate-x-1 transition-all mr-1" />
<span>Exit</span>
{showText && <span>Exit</span>}
</button>
);
};

110
ui/desktop/src/sessions.ts Normal file
View File

@@ -0,0 +1,110 @@
import { getApiUrl, getSecretKey } from './config';
export interface SessionMetadata {
description: string;
message_count: number;
total_tokens: number | null;
}
export interface Session {
id: string;
path: string;
modified: string;
metadata: SessionMetadata;
}
export interface SessionsResponse {
sessions: Session[];
}
export interface SessionMessage {
role: 'user' | 'assistant';
created: number;
content: {
type: string;
text: string;
}[];
}
export interface SessionDetails {
session_id: string;
metadata: SessionMetadata;
messages: SessionMessage[];
}
/**
* Generate a session ID in the format yyyymmdd_hhmmss
*/
export function generateSessionId(): string {
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 hours = String(now.getHours()).padStart(2, '0');
const minutes = String(now.getMinutes()).padStart(2, '0');
const seconds = String(now.getSeconds()).padStart(2, '0');
return `${year}${month}${day}_${hours}${minutes}${seconds}`;
}
/**
* Fetches all available sessions from the API
* @returns Promise with sessions data
*/
export async function fetchSessions(): Promise<SessionsResponse> {
try {
const response = await fetch(getApiUrl('/sessions'), {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'X-Secret-Key': getSecretKey(),
},
});
if (!response.ok) {
throw new Error(`Failed to fetch sessions: ${response.status} ${response.statusText}`);
}
// TODO: remove this logic once everyone migrates to the new sessions format
// for now, filter out sessions whose description is empty (old CLI sessions)
const sessions = (await response.json()).sessions.filter(
(session: Session) => session.metadata.description !== ''
);
// order sessions by 'modified' date descending
sessions.sort(
(a: Session, b: Session) => new Date(b.modified).getTime() - new Date(a.modified).getTime()
);
return { sessions };
} catch (error) {
console.error('Error fetching sessions:', error);
throw error;
}
}
/**
* Fetches details for a specific session
* @param sessionId The ID of the session to fetch
* @returns Promise with session details
*/
export async function fetchSessionDetails(sessionId: string): Promise<SessionDetails> {
try {
const response = await fetch(getApiUrl(`/sessions/${sessionId}`), {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'X-Secret-Key': getSecretKey(),
},
});
if (!response.ok) {
throw new Error(`Failed to fetch session details: ${response.status} ${response.statusText}`);
}
return await response.json();
} catch (error) {
console.error(`Error fetching session details for ${sessionId}:`, error);
throw error;
}
}

View File