diff --git a/crates/goose-server/src/openapi.rs b/crates/goose-server/src/openapi.rs index e963dc37..17099ab8 100644 --- a/crates/goose-server/src/openapi.rs +++ b/crates/goose-server/src/openapi.rs @@ -9,6 +9,8 @@ use goose::message::{ }; use goose::permission::permission_confirmation::PrincipalType; use goose::providers::base::{ConfigKey, ModelInfo, ProviderMetadata}; +use goose::session::info::SessionInfo; +use goose::session::SessionMetadata; use mcp_core::content::{Annotations, Content, EmbeddedResource, ImageContent, TextContent}; use mcp_core::handler::ToolResultSchema; use mcp_core::resource::ResourceContents; @@ -33,7 +35,9 @@ use utoipa::OpenApi; super::routes::config_management::upsert_permissions, super::routes::agent::get_tools, super::routes::reply::confirm_permission, - super::routes::context::manage_context, // Added this path + super::routes::context::manage_context, + super::routes::session::list_sessions, + super::routes::session::get_session_history ), components(schemas( super::routes::config_management::UpsertConfigQuery, @@ -48,6 +52,8 @@ use utoipa::OpenApi; super::routes::reply::PermissionConfirmationRequest, super::routes::context::ContextManageRequest, super::routes::context::ContextManageResponse, + super::routes::session::SessionListResponse, + super::routes::session::SessionHistoryResponse, Message, MessageContent, Content, @@ -76,6 +82,8 @@ use utoipa::OpenApi; PermissionLevel, PrincipalType, ModelInfo, + SessionInfo, + SessionMetadata, )) )] pub struct ApiDoc; diff --git a/crates/goose-server/src/routes/session.rs b/crates/goose-server/src/routes/session.rs index 0c69dc5f..a5b22ebb 100644 --- a/crates/goose-server/src/routes/session.rs +++ b/crates/goose-server/src/routes/session.rs @@ -11,20 +11,41 @@ use axum::{ use goose::message::Message; use goose::session; use goose::session::info::{get_session_info, SessionInfo, SortOrder}; +use goose::session::SessionMetadata; use serde::Serialize; +use utoipa::ToSchema; -#[derive(Serialize)] -struct SessionListResponse { +#[derive(Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct SessionListResponse { + /// List of available session information objects sessions: Vec, } -#[derive(Serialize)] -struct SessionHistoryResponse { +#[derive(Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct SessionHistoryResponse { + /// Unique identifier for the session session_id: String, - metadata: session::SessionMetadata, + /// Session metadata containing creation time and other details + metadata: SessionMetadata, + /// List of messages in the session conversation messages: Vec, } +#[utoipa::path( + get, + path = "/sessions", + responses( + (status = 200, description = "List of available sessions retrieved successfully", body = SessionListResponse), + (status = 401, description = "Unauthorized - Invalid or missing API key"), + (status = 500, description = "Internal server error") + ), + security( + ("api_key" = []) + ), + tag = "Session Management" +)] // List all available sessions async fn list_sessions( State(state): State>, @@ -38,6 +59,23 @@ async fn list_sessions( Ok(Json(SessionListResponse { sessions })) } +#[utoipa::path( + get, + path = "/sessions/{session_id}", + params( + ("session_id" = String, Path, description = "Unique identifier for the session") + ), + responses( + (status = 200, description = "Session history retrieved successfully", body = SessionHistoryResponse), + (status = 401, description = "Unauthorized - Invalid or missing API key"), + (status = 404, description = "Session not found"), + (status = 500, description = "Internal server error") + ), + security( + ("api_key" = []) + ), + tag = "Session Management" +)] // Get a specific session's history async fn get_session_history( State(state): State>, diff --git a/crates/goose/src/message.rs b/crates/goose/src/message.rs index 5cbd6219..81be5519 100644 --- a/crates/goose/src/message.rs +++ b/crates/goose/src/message.rs @@ -77,9 +77,8 @@ pub struct RedactedThinkingContent { pub data: String, } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] -#[derive(ToSchema)] pub struct FrontendToolRequest { pub id: String, #[serde(with = "tool_result_serde")] diff --git a/crates/goose/src/session/info.rs b/crates/goose/src/session/info.rs index f44794d3..6056257a 100644 --- a/crates/goose/src/session/info.rs +++ b/crates/goose/src/session/info.rs @@ -1,10 +1,10 @@ +use crate::session::{self, SessionMetadata}; use anyhow::Result; use serde::Serialize; use std::cmp::Ordering; +use utoipa::ToSchema; -use crate::session::{self, SessionMetadata}; - -#[derive(Clone, Serialize)] +#[derive(Clone, Serialize, ToSchema)] pub struct SessionInfo { pub id: String, pub path: String, diff --git a/crates/goose/src/session/storage.rs b/crates/goose/src/session/storage.rs index d0fcb588..554ab02f 100644 --- a/crates/goose/src/session/storage.rs +++ b/crates/goose/src/session/storage.rs @@ -8,6 +8,7 @@ use std::fs::{self, File}; use std::io::{self, BufRead, Write}; use std::path::{Path, PathBuf}; use std::sync::Arc; +use utoipa::ToSchema; fn get_home_dir() -> PathBuf { choose_app_strategy(crate::config::APP_STRATEGY.clone()) @@ -17,9 +18,10 @@ fn get_home_dir() -> PathBuf { } /// Metadata for a session, stored as the first line in the session file -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, ToSchema)] pub struct SessionMetadata { /// Working directory for the session + #[schema(value_type = String, example = "/home/user/sessions/session1")] pub working_dir: PathBuf, /// A short description of the session, typically 3 words or less pub description: String, diff --git a/crates/mcp-core/src/resource.rs b/crates/mcp-core/src/resource.rs index 73ad8064..ae9c0689 100644 --- a/crates/mcp-core/src/resource.rs +++ b/crates/mcp-core/src/resource.rs @@ -26,9 +26,8 @@ pub struct Resource { pub annotations: Option, } -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[derive(ToSchema, Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(rename_all = "camelCase", untagged)] -#[derive(ToSchema)] pub enum ResourceContents { TextResourceContents { uri: String, diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index 5ae8bb24..a709fee1 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -452,6 +452,82 @@ } ] } + }, + "/sessions": { + "get": { + "tags": [ + "Session Management" + ], + "operationId": "list_sessions", + "responses": { + "200": { + "description": "List of available sessions retrieved successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionListResponse" + } + } + } + }, + "401": { + "description": "Unauthorized - Invalid or missing API key" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "api_key": [] + } + ] + } + }, + "/sessions/{session_id}": { + "get": { + "tags": [ + "Session Management" + ], + "operationId": "get_session_history", + "parameters": [ + { + "name": "session_id", + "in": "path", + "description": "Unique identifier for the session", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Session history retrieved successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionHistoryResponse" + } + } + } + }, + "401": { + "description": "Unauthorized - Invalid or missing API key" + }, + "404": { + "description": "Session not found" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "api_key": [] + } + ] + } } }, "components": { @@ -1372,6 +1448,129 @@ "assistant" ] }, + "SessionHistoryResponse": { + "type": "object", + "required": [ + "sessionId", + "metadata", + "messages" + ], + "properties": { + "messages": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Message" + }, + "description": "List of messages in the session conversation" + }, + "metadata": { + "$ref": "#/components/schemas/SessionMetadata" + }, + "sessionId": { + "type": "string", + "description": "Unique identifier for the session" + } + } + }, + "SessionInfo": { + "type": "object", + "required": [ + "id", + "path", + "modified", + "metadata" + ], + "properties": { + "id": { + "type": "string" + }, + "metadata": { + "$ref": "#/components/schemas/SessionMetadata" + }, + "modified": { + "type": "string" + }, + "path": { + "type": "string" + } + } + }, + "SessionListResponse": { + "type": "object", + "required": [ + "sessions" + ], + "properties": { + "sessions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SessionInfo" + }, + "description": "List of available session information objects" + } + } + }, + "SessionMetadata": { + "type": "object", + "description": "Metadata for a session, stored as the first line in the session file", + "required": [ + "working_dir", + "description", + "message_count" + ], + "properties": { + "accumulated_input_tokens": { + "type": "integer", + "format": "int32", + "description": "The number of input tokens used in the session. Accumulated across all messages.", + "nullable": true + }, + "accumulated_output_tokens": { + "type": "integer", + "format": "int32", + "description": "The number of output tokens used in the session. Accumulated across all messages.", + "nullable": true + }, + "accumulated_total_tokens": { + "type": "integer", + "format": "int32", + "description": "The total number of tokens used in the session. Accumulated across all messages (useful for tracking cost over an entire session).", + "nullable": true + }, + "description": { + "type": "string", + "description": "A short description of the session, typically 3 words or less" + }, + "input_tokens": { + "type": "integer", + "format": "int32", + "description": "The number of input tokens used in the session. Retrieved from the provider's last usage.", + "nullable": true + }, + "message_count": { + "type": "integer", + "description": "Number of messages in the session", + "minimum": 0 + }, + "output_tokens": { + "type": "integer", + "format": "int32", + "description": "The number of output tokens used in the session. Retrieved from the provider's last usage.", + "nullable": true + }, + "total_tokens": { + "type": "integer", + "format": "int32", + "description": "The total number of tokens used in the session. Retrieved from the provider's last usage.", + "nullable": true + }, + "working_dir": { + "type": "string", + "description": "Working directory for the session", + "example": "/home/user/sessions/session1" + } + } + }, "TextContent": { "type": "object", "required": [ diff --git a/ui/desktop/src/api/sdk.gen.ts b/ui/desktop/src/api/sdk.gen.ts index 041710cc..d1662884 100644 --- a/ui/desktop/src/api/sdk.gen.ts +++ b/ui/desktop/src/api/sdk.gen.ts @@ -1,7 +1,7 @@ // This file is auto-generated by @hey-api/openapi-ts import type { Options as ClientOptions, TDataShape, Client } from '@hey-api/client-fetch'; -import type { GetToolsData, GetToolsResponse, ReadAllConfigData, ReadAllConfigResponse, BackupConfigData, BackupConfigResponse, GetExtensionsData, GetExtensionsResponse, AddExtensionData, AddExtensionResponse, RemoveExtensionData, RemoveExtensionResponse, InitConfigData, InitConfigResponse, UpsertPermissionsData, UpsertPermissionsResponse, ProvidersData, ProvidersResponse2, ReadConfigData, RemoveConfigData, RemoveConfigResponse, UpsertConfigData, UpsertConfigResponse, ConfirmPermissionData, ManageContextData, ManageContextResponse } from './types.gen'; +import type { GetToolsData, GetToolsResponse, ReadAllConfigData, ReadAllConfigResponse, BackupConfigData, BackupConfigResponse, GetExtensionsData, GetExtensionsResponse, AddExtensionData, AddExtensionResponse, RemoveExtensionData, RemoveExtensionResponse, InitConfigData, InitConfigResponse, UpsertPermissionsData, UpsertPermissionsResponse, ProvidersData, ProvidersResponse2, ReadConfigData, RemoveConfigData, RemoveConfigResponse, UpsertConfigData, UpsertConfigResponse, ConfirmPermissionData, ManageContextData, ManageContextResponse, ListSessionsData, ListSessionsResponse, GetSessionHistoryData, GetSessionHistoryResponse } from './types.gen'; import { client as _heyApiClient } from './client.gen'; export type Options = ClientOptions & { @@ -142,4 +142,18 @@ export const manageContext = (options: Opt ...options?.headers } }); +}; + +export const listSessions = (options?: Options) => { + return (options?.client ?? _heyApiClient).get({ + url: '/sessions', + ...options + }); +}; + +export const getSessionHistory = (options: Options) => { + return (options.client ?? _heyApiClient).get({ + url: '/sessions/{session_id}', + ...options + }); }; \ No newline at end of file diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index d96ecbcb..cbbf895f 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -292,6 +292,74 @@ export type ResourceContents = { export type Role = 'user' | 'assistant'; +export type SessionHistoryResponse = { + /** + * List of messages in the session conversation + */ + messages: Array; + metadata: SessionMetadata; + /** + * Unique identifier for the session + */ + sessionId: string; +}; + +export type SessionInfo = { + id: string; + metadata: SessionMetadata; + modified: string; + path: string; +}; + +export type SessionListResponse = { + /** + * List of available session information objects + */ + sessions: Array; +}; + +/** + * Metadata for a session, stored as the first line in the session file + */ +export type SessionMetadata = { + /** + * The number of input tokens used in the session. Accumulated across all messages. + */ + accumulated_input_tokens?: number | null; + /** + * The number of output tokens used in the session. Accumulated across all messages. + */ + accumulated_output_tokens?: number | null; + /** + * The total number of tokens used in the session. Accumulated across all messages (useful for tracking cost over an entire session). + */ + accumulated_total_tokens?: number | null; + /** + * A short description of the session, typically 3 words or less + */ + description: string; + /** + * The number of input tokens used in the session. Retrieved from the provider's last usage. + */ + input_tokens?: number | null; + /** + * Number of messages in the session + */ + message_count: number; + /** + * The number of output tokens used in the session. Retrieved from the provider's last usage. + */ + output_tokens?: number | null; + /** + * The total number of tokens used in the session. Retrieved from the provider's last usage. + */ + total_tokens?: number | null; + /** + * Working directory for the session + */ + working_dir: string; +}; + export type TextContent = { annotations?: Annotations | null; text: string; @@ -775,6 +843,69 @@ export type ManageContextResponses = { export type ManageContextResponse = ManageContextResponses[keyof ManageContextResponses]; +export type ListSessionsData = { + body?: never; + path?: never; + query?: never; + url: '/sessions'; +}; + +export type ListSessionsErrors = { + /** + * Unauthorized - Invalid or missing API key + */ + 401: unknown; + /** + * Internal server error + */ + 500: unknown; +}; + +export type ListSessionsResponses = { + /** + * List of available sessions retrieved successfully + */ + 200: SessionListResponse; +}; + +export type ListSessionsResponse = ListSessionsResponses[keyof ListSessionsResponses]; + +export type GetSessionHistoryData = { + body?: never; + path: { + /** + * Unique identifier for the session + */ + session_id: string; + }; + query?: never; + url: '/sessions/{session_id}'; +}; + +export type GetSessionHistoryErrors = { + /** + * Unauthorized - Invalid or missing API key + */ + 401: unknown; + /** + * Session not found + */ + 404: unknown; + /** + * Internal server error + */ + 500: unknown; +}; + +export type GetSessionHistoryResponses = { + /** + * Session history retrieved successfully + */ + 200: SessionHistoryResponse; +}; + +export type GetSessionHistoryResponse = GetSessionHistoryResponses[keyof GetSessionHistoryResponses]; + export type ClientOptions = { baseUrl: `${string}://${string}` | (string & {}); }; \ No newline at end of file diff --git a/ui/desktop/src/components/ChatView.tsx b/ui/desktop/src/components/ChatView.tsx index 68059782..bbc402d8 100644 --- a/ui/desktop/src/components/ChatView.tsx +++ b/ui/desktop/src/components/ChatView.tsx @@ -15,12 +15,15 @@ import { SearchView } from './conversation/SearchView'; import { createRecipe } from '../recipe'; import { AgentHeader } from './AgentHeader'; import LayingEggLoader from './LayingEggLoader'; -import { fetchSessionDetails } from '../sessions'; +import { fetchSessionDetails, generateSessionId } from '../sessions'; import 'react-toastify/dist/ReactToastify.css'; import { useMessageStream } from '../hooks/useMessageStream'; import { SessionSummaryModal } from './context_management/SessionSummaryModal'; import { Recipe } from '../recipe'; -import { ContextManagerProvider, useChatContextManager } from './context_management/ContextManager'; +import { + ChatContextManagerProvider, + useChatContextManager, +} from './context_management/ContextManager'; import { ContextLengthExceededHandler } from './context_management/ContextLengthExceededHandler'; import { Message, @@ -64,14 +67,14 @@ export default function ChatView({ setIsGoosehintsModalOpen: (isOpen: boolean) => void; }) { return ( - + - + ); } @@ -93,7 +96,9 @@ function ChatContent({ const [showGame, setShowGame] = useState(false); const [isGeneratingRecipe, setIsGeneratingRecipe] = useState(false); const [sessionTokenCount, setSessionTokenCount] = useState(0); + const [ancestorMessages, setAncestorMessages] = useState([]); const [droppedFiles, setDroppedFiles] = useState([]); + const scrollRef = useRef(null); const { @@ -108,7 +113,6 @@ function ChatContent({ useEffect(() => { // Log all messages when the component first mounts - console.log('Initial messages when resuming session:', chat.messages); window.electron.logInfo( 'Initial messages when resuming session: ' + JSON.stringify(chat.messages, null, 2) ); @@ -129,6 +133,7 @@ function ChatContent({ setInput: _setInput, handleInputChange: _handleInputChange, handleSubmit: _submitMessage, + updateMessageStreamBody, } = useMessageStream({ api: getApiUrl('/reply'), initialMessages: chat.messages, @@ -159,6 +164,36 @@ function ChatContent({ }, }); + // for CLE events -- create a new session id for the next set of messages + useEffect(() => { + // If we're in a continuation session, update the chat ID + if (summarizedThread.length > 0) { + const newSessionId = generateSessionId(); + + // Update the session ID in the chat object + setChat({ + ...chat, + id: newSessionId!, + title: `Continued from ${chat.id}`, + messageHistoryIndex: summarizedThread.length, + }); + + // Update the body used by useMessageStream to send future messages to the new session + if (summarizedThread.length > 0 && updateMessageStreamBody) { + updateMessageStreamBody({ + session_id: newSessionId, + session_working_dir: window.appConfig.get('GOOSE_WORKING_DIR'), + }); + } + } + + // only update if summarizedThread length changes from 0 -> 1+ + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + // eslint-disable-next-line react-hooks/exhaustive-deps + summarizedThread.length > 0, + ]); + // Listen for make-agent-from-chat event useEffect(() => { const handleMakeAgent = async () => { @@ -201,7 +236,8 @@ function ChatContent({ window.electron.logInfo('Opening recipe editor window'); } catch (error) { window.electron.logInfo('Failed to create recipe:'); - window.electron.logInfo(error.message); + const errorMessage = error instanceof Error ? error.message : String(error); + window.electron.logInfo(errorMessage); } finally { setIsGeneratingRecipe(false); } @@ -233,25 +269,33 @@ function ChatContent({ // Handle submit const handleSubmit = (e: React.FormEvent) => { window.electron.startPowerSaveBlocker(); - const customEvent = e as CustomEvent; + const customEvent = e as unknown as CustomEvent; const content = customEvent.detail?.value || ''; if (content.trim()) { setLastInteractionTime(Date.now()); - if (process.env.ALPHA && summarizedThread.length > 0) { - // First reset the messages with the summary - resetMessagesWithSummary(messages, setMessages); + if (summarizedThread.length > 0) { + // move current `messages` to `ancestorMessages` and `messages` to `summarizedThread` + resetMessagesWithSummary( + messages, + setMessages, + ancestorMessages, + setAncestorMessages, + summaryContent + ); - // Then append the new user message + // update the chat with new sessionId + + // now call the llm setTimeout(() => { append(createUserMessage(content)); if (scrollRef.current?.scrollToBottom) { scrollRef.current.scrollToBottom(); } - }, 150); // Small delay to ensure state updates properly + }, 150); } else { - // Normal flow - just append the message + // Normal flow (existing code) append(createUserMessage(content)); if (scrollRef.current?.scrollToBottom) { scrollRef.current.scrollToBottom(); @@ -324,6 +368,8 @@ function ChatContent({ // Create tool responses for all interrupted tool requests let responseMessage: Message = { + display: true, + sendToLLM: true, role: 'user', created: Date.now(), content: [], @@ -352,7 +398,7 @@ function ChatContent({ // Filter out standalone tool response messages for rendering // They will be shown as part of the tool invocation in the assistant message - const filteredMessages = messages.filter((message) => { + const filteredMessages = [...ancestorMessages, ...messages].filter((message) => { // Only filter out when display is explicitly false if (message.display === false) return false; @@ -466,10 +512,13 @@ function ChatContent({ ) : ( <> {/* Only render GooseMessage if it's not a CLE message (and we are not in alpha mode) */} - {process.env.ALPHA && hasContextLengthExceededContent(message) ? ( + {process.env.NODE_ENV === 'development' && + hasContextLengthExceededContent(message) ? ( ) : ( {showGame && setShowGame(false)} />} - {process.env.ALPHA && ( + {process.env.NODE_ENV === 'development' && ( = ({ messages, messageId, + chatId, + workingDir, }) => { - const { fetchSummary, summaryContent, isLoadingSummary, errorLoadingSummary, openSummaryModal } = - useChatContextManager(); + const { + summaryContent, + isLoadingSummary, + errorLoadingSummary, + openSummaryModal, + handleContextLengthExceeded, + } = useChatContextManager(); const [hasFetchStarted, setHasFetchStarted] = useState(false); // Find the relevant message to check if it's the latest const isCurrentMessageLatest = - messageId === messages[messages.length - 1].id || - messageId === messages[messages.length - 1].created.toString(); + messageId === messages[messages.length - 1]?.id || + messageId === String(messages[messages.length - 1]?.created); // Only allow interaction for the most recent context length exceeded event const shouldAllowSummaryInteraction = isCurrentMessageLatest; @@ -27,31 +36,60 @@ export const ContextLengthExceededHandler: React.FC { + setHasFetchStarted(true); + fetchStartedRef.current = true; + + // Call the async function without awaiting it in useEffect + handleContextLengthExceeded(messages, chatId, workingDir).catch((err) => { + console.error('Error handling context length exceeded:', err); + }); + }; + useEffect(() => { - // Automatically fetch summary if conditions are met if ( !summaryContent && !hasFetchStarted && shouldAllowSummaryInteraction && !fetchStartedRef.current ) { - setHasFetchStarted(true); - fetchStartedRef.current = true; - fetchSummary(messages); + // Use the wrapper function instead of calling the async function directly + triggerContextLengthExceeded(); } - }, [fetchSummary, hasFetchStarted, messages, shouldAllowSummaryInteraction, summaryContent]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + hasFetchStarted, + messages, + shouldAllowSummaryInteraction, + summaryContent, + chatId, + workingDir, + ]); - // Handle retry + // Handle retry - Call the async function properly const handleRetry = () => { if (!shouldAllowSummaryInteraction) return; - fetchSummary(messages); + + // Reset states for retry + setHasFetchStarted(false); + fetchStartedRef.current = false; + + // Trigger the process again + triggerContextLengthExceeded(); }; // Render the notification UI return (
+ {/* Horizontal line with text in the middle - shown regardless of loading state */} +
+
+
+
+ {isLoadingSummary && shouldAllowSummaryInteraction ? ( - // Only show loading indicator during loading state + // Show loading indicator during loading state
Preparing summary... @@ -59,15 +97,17 @@ export const ContextLengthExceededHandler: React.FC - {'Session summarized'} - + {`Your conversation has exceeded the model's context capacity`} + {`Messages above this line remain viewable but are not included in the active context`} {/* Only show the button if its last message */} {shouldAllowSummaryInteraction && ( )} diff --git a/ui/desktop/src/components/context_management/ContextManager.tsx b/ui/desktop/src/components/context_management/ContextManager.tsx index 55c01629..05bc0280 100644 --- a/ui/desktop/src/components/context_management/ContextManager.tsx +++ b/ui/desktop/src/components/context_management/ContextManager.tsx @@ -3,7 +3,7 @@ import { Message } from '../../types/message'; import { manageContextFromBackend, convertApiMessageToFrontendMessage } from './index'; // Define the context management interface -interface ContextManagerState { +interface ChatContextManagerState { summaryContent: string; summarizedThread: Message[]; isSummaryModalOpen: boolean; @@ -11,31 +11,73 @@ interface ContextManagerState { errorLoadingSummary: boolean; } -interface ContextManagerActions { +interface ChatContextManagerActions { fetchSummary: (messages: Message[]) => Promise; updateSummary: (newSummaryContent: string) => void; resetMessagesWithSummary: ( messages: Message[], - setMessages: (messages: Message[]) => void + setMessages: (messages: Message[]) => void, + ancestorMessages: Message[], + setAncestorMessages: (messages: Message[]) => void, + summaryContent: string ) => void; openSummaryModal: () => void; closeSummaryModal: () => void; hasContextLengthExceededContent: (message: Message) => boolean; + handleContextLengthExceeded: ( + messages: Message[], + chatId: string, + workingDir: string + ) => Promise; } // Create the context -const ContextManagerContext = createContext< - (ContextManagerState & ContextManagerActions) | undefined +const ChatContextManagerContext = createContext< + (ChatContextManagerState & ChatContextManagerActions) | undefined >(undefined); // Create the provider component -export const ContextManagerProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { +export const ChatContextManagerProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { const [summaryContent, setSummaryContent] = useState(''); const [summarizedThread, setSummarizedThread] = useState([]); const [isSummaryModalOpen, setIsSummaryModalOpen] = useState(false); const [isLoadingSummary, setIsLoadingSummary] = useState(false); const [errorLoadingSummary, setErrorLoadingSummary] = useState(false); + const handleContextLengthExceeded = async (messages: Message[]): Promise => { + setIsLoadingSummary(true); + setErrorLoadingSummary(false); + + try { + // 2. Now get the summary from the backend + const summaryResponse = await manageContextFromBackend({ + messages: messages, + manageAction: 'summarize', + }); + + // Convert API messages to frontend messages + const convertedMessages = summaryResponse.messages.map( + (apiMessage) => convertApiMessageToFrontendMessage(apiMessage, false, true) // do not show to user but send to llm + ); + + // Extract summary from the first message + const summaryMessage = convertedMessages[0].content[0]; + if (summaryMessage.type === 'text') { + const summary = summaryMessage.text; + setSummaryContent(summary); + setSummarizedThread(convertedMessages); + } + + setIsLoadingSummary(false); + } catch (err) { + console.error('Error handling context length exceeded:', err); + setErrorLoadingSummary(true); + setIsLoadingSummary(false); + } + }; + const fetchSummary = async (messages: Message[]) => { setIsLoadingSummary(true); setErrorLoadingSummary(false); @@ -47,8 +89,8 @@ export const ContextManagerProvider: React.FC<{ children: React.ReactNode }> = ( }); // Convert API messages to frontend messages - const convertedMessages = response.messages.map((apiMessage) => - convertApiMessageToFrontendMessage(apiMessage) + const convertedMessages = response.messages.map( + (apiMessage) => convertApiMessageToFrontendMessage(apiMessage, false, true) // do not show to user but send to llm ); // Extract the summary text from the first message @@ -101,27 +143,69 @@ export const ContextManagerProvider: React.FC<{ children: React.ReactNode }> = ( const resetMessagesWithSummary = ( messages: Message[], - setMessages: (messages: Message[]) => void + setMessages: (messages: Message[]) => void, + ancestorMessages: Message[], + setAncestorMessages: (messages: Message[]) => void, + summaryContent: string ) => { - // Update summarizedThread with metadata - const updatedSummarizedThread = summarizedThread.map((msg) => ({ - ...msg, - display: false, - sendToLLM: true, - })); + // Create a copy of the summarized thread + const updatedSummarizedThread = [...summarizedThread]; - // Update list of messages with other metadata - const updatedMessages = messages.map((msg) => ({ - ...msg, - display: true, - sendToLLM: false, - })); + // Make sure there's at least one message in the summarized thread + if (updatedSummarizedThread.length > 0) { + // Get the first message + const firstMessage = { ...updatedSummarizedThread[0] }; - // Make a copy that combines both - const newMessages = [...updatedMessages, ...updatedSummarizedThread]; + // Make a copy of the content array + const contentCopy = [...firstMessage.content]; + + // Assuming the first content item is of type TextContent + if (contentCopy.length > 0 && 'text' in contentCopy[0]) { + // Update the text with the new summary content + contentCopy[0] = { + ...contentCopy[0], + text: summaryContent, + }; + + // Update the first message with the new content + firstMessage.content = contentCopy; + + // Update the first message in the thread + updatedSummarizedThread[0] = firstMessage; + } + } + + // Update metadata for the summarized thread + const finalUpdatedThread = updatedSummarizedThread.map((msg, index) => ({ + ...msg, + display: index === 0, // First message has display: true, others false + sendToLLM: true, // All messages have sendToLLM: true + })); // Update the messages state - setMessages(newMessages); + setMessages(finalUpdatedThread); + + // If ancestorMessages already has items, extend it instead of replacing it + if (ancestorMessages.length > 0) { + // Convert current messages to ancestor format + const newAncestorMessages = messages.map((msg) => ({ + ...msg, + display: true, + sendToLLM: false, + })); + + // Append new ancestor messages to existing ones + setAncestorMessages([...ancestorMessages, ...newAncestorMessages]); + } else { + // Initial set of ancestor messages + const newAncestorMessages = messages.map((msg) => ({ + ...msg, + display: true, + sendToLLM: false, + })); + + setAncestorMessages(newAncestorMessages); + } // Clear the summarized thread and content setSummarizedThread([]); @@ -155,14 +239,19 @@ export const ContextManagerProvider: React.FC<{ children: React.ReactNode }> = ( openSummaryModal, closeSummaryModal, hasContextLengthExceededContent, + handleContextLengthExceeded, }; - return {children}; + return ( + + {children} + + ); }; // Create a hook to use the context export const useChatContextManager = () => { - const context = useContext(ContextManagerContext); + const context = useContext(ChatContextManagerContext); if (context === undefined) { throw new Error('useContextManager must be used within a ContextManagerProvider'); } diff --git a/ui/desktop/src/components/context_management/SessionSummaryModal.tsx b/ui/desktop/src/components/context_management/SessionSummaryModal.tsx index 7a875f54..13d8eae0 100644 --- a/ui/desktop/src/components/context_management/SessionSummaryModal.tsx +++ b/ui/desktop/src/components/context_management/SessionSummaryModal.tsx @@ -93,6 +93,12 @@ export function SessionSummaryModal({ ref={textareaRef} defaultValue={summaryContent} className="bg-gray-50 dark:bg-gray-800 p-4 rounded-lg text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-gray-700 text-sm w-full min-h-[200px] focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" + style={{ + textRendering: 'optimizeLegibility', + WebkitFontSmoothing: 'antialiased', + MozOsxFontSmoothing: 'grayscale', + transform: 'translateZ(0)', // Force hardware acceleration + }} />
); diff --git a/ui/desktop/src/components/context_management/index.ts b/ui/desktop/src/components/context_management/index.ts index 52c1298d..aef55632 100644 --- a/ui/desktop/src/components/context_management/index.ts +++ b/ui/desktop/src/components/context_management/index.ts @@ -49,10 +49,14 @@ export async function manageContextFromBackend({ } // Function to convert API Message to frontend Message -export function convertApiMessageToFrontendMessage(apiMessage: ApiMessage): FrontendMessage { +export function convertApiMessageToFrontendMessage( + apiMessage: ApiMessage, + display?: boolean, + sendToLLM?: boolean +): FrontendMessage { return { - display: false, - sendToLLM: false, + display: display ?? true, + sendToLLM: sendToLLM ?? true, id: generateId(), role: apiMessage.role, created: apiMessage.created, diff --git a/ui/desktop/src/components/context_management/sessionManagement.ts b/ui/desktop/src/components/context_management/sessionManagement.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/ui/desktop/src/components/sessions/SessionListView.tsx b/ui/desktop/src/components/sessions/SessionListView.tsx index 61dc8878..ddeebd72 100644 --- a/ui/desktop/src/components/sessions/SessionListView.tsx +++ b/ui/desktop/src/components/sessions/SessionListView.tsx @@ -35,8 +35,8 @@ const SessionListView: React.FC = ({ setView, onSelectSess setIsLoading(true); setError(null); try { - const response = await fetchSessions(); - setSessions(response.sessions); + const sessions = await fetchSessions(); + setSessions(sessions); } catch (err) { console.error('Failed to load sessions:', err); setError('Failed to load sessions. Please try again later.'); diff --git a/ui/desktop/src/components/sessions/SessionsView.tsx b/ui/desktop/src/components/sessions/SessionsView.tsx index c28cd7be..0cd09550 100644 --- a/ui/desktop/src/components/sessions/SessionsView.tsx +++ b/ui/desktop/src/components/sessions/SessionsView.tsx @@ -47,6 +47,8 @@ const SessionsView: React.FC = ({ setView }) => { const handleResumeSession = () => { if (selectedSession) { + console.log('Selected session object:', JSON.stringify(selectedSession, null, 2)); + // Get the working directory from the session metadata const workingDir = selectedSession.metadata.working_dir; diff --git a/ui/desktop/src/hooks/useMessageStream.ts b/ui/desktop/src/hooks/useMessageStream.ts index 9ad56544..71e096c0 100644 --- a/ui/desktop/src/hooks/useMessageStream.ts +++ b/ui/desktop/src/hooks/useMessageStream.ts @@ -121,6 +121,9 @@ export interface UseMessageStreamHelpers { /** Add a tool result to a tool call */ addToolResult: ({ toolCallId, result }: { toolCallId: string; result: unknown }) => void; + + /** Modify body (session id and/or work dir mid-stream) **/ + updateMessageStreamBody?: (newBody: object) => void; } /** @@ -148,6 +151,14 @@ export function useMessageStream({ fallbackData: initialMessages, }); + // expose a way to update the body so we can update the session id when CLE occurs + const updateMessageStreamBody = useCallback((newBody: object) => { + extraMetadataRef.current.body = { + ...extraMetadataRef.current.body, + ...newBody, + }; + }, []); + // Keep the latest messages in a ref const messagesRef = useRef(messages || []); useEffect(() => { @@ -505,5 +516,6 @@ export function useMessageStream({ handleSubmit, isLoading: isLoading || false, addToolResult, + updateMessageStreamBody, }; } diff --git a/ui/desktop/src/sessions.ts b/ui/desktop/src/sessions.ts index 08a7bd7a..b4ba4374 100644 --- a/ui/desktop/src/sessions.ts +++ b/ui/desktop/src/sessions.ts @@ -1,5 +1,6 @@ -import { getApiUrl, getSecretKey } from './config'; import { Message } from './types/message'; +import { getSessionHistory, listSessions, SessionInfo } from './api'; +import { convertApiMessageToFrontendMessage } from './components/context_management'; export interface SessionMetadata { description: string; @@ -54,36 +55,40 @@ export function generateSessionId(): string { * Fetches all available sessions from the API * @returns Promise with sessions data */ -export async function fetchSessions(): Promise { +/** + * Fetches all available sessions from the API + * @returns Promise with an array of Session objects + */ +export async function fetchSessions(): Promise { try { - const response = await fetch(getApiUrl('/sessions'), { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - 'X-Secret-Key': getSecretKey(), - }, - }); + const response = await listSessions(); - if (!response.ok) { - throw new Error(`Failed to fetch sessions: ${response.status} ${response.statusText}`); + // Check if the response has the expected structure + if (response && response.data && response.data.sessions) { + // Since the API returns SessionInfo, we need to convert to Session + const sessions = response.data.sessions + .filter( + (sessionInfo: SessionInfo) => + sessionInfo.metadata && sessionInfo.metadata.description !== '' + ) + .map( + (sessionInfo: SessionInfo): Session => ({ + id: sessionInfo.id, + path: sessionInfo.path, + modified: sessionInfo.modified, + metadata: ensureWorkingDir(sessionInfo.metadata), + }) + ); + + // order sessions by 'modified' date descending + sessions.sort( + (a: Session, b: Session) => new Date(b.modified).getTime() - new Date(a.modified).getTime() + ); + + return sessions; + } else { + throw new Error('Unexpected response format from listSessions'); } - - // 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 rawSessions = await response.json(); - const sessions = rawSessions.sessions - .filter((session: Session) => session.metadata.description !== '') - .map((session: Session) => ({ - ...session, - metadata: ensureWorkingDir(session.metadata), - })); - - // 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; @@ -97,22 +102,17 @@ export async function fetchSessions(): Promise { */ export async function fetchSessionDetails(sessionId: string): Promise { try { - const response = await fetch(getApiUrl(`/sessions/${sessionId}`), { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - 'X-Secret-Key': getSecretKey(), - }, + const response = await getSessionHistory({ + path: { session_id: sessionId }, }); - if (!response.ok) { - throw new Error(`Failed to fetch session details: ${response.status} ${response.statusText}`); - } - - const details = await response.json(); + // Convert the SessionHistoryResponse to a SessionDetails object return { - ...details, - metadata: ensureWorkingDir(details.metadata), + session_id: response.data.sessionId, + metadata: ensureWorkingDir(response.data.metadata), + messages: response.data.messages.map((message) => + convertApiMessageToFrontendMessage(message, true, true) + ), // slight diffs between backend and frontend Message obj }; } catch (error) { console.error(`Error fetching session details for ${sessionId}:`, error);