mirror of
https://github.com/aljazceru/goose.git
synced 2025-12-19 15:14:21 +01:00
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:
@@ -2,16 +2,16 @@ use console::style;
|
|||||||
use goose::agents::extension::ExtensionError;
|
use goose::agents::extension::ExtensionError;
|
||||||
use goose::agents::AgentFactory;
|
use goose::agents::AgentFactory;
|
||||||
use goose::config::{Config, ExtensionManager};
|
use goose::config::{Config, ExtensionManager};
|
||||||
|
use goose::session;
|
||||||
|
use goose::session::Identifier;
|
||||||
use mcp_client::transport::Error as McpClientError;
|
use mcp_client::transport::Error as McpClientError;
|
||||||
use std::path::PathBuf;
|
|
||||||
use std::process;
|
use std::process;
|
||||||
|
|
||||||
use super::output;
|
use super::output;
|
||||||
use super::storage;
|
|
||||||
use super::Session;
|
use super::Session;
|
||||||
|
|
||||||
pub async fn build_session(
|
pub async fn build_session(
|
||||||
identifier: Option<storage::Identifier>,
|
identifier: Option<Identifier>,
|
||||||
resume: bool,
|
resume: bool,
|
||||||
extensions: Vec<String>,
|
extensions: Vec<String>,
|
||||||
builtins: Vec<String>,
|
builtins: Vec<String>,
|
||||||
@@ -65,7 +65,7 @@ pub async fn build_session(
|
|||||||
// Handle session file resolution and resuming
|
// Handle session file resolution and resuming
|
||||||
let session_file = if resume {
|
let session_file = if resume {
|
||||||
if let Some(identifier) = identifier {
|
if let Some(identifier) = identifier {
|
||||||
let session_file = storage::get_path(identifier);
|
let session_file = session::get_path(identifier);
|
||||||
if !session_file.exists() {
|
if !session_file.exists() {
|
||||||
output::render_error(&format!(
|
output::render_error(&format!(
|
||||||
"Cannot resume session {} - no such session exists",
|
"Cannot resume session {} - no such session exists",
|
||||||
@@ -76,7 +76,7 @@ pub async fn build_session(
|
|||||||
session_file
|
session_file
|
||||||
} else {
|
} else {
|
||||||
// Try to resume most recent session
|
// Try to resume most recent session
|
||||||
match storage::get_most_recent_session() {
|
match session::get_most_recent_session() {
|
||||||
Ok(file) => file,
|
Ok(file) => file,
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
output::render_error("Cannot resume - no previous sessions found");
|
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
|
// Create new session with provided name/path or generated name
|
||||||
let id = match identifier {
|
let id = match identifier {
|
||||||
Some(identifier) => 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
|
// Create new session
|
||||||
@@ -130,20 +131,3 @@ pub async fn build_session(
|
|||||||
output::display_session_info(resume, &provider_name, &model, &session_file);
|
output::display_session_info(resume, &provider_name, &model, &session_file);
|
||||||
session
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,11 +3,10 @@ mod completion;
|
|||||||
mod input;
|
mod input;
|
||||||
mod output;
|
mod output;
|
||||||
mod prompt;
|
mod prompt;
|
||||||
mod storage;
|
|
||||||
mod thinking;
|
mod thinking;
|
||||||
|
|
||||||
pub use builder::build_session;
|
pub use builder::build_session;
|
||||||
pub use storage::Identifier;
|
pub use goose::session::Identifier;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use completion::GooseCompleter;
|
use completion::GooseCompleter;
|
||||||
@@ -16,6 +15,7 @@ use etcetera::AppStrategy;
|
|||||||
use goose::agents::extension::{Envs, ExtensionConfig};
|
use goose::agents::extension::{Envs, ExtensionConfig};
|
||||||
use goose::agents::Agent;
|
use goose::agents::Agent;
|
||||||
use goose::message::{Message, MessageContent};
|
use goose::message::{Message, MessageContent};
|
||||||
|
use goose::session;
|
||||||
use mcp_core::handler::ToolError;
|
use mcp_core::handler::ToolError;
|
||||||
use mcp_core::prompt::PromptMessage;
|
use mcp_core::prompt::PromptMessage;
|
||||||
|
|
||||||
@@ -56,7 +56,7 @@ impl CompletionCache {
|
|||||||
|
|
||||||
impl Session {
|
impl Session {
|
||||||
pub fn new(agent: Box<dyn Agent>, session_file: PathBuf) -> Self {
|
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,
|
Ok(msgs) => msgs,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("Warning: Failed to load message history: {}", e);
|
eprintln!("Warning: Failed to load message history: {}", e);
|
||||||
@@ -196,7 +196,13 @@ impl Session {
|
|||||||
/// Process a single message and get the response
|
/// Process a single message and get the response
|
||||||
async fn process_message(&mut self, message: String) -> Result<()> {
|
async fn process_message(&mut self, message: String) -> Result<()> {
|
||||||
self.messages.push(Message::user().with_text(&message));
|
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?;
|
self.process_agent_response(false).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -260,7 +266,13 @@ impl Session {
|
|||||||
save_history(&mut editor);
|
save_history(&mut editor);
|
||||||
|
|
||||||
self.messages.push(Message::user().with_text(&content));
|
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();
|
output::show_thinking();
|
||||||
self.process_agent_response(true).await?;
|
self.process_agent_response(true).await?;
|
||||||
@@ -399,7 +411,8 @@ impl Session {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn process_agent_response(&mut self, interactive: bool) -> Result<()> {
|
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;
|
use futures::StreamExt;
|
||||||
loop {
|
loop {
|
||||||
@@ -421,7 +434,10 @@ impl Session {
|
|||||||
// otherwise we have a model/tool to render
|
// otherwise we have a model/tool to render
|
||||||
else {
|
else {
|
||||||
self.messages.push(message.clone());
|
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()};
|
if interactive {output::hide_thinking()};
|
||||||
output::render_message(&message);
|
output::render_message(&message);
|
||||||
if interactive {output::show_thinking()};
|
if interactive {output::show_thinking()};
|
||||||
@@ -430,7 +446,9 @@ impl Session {
|
|||||||
Some(Err(e)) => {
|
Some(Err(e)) => {
|
||||||
eprintln!("Error: {}", e);
|
eprintln!("Error: {}", e);
|
||||||
drop(stream);
|
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(
|
output::render_error(
|
||||||
"The error above was an exception we were not able to handle.\n\
|
"The error above was an exception we were not able to handle.\n\
|
||||||
These errors are often related to connection or authentication\n\
|
These errors are often related to connection or authentication\n\
|
||||||
@@ -444,7 +462,9 @@ impl Session {
|
|||||||
}
|
}
|
||||||
_ = tokio::signal::ctrl_c() => {
|
_ = tokio::signal::ctrl_c() => {
|
||||||
drop(stream);
|
drop(stream);
|
||||||
self.handle_interrupted_messages(true);
|
if let Err(e) = self.handle_interrupted_messages(true).await {
|
||||||
|
eprintln!("Error handling interruption: {}", e);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -452,7 +472,7 @@ impl Session {
|
|||||||
Ok(())
|
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
|
// First, get any tool requests from the last message if it exists
|
||||||
let tool_requests = self
|
let tool_requests = self
|
||||||
.messages
|
.messages
|
||||||
@@ -493,11 +513,18 @@ impl Session {
|
|||||||
}
|
}
|
||||||
self.messages.push(response_message);
|
self.messages.push(response_message);
|
||||||
|
|
||||||
|
// No need for description update here
|
||||||
|
session::persist_messages(&self.session_file, &self.messages, None).await?;
|
||||||
|
|
||||||
let prompt = format!(
|
let prompt = format!(
|
||||||
"The existing call to {} was interrupted. How would you like to proceed?",
|
"The existing call to {} was interrupted. How would you like to proceed?",
|
||||||
last_tool_name
|
last_tool_name
|
||||||
);
|
);
|
||||||
self.messages.push(Message::assistant().with_text(&prompt));
|
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));
|
output::render_message(&Message::assistant().with_text(&prompt));
|
||||||
} else {
|
} else {
|
||||||
// An interruption occurred outside of a tool request-response.
|
// 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
|
// 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?";
|
let prompt = "The tool calling loop was interrupted. How would you like to proceed?";
|
||||||
self.messages.push(Message::assistant().with_text(prompt));
|
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));
|
output::render_message(&Message::assistant().with_text(prompt));
|
||||||
}
|
}
|
||||||
Some(_) => {
|
Some(_) => {
|
||||||
@@ -521,6 +553,7 @@ impl Session {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn session_file(&self) -> PathBuf {
|
pub fn session_file(&self) -> PathBuf {
|
||||||
|
|||||||
@@ -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(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,6 +5,7 @@ pub mod configs;
|
|||||||
pub mod extension;
|
pub mod extension;
|
||||||
pub mod health;
|
pub mod health;
|
||||||
pub mod reply;
|
pub mod reply;
|
||||||
|
pub mod session;
|
||||||
|
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
|
|
||||||
@@ -16,5 +17,6 @@ pub fn configure(state: crate::state::AppState) -> Router {
|
|||||||
.merge(agent::routes(state.clone()))
|
.merge(agent::routes(state.clone()))
|
||||||
.merge(extension::routes(state.clone()))
|
.merge(extension::routes(state.clone()))
|
||||||
.merge(configs::routes(state.clone()))
|
.merge(configs::routes(state.clone()))
|
||||||
.merge(config_management::routes(state))
|
.merge(config_management::routes(state.clone()))
|
||||||
|
.merge(session::routes(state))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use axum::{
|
|||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use futures::{stream::StreamExt, Stream};
|
use futures::{stream::StreamExt, Stream};
|
||||||
use goose::message::{Message, MessageContent};
|
use goose::message::{Message, MessageContent};
|
||||||
|
use goose::session;
|
||||||
|
|
||||||
use mcp_core::role::Role;
|
use mcp_core::role::Role;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -27,6 +28,7 @@ use tokio_stream::wrappers::ReceiverStream;
|
|||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct ChatRequest {
|
struct ChatRequest {
|
||||||
messages: Vec<Message>,
|
messages: Vec<Message>,
|
||||||
|
session_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Custom SSE response type for streaming messages
|
// Custom SSE response type for streaming messages
|
||||||
@@ -109,6 +111,11 @@ async fn handler(
|
|||||||
// Get messages directly from the request
|
// Get messages directly from the request
|
||||||
let messages = request.messages;
|
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
|
// Get a lock on the shared agent
|
||||||
let agent = state.agent.clone();
|
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,
|
Ok(stream) => stream,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!("Failed to start reply stream: {:?}", 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 {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
response = timeout(Duration::from_millis(500), stream.next()) => {
|
response = timeout(Duration::from_millis(500), stream.next()) => {
|
||||||
match response {
|
match response {
|
||||||
Ok(Some(Ok(message))) => {
|
Ok(Some(Ok(message))) => {
|
||||||
|
all_messages.push(message.clone());
|
||||||
if let Err(e) = stream_event(MessageEvent::Message { message }, &tx).await {
|
if let Err(e) = stream_event(MessageEvent::Message { message }, &tx).await {
|
||||||
tracing::error!("Error sending message through channel: {}", e);
|
tracing::error!("Error sending message through channel: {}", e);
|
||||||
let _ = stream_event(
|
let _ = stream_event(
|
||||||
@@ -173,6 +194,16 @@ async fn handler(
|
|||||||
).await;
|
).await;
|
||||||
break;
|
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))) => {
|
Ok(Some(Err(e))) => {
|
||||||
tracing::error!("Error processing message: {}", e);
|
tracing::error!("Error processing message: {}", e);
|
||||||
@@ -214,6 +245,7 @@ async fn handler(
|
|||||||
#[derive(Debug, Deserialize, Serialize)]
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
struct AskRequest {
|
struct AskRequest {
|
||||||
prompt: String,
|
prompt: String,
|
||||||
|
session_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
@@ -237,16 +269,30 @@ async fn ask_handler(
|
|||||||
return Err(StatusCode::UNAUTHORIZED);
|
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 = state.agent.clone();
|
||||||
let agent = agent.write().await;
|
let agent = agent.write().await;
|
||||||
let agent = agent.as_ref().ok_or(StatusCode::NOT_FOUND)?;
|
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
|
// Create a single message for the prompt
|
||||||
let messages = vec![Message::user().with_text(request.prompt)];
|
let messages = vec![Message::user().with_text(request.prompt)];
|
||||||
|
|
||||||
// Get response from agent
|
// Get response from agent
|
||||||
let mut response_text = String::new();
|
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,
|
Ok(stream) => stream,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!("Failed to start reply stream: {:?}", 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 {
|
while let Some(response) = stream.next().await {
|
||||||
match response {
|
match response {
|
||||||
Ok(message) => {
|
Ok(message) => {
|
||||||
if message.role == Role::Assistant {
|
if message.role == Role::Assistant {
|
||||||
for content in message.content {
|
for content in &message.content {
|
||||||
if let MessageContent::Text(text) = content {
|
if let MessageContent::Text(text) = content {
|
||||||
response_text.push_str(&text.text);
|
response_text.push_str(&text.text);
|
||||||
response_text.push('\n');
|
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 {
|
Ok(Json(AskResponse {
|
||||||
response: response_text.trim().to_string(),
|
response: response_text.trim().to_string(),
|
||||||
}))
|
}))
|
||||||
@@ -394,6 +463,7 @@ mod tests {
|
|||||||
.body(Body::from(
|
.body(Body::from(
|
||||||
serde_json::to_string(&AskRequest {
|
serde_json::to_string(&AskRequest {
|
||||||
prompt: "test prompt".to_string(),
|
prompt: "test prompt".to_string(),
|
||||||
|
session_id: Some("test-session".to_string()),
|
||||||
})
|
})
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
))
|
))
|
||||||
|
|||||||
128
crates/goose-server/src/routes/session.rs
Normal file
128
crates/goose-server/src/routes/session.rs
Normal 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)
|
||||||
|
}
|
||||||
@@ -30,7 +30,7 @@ async fn main() {
|
|||||||
let messages = vec![Message::user()
|
let messages = vec![Message::user()
|
||||||
.with_text("can you summarize the readme.md in this dir using just a haiku?")];
|
.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 {
|
while let Some(message) = stream.next().await {
|
||||||
println!(
|
println!(
|
||||||
"{}",
|
"{}",
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ use anyhow::Result;
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use futures::stream::BoxStream;
|
use futures::stream::BoxStream;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use super::extension::{ExtensionConfig, ExtensionResult};
|
use super::extension::{ExtensionConfig, ExtensionResult};
|
||||||
use crate::message::Message;
|
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::prompt::Prompt;
|
||||||
use mcp_core::protocol::GetPromptResult;
|
use mcp_core::protocol::GetPromptResult;
|
||||||
|
|
||||||
@@ -15,7 +17,11 @@ use mcp_core::protocol::GetPromptResult;
|
|||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait Agent: Send + Sync {
|
pub trait Agent: Send + Sync {
|
||||||
/// Create a stream that yields each message as it's generated by the agent
|
/// 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
|
/// Add a new MCP client to the agent
|
||||||
async fn add_extension(&mut self, config: ExtensionConfig) -> ExtensionResult<()>;
|
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
|
/// Get a prompt result with the given name and arguments
|
||||||
/// Returns the prompt text that would be used as user input
|
/// Returns the prompt text that would be used as user input
|
||||||
async fn get_prompt(&self, name: &str, arguments: Value) -> Result<GetPromptResult>;
|
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>>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ pub struct Capabilities {
|
|||||||
clients: HashMap<String, McpClientBox>,
|
clients: HashMap<String, McpClientBox>,
|
||||||
instructions: HashMap<String, String>,
|
instructions: HashMap<String, String>,
|
||||||
resource_capable_extensions: HashSet<String>,
|
resource_capable_extensions: HashSet<String>,
|
||||||
provider: Box<dyn Provider>,
|
provider: Arc<Box<dyn Provider>>,
|
||||||
provider_usage: Mutex<Vec<ProviderUsage>>,
|
provider_usage: Mutex<Vec<ProviderUsage>>,
|
||||||
system_prompt_override: Option<String>,
|
system_prompt_override: Option<String>,
|
||||||
system_prompt_extensions: Vec<String>,
|
system_prompt_extensions: Vec<String>,
|
||||||
@@ -90,7 +90,7 @@ impl Capabilities {
|
|||||||
clients: HashMap::new(),
|
clients: HashMap::new(),
|
||||||
instructions: HashMap::new(),
|
instructions: HashMap::new(),
|
||||||
resource_capable_extensions: HashSet::new(),
|
resource_capable_extensions: HashSet::new(),
|
||||||
provider,
|
provider: Arc::new(provider),
|
||||||
provider_usage: Mutex::new(Vec::new()),
|
provider_usage: Mutex::new(Vec::new()),
|
||||||
system_prompt_override: None,
|
system_prompt_override: None,
|
||||||
system_prompt_extensions: Vec::new(),
|
system_prompt_extensions: Vec::new(),
|
||||||
@@ -202,8 +202,8 @@ impl Capabilities {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Get a reference to the provider
|
/// Get a reference to the provider
|
||||||
pub fn provider(&self) -> &dyn Provider {
|
pub fn provider(&self) -> Arc<Box<dyn Provider>> {
|
||||||
&*self.provider
|
Arc::clone(&self.provider)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Record provider usage
|
/// Record provider usage
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use futures::stream::BoxStream;
|
use futures::stream::BoxStream;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
use tracing::{debug, instrument};
|
use tracing::{debug, instrument};
|
||||||
|
|
||||||
@@ -12,8 +13,8 @@ use crate::agents::extension::{ExtensionConfig, ExtensionResult};
|
|||||||
use crate::message::{Message, ToolRequest};
|
use crate::message::{Message, ToolRequest};
|
||||||
use crate::providers::base::Provider;
|
use crate::providers::base::Provider;
|
||||||
use crate::providers::base::ProviderUsage;
|
use crate::providers::base::ProviderUsage;
|
||||||
use crate::register_agent;
|
|
||||||
use crate::token_counter::TokenCounter;
|
use crate::token_counter::TokenCounter;
|
||||||
|
use crate::{register_agent, session};
|
||||||
use anyhow::{anyhow, Result};
|
use anyhow::{anyhow, Result};
|
||||||
use indoc::indoc;
|
use indoc::indoc;
|
||||||
use mcp_core::prompt::Prompt;
|
use mcp_core::prompt::Prompt;
|
||||||
@@ -73,6 +74,7 @@ impl Agent for ReferenceAgent {
|
|||||||
async fn reply(
|
async fn reply(
|
||||||
&self,
|
&self,
|
||||||
messages: &[Message],
|
messages: &[Message],
|
||||||
|
session_id: Option<session::Identifier>,
|
||||||
) -> anyhow::Result<BoxStream<'_, anyhow::Result<Message>>> {
|
) -> anyhow::Result<BoxStream<'_, anyhow::Result<Message>>> {
|
||||||
let mut messages = messages.to_vec();
|
let mut messages = messages.to_vec();
|
||||||
let reply_span = tracing::Span::current();
|
let reply_span = tracing::Span::current();
|
||||||
@@ -143,7 +145,19 @@ impl Agent for ReferenceAgent {
|
|||||||
&messages,
|
&messages,
|
||||||
&tools,
|
&tools,
|
||||||
).await?;
|
).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 the assistant's response
|
||||||
yield response.clone();
|
yield response.clone();
|
||||||
@@ -233,6 +247,11 @@ impl Agent for ReferenceAgent {
|
|||||||
|
|
||||||
Err(anyhow!("Prompt '{}' not found", name))
|
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);
|
register_agent!("reference", ReferenceAgent);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use futures::stream::BoxStream;
|
use futures::stream::BoxStream;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
use tracing::{debug, error, instrument, warn};
|
use tracing::{debug, error, instrument, warn};
|
||||||
@@ -18,6 +19,7 @@ use crate::providers::base::Provider;
|
|||||||
use crate::providers::base::ProviderUsage;
|
use crate::providers::base::ProviderUsage;
|
||||||
use crate::providers::errors::ProviderError;
|
use crate::providers::errors::ProviderError;
|
||||||
use crate::register_agent;
|
use crate::register_agent;
|
||||||
|
use crate::session;
|
||||||
use crate::token_counter::TokenCounter;
|
use crate::token_counter::TokenCounter;
|
||||||
use crate::truncate::{truncate_messages, OldestFirstTruncation};
|
use crate::truncate::{truncate_messages, OldestFirstTruncation};
|
||||||
use anyhow::{anyhow, Result};
|
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(
|
async fn reply(
|
||||||
&self,
|
&self,
|
||||||
messages: &[Message],
|
messages: &[Message],
|
||||||
|
session_id: Option<session::Identifier>,
|
||||||
) -> anyhow::Result<BoxStream<'_, anyhow::Result<Message>>> {
|
) -> anyhow::Result<BoxStream<'_, anyhow::Result<Message>>> {
|
||||||
let mut messages = messages.to_vec();
|
let mut messages = messages.to_vec();
|
||||||
let reply_span = tracing::Span::current();
|
let reply_span = tracing::Span::current();
|
||||||
@@ -223,7 +226,19 @@ impl Agent for TruncateAgent {
|
|||||||
&tools,
|
&tools,
|
||||||
).await {
|
).await {
|
||||||
Ok((response, usage)) => {
|
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
|
// Reset truncation attempt
|
||||||
truncation_attempt = 0;
|
truncation_attempt = 0;
|
||||||
@@ -435,6 +450,11 @@ impl Agent for TruncateAgent {
|
|||||||
|
|
||||||
Err(anyhow!("Prompt '{}' not found", name))
|
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);
|
register_agent!("truncate", TruncateAgent);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ pub mod message;
|
|||||||
pub mod model;
|
pub mod model;
|
||||||
pub mod prompt_template;
|
pub mod prompt_template;
|
||||||
pub mod providers;
|
pub mod providers;
|
||||||
|
pub mod session;
|
||||||
pub mod token_counter;
|
pub mod token_counter;
|
||||||
pub mod tracing;
|
pub mod tracing;
|
||||||
pub mod truncate;
|
pub mod truncate;
|
||||||
|
|||||||
8
crates/goose/src/session/mod.rs
Normal file
8
crates/goose/src/session/mod.rs
Normal 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,
|
||||||
|
};
|
||||||
411
crates/goose/src/session/storage.rs
Normal file
411
crates/goose/src/session/storage.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
tokio::pin!(reply_stream);
|
||||||
|
|
||||||
let mut responses = Vec::new();
|
let mut responses = Vec::new();
|
||||||
|
|||||||
@@ -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
|
## Key Technologies
|
||||||
- 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.
|
|
||||||
|
|
||||||
Look at package.json for how to build and run (eg npm start if checking with user)
|
- **Electron**: For cross-platform desktop app functionality
|
||||||
./src has most of the code
|
- **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.
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import SettingsView, { type SettingsViewOptions } from './components/settings/Se
|
|||||||
import SettingsViewV2 from './components/settings_v2/SettingsView';
|
import SettingsViewV2 from './components/settings_v2/SettingsView';
|
||||||
import MoreModelsView from './components/settings/models/MoreModelsView';
|
import MoreModelsView from './components/settings/models/MoreModelsView';
|
||||||
import ConfigureProvidersView from './components/settings/providers/ConfigureProvidersView';
|
import ConfigureProvidersView from './components/settings/providers/ConfigureProvidersView';
|
||||||
|
import SessionsView from './components/sessions/SessionsView';
|
||||||
import ProviderSettings from './components/settings_v2/providers/ProviderSettingsPage';
|
import ProviderSettings from './components/settings_v2/providers/ProviderSettingsPage';
|
||||||
|
|
||||||
import 'react-toastify/dist/ReactToastify.css';
|
import 'react-toastify/dist/ReactToastify.css';
|
||||||
@@ -30,7 +31,8 @@ export type View =
|
|||||||
| 'configureProviders'
|
| 'configureProviders'
|
||||||
| 'configPage'
|
| 'configPage'
|
||||||
| 'alphaConfigureProviders'
|
| 'alphaConfigureProviders'
|
||||||
| 'settingsV2';
|
| 'settingsV2'
|
||||||
|
| 'sessions';
|
||||||
|
|
||||||
export type ViewConfig = {
|
export type ViewConfig = {
|
||||||
view: View;
|
view: View;
|
||||||
@@ -246,7 +248,8 @@ export default function App() {
|
|||||||
{view === 'alphaConfigureProviders' && (
|
{view === 'alphaConfigureProviders' && (
|
||||||
<ProviderSettings onClose={() => setView('chat')} />
|
<ProviderSettings onClose={() => setView('chat')} />
|
||||||
)}
|
)}
|
||||||
{view === 'chat' && <ChatView setView={setView} />}
|
{view === 'chat' && <ChatView setView={setView} viewOptions={viewOptions} />}
|
||||||
|
{view === 'sessions' && <SessionsView setView={setView} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import { getApiUrl } from '../config';
|
import { getApiUrl } from '../config';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { generateSessionId } from '../sessions';
|
||||||
import BottomMenu from './BottomMenu';
|
import BottomMenu from './BottomMenu';
|
||||||
import FlappyGoose from './FlappyGoose';
|
import FlappyGoose from './FlappyGoose';
|
||||||
import GooseMessage from './GooseMessage';
|
import GooseMessage from './GooseMessage';
|
||||||
@@ -23,23 +23,58 @@ export interface ChatType {
|
|||||||
messages: Message[];
|
messages: Message[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ChatView({ setView }: { setView: (view: View) => void }) {
|
export default function ChatView({
|
||||||
// Generate or retrieve a unique window ID
|
setView,
|
||||||
const [windowId] = useState(() => {
|
viewOptions,
|
||||||
// Check if we already have a window ID in sessionStorage
|
}: {
|
||||||
const existingId = window.sessionStorage.getItem('goose-window-id');
|
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) {
|
if (existingId) {
|
||||||
return existingId;
|
return existingId;
|
||||||
}
|
}
|
||||||
// Create a new ID if none exists
|
const newId = generateSessionId();
|
||||||
const newId = uuidv4();
|
window.sessionStorage.setItem('goose-session-id', newId);
|
||||||
window.sessionStorage.setItem('goose-window-id', newId);
|
|
||||||
return newId;
|
return newId;
|
||||||
});
|
});
|
||||||
|
|
||||||
const [chat, setChat] = useState<ChatType>(() => {
|
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
|
// 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) {
|
if (savedChat) {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(savedChat);
|
return JSON.parse(savedChat);
|
||||||
@@ -75,6 +110,7 @@ export default function ChatView({ setView }: { setView: (view: View) => void })
|
|||||||
} = useMessageStream({
|
} = useMessageStream({
|
||||||
api: getApiUrl('/reply'),
|
api: getApiUrl('/reply'),
|
||||||
initialMessages: chat?.messages || [],
|
initialMessages: chat?.messages || [],
|
||||||
|
body: { session_id: sessionId },
|
||||||
onFinish: async (message, _reason) => {
|
onFinish: async (message, _reason) => {
|
||||||
window.electron.stopPowerSaveBlocker();
|
window.electron.stopPowerSaveBlocker();
|
||||||
|
|
||||||
@@ -106,13 +142,13 @@ export default function ChatView({ setView }: { setView: (view: View) => void })
|
|||||||
const updatedChat = { ...prevChat, messages };
|
const updatedChat = { ...prevChat, messages };
|
||||||
// Save to sessionStorage
|
// Save to sessionStorage
|
||||||
try {
|
try {
|
||||||
window.sessionStorage.setItem(`goose-chat-${windowId}`, JSON.stringify(updatedChat));
|
window.sessionStorage.setItem(`goose-chat-${sessionId}`, JSON.stringify(updatedChat));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to save chat to sessionStorage:', e);
|
console.error('Failed to save chat to sessionStorage:', e);
|
||||||
}
|
}
|
||||||
return updatedChat;
|
return updatedChat;
|
||||||
});
|
});
|
||||||
}, [messages, windowId]);
|
}, [messages, sessionId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (messages.length > 0) {
|
if (messages.length > 0) {
|
||||||
|
|||||||
@@ -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 */}
|
{/* Settings Menu */}
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|||||||
196
ui/desktop/src/components/sessions/SessionHistoryView.tsx
Normal file
196
ui/desktop/src/components/sessions/SessionHistoryView.tsx
Normal 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;
|
||||||
150
ui/desktop/src/components/sessions/SessionListView.tsx
Normal file
150
ui/desktop/src/components/sessions/SessionListView.tsx
Normal 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;
|
||||||
72
ui/desktop/src/components/sessions/SessionsView.tsx
Normal file
72
ui/desktop/src/components/sessions/SessionsView.tsx
Normal 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;
|
||||||
@@ -4,9 +4,16 @@ import Back from '../icons/Back';
|
|||||||
interface BackButtonProps {
|
interface BackButtonProps {
|
||||||
onClick?: () => void; // Mark onClick as optional
|
onClick?: () => void; // Mark onClick as optional
|
||||||
className?: string;
|
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 = () => {
|
const handleExit = () => {
|
||||||
if (onClick) {
|
if (onClick) {
|
||||||
onClick(); // Custom onClick handler passed via props
|
onClick(); // Custom onClick handler passed via props
|
||||||
@@ -20,10 +27,10 @@ const BackButton: React.FC<BackButtonProps> = ({ onClick, className = '' }) => {
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={handleExit}
|
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" />
|
<Back className="w-3 h-3 group-hover:-translate-x-1 transition-all mr-1" />
|
||||||
<span>Exit</span>
|
{showText && <span>Exit</span>}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
110
ui/desktop/src/sessions.ts
Normal file
110
ui/desktop/src/sessions.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
0
ui/desktop/src/utils/session.ts
Normal file
0
ui/desktop/src/utils/session.ts
Normal file
Reference in New Issue
Block a user