mirror of
https://github.com/block/goose.git
synced 2026-04-28 03:29:36 +00:00
Next camp (#5237)
Co-authored-by: Douwe Osinga <douwe@squareup.com> Co-authored-by: Zane <75694352+zanesq@users.noreply.github.com> Co-authored-by: Zane Staggs <zane@squareup.com>
This commit is contained in:
parent
5076481092
commit
d836a10af1
21 changed files with 671 additions and 477 deletions
15
Justfile
15
Justfile
|
|
@ -170,13 +170,14 @@ run-ui-only:
|
|||
@echo "Running UI..."
|
||||
cd ui/desktop && npm install && npm run start-gui
|
||||
|
||||
debug-ui:
|
||||
@echo "🚀 Starting goose frontend in external backend mode"
|
||||
cd ui/desktop && \
|
||||
export GOOSE_EXTERNAL_BACKEND=true && \
|
||||
export GOOSE_EXTERNAL_PORT=3000 && \
|
||||
npm install && \
|
||||
npm run start-gui
|
||||
debug-ui *alpha:
|
||||
@echo "🚀 Starting goose frontend in external backend mode{{ if alpha == "alpha" { " with alpha features enabled" } else { "" } }}"
|
||||
cd ui/desktop && \
|
||||
export GOOSE_EXTERNAL_BACKEND=true && \
|
||||
export GOOSE_EXTERNAL_PORT=3000 && \
|
||||
{{ if alpha == "alpha" { "export ALPHA=true &&" } else { "" } }} \
|
||||
npm install && \
|
||||
npm run {{ if alpha == "alpha" { "start-alpha-gui" } else { "start-gui" } }}
|
||||
|
||||
# Run UI with main process debugging enabled
|
||||
# To debug main process:
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ use goose::config::PermissionManager;
|
|||
use goose::config::Config;
|
||||
use goose::model::ModelConfig;
|
||||
use goose::prompt_template::render_global_file;
|
||||
use goose::providers::create;
|
||||
use goose::providers::{create, create_with_named_model};
|
||||
use goose::recipe::Recipe;
|
||||
use goose::recipe_deeplink;
|
||||
use goose::session::{Session, SessionManager};
|
||||
|
|
@ -28,7 +28,7 @@ use std::collections::HashMap;
|
|||
use std::path::PathBuf;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::sync::Arc;
|
||||
use tracing::error;
|
||||
use tracing::{error, warn};
|
||||
|
||||
#[derive(Deserialize, utoipa::ToSchema)]
|
||||
pub struct UpdateFromSessionRequest {
|
||||
|
|
@ -67,6 +67,7 @@ pub struct StartAgentRequest {
|
|||
#[derive(Deserialize, utoipa::ToSchema)]
|
||||
pub struct ResumeAgentRequest {
|
||||
session_id: String,
|
||||
load_model_and_extensions: bool,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
|
|
@ -172,6 +173,7 @@ async fn start_agent(
|
|||
)
|
||||
)]
|
||||
async fn resume_agent(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(payload): Json<ResumeAgentRequest>,
|
||||
) -> Result<Json<Session>, ErrorResponse> {
|
||||
let session = SessionManager::get_session(&payload.session_id, true)
|
||||
|
|
@ -184,6 +186,74 @@ async fn resume_agent(
|
|||
}
|
||||
})?;
|
||||
|
||||
if payload.load_model_and_extensions {
|
||||
let agent = state
|
||||
.get_agent_for_route(payload.session_id)
|
||||
.await
|
||||
.map_err(|code| ErrorResponse {
|
||||
message: "Failed to get agent for route".into(),
|
||||
status: code,
|
||||
})?;
|
||||
|
||||
let config = Config::global();
|
||||
|
||||
let provider_result = async {
|
||||
let provider_name: String =
|
||||
config
|
||||
.get_param("GOOSE_PROVIDER")
|
||||
.map_err(|_| ErrorResponse {
|
||||
message: "Could not configure agent: missing provider".into(),
|
||||
status: StatusCode::INTERNAL_SERVER_ERROR,
|
||||
})?;
|
||||
|
||||
let model: String = config.get_param("GOOSE_MODEL").map_err(|_| ErrorResponse {
|
||||
message: "Could not configure agent: missing model".into(),
|
||||
status: StatusCode::INTERNAL_SERVER_ERROR,
|
||||
})?;
|
||||
|
||||
let provider = create_with_named_model(&provider_name, &model)
|
||||
.await
|
||||
.map_err(|_| ErrorResponse {
|
||||
message: "Could not configure agent: missing model".into(),
|
||||
status: StatusCode::INTERNAL_SERVER_ERROR,
|
||||
})?;
|
||||
|
||||
agent
|
||||
.update_provider(provider)
|
||||
.await
|
||||
.map_err(|e| ErrorResponse {
|
||||
message: format!("Could not configure agent: {}", e),
|
||||
status: StatusCode::INTERNAL_SERVER_ERROR,
|
||||
})
|
||||
};
|
||||
|
||||
let extensions_result = async {
|
||||
let enabled_configs = goose::config::get_enabled_extensions();
|
||||
let agent_clone = agent.clone();
|
||||
|
||||
let extension_futures = enabled_configs
|
||||
.into_iter()
|
||||
.map(|config| {
|
||||
let config_clone = config.clone();
|
||||
let agent_ref = agent_clone.clone();
|
||||
|
||||
async move {
|
||||
if let Err(e) = agent_ref.add_extension(config_clone.clone()).await {
|
||||
warn!("Failed to load extension {}: {}", config_clone.name(), e);
|
||||
}
|
||||
Ok::<_, ErrorResponse>(())
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
futures::future::join_all(extension_futures).await;
|
||||
Ok::<(), ErrorResponse>(()) // Fixed type annotation
|
||||
};
|
||||
|
||||
let (provider_result, _) = tokio::join!(provider_result, extensions_result);
|
||||
provider_result?;
|
||||
}
|
||||
|
||||
Ok(Json(session))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -63,9 +63,7 @@ pub struct SessionUpdateBuilder {
|
|||
#[derive(Serialize, ToSchema, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SessionInsights {
|
||||
/// Total number of sessions
|
||||
total_sessions: usize,
|
||||
/// Total tokens used across all sessions
|
||||
total_tokens: i64,
|
||||
}
|
||||
|
||||
|
|
@ -785,7 +783,9 @@ impl SessionStorage {
|
|||
.await?;
|
||||
|
||||
let mut messages = Vec::new();
|
||||
for (role_str, content_json, created_timestamp, metadata_json) in rows {
|
||||
for (idx, (role_str, content_json, created_timestamp, metadata_json)) in
|
||||
rows.into_iter().enumerate()
|
||||
{
|
||||
let role = match role_str.as_str() {
|
||||
"user" => Role::User,
|
||||
"assistant" => Role::Assistant,
|
||||
|
|
@ -799,6 +799,8 @@ impl SessionStorage {
|
|||
|
||||
let mut message = Message::new(role, created_timestamp, content);
|
||||
message.metadata = metadata;
|
||||
// TODO(Douwe): make id required
|
||||
message = message.with_id(format!("msg_{}_{}", session_id, idx));
|
||||
messages.push(message);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3794,9 +3794,13 @@
|
|||
"ResumeAgentRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"session_id"
|
||||
"session_id",
|
||||
"load_model_and_extensions"
|
||||
],
|
||||
"properties": {
|
||||
"load_model_and_extensions": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"session_id": {
|
||||
"type": "string"
|
||||
}
|
||||
|
|
@ -4122,13 +4126,11 @@
|
|||
"properties": {
|
||||
"totalSessions": {
|
||||
"type": "integer",
|
||||
"description": "Total number of sessions",
|
||||
"minimum": 0
|
||||
},
|
||||
"totalTokens": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"description": "Total tokens used across all sessions"
|
||||
"format": "int64"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -89,18 +89,32 @@ const PairRouteWrapper = ({
|
|||
const setView = useNavigation();
|
||||
const routeState =
|
||||
(location.state as PairRouteState) || (window.history.state as PairRouteState) || {};
|
||||
const [searchParams] = useSearchParams();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [initialMessage] = useState(routeState.initialMessage);
|
||||
|
||||
const resumeSessionId = searchParams.get('resumeSessionId') ?? undefined;
|
||||
|
||||
// Determine which session ID to use:
|
||||
// 1. From route state (when navigating from Hub with a new session)
|
||||
// 2. From URL params (when resuming a session)
|
||||
// 3. From the existing chat state (when navigating to Pair directly)
|
||||
const sessionId = routeState.resumeSessionId || resumeSessionId || chat.sessionId;
|
||||
|
||||
// Update URL with session ID if it's not already there (new chat from pair)
|
||||
useEffect(() => {
|
||||
if (process.env.ALPHA && sessionId && sessionId !== resumeSessionId) {
|
||||
setSearchParams((prev) => {
|
||||
prev.set('resumeSessionId', sessionId);
|
||||
return prev;
|
||||
});
|
||||
}
|
||||
}, [sessionId, resumeSessionId, setSearchParams]);
|
||||
|
||||
return process.env.ALPHA ? (
|
||||
<Pair2
|
||||
chat={chat}
|
||||
setChat={setChat}
|
||||
setView={setView}
|
||||
setIsGoosehintsModalOpen={setIsGoosehintsModalOpen}
|
||||
resumeSessionId={resumeSessionId}
|
||||
sessionId={sessionId}
|
||||
initialMessage={initialMessage}
|
||||
/>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -605,6 +605,7 @@ export type Response = {
|
|||
};
|
||||
|
||||
export type ResumeAgentRequest = {
|
||||
load_model_and_extensions: boolean;
|
||||
session_id: string;
|
||||
};
|
||||
|
||||
|
|
@ -707,13 +708,7 @@ export type SessionDisplayInfo = {
|
|||
};
|
||||
|
||||
export type SessionInsights = {
|
||||
/**
|
||||
* Total number of sessions
|
||||
*/
|
||||
totalSessions: number;
|
||||
/**
|
||||
* Total tokens used across all sessions
|
||||
*/
|
||||
totalTokens: number;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,37 +0,0 @@
|
|||
interface AgentHeaderProps {
|
||||
title: string;
|
||||
profileInfo?: string;
|
||||
onChangeProfile?: () => void;
|
||||
showBorder?: boolean;
|
||||
}
|
||||
|
||||
export function AgentHeader({
|
||||
title,
|
||||
profileInfo,
|
||||
onChangeProfile,
|
||||
showBorder = false,
|
||||
}: AgentHeaderProps) {
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center justify-between px-4 py-2 ${showBorder ? 'border-b border-borderSubtle' : ''}`}
|
||||
>
|
||||
<div className="flex items-center ml-6">
|
||||
<span className="w-2 h-2 rounded-full bg-green-500 mr-2" />
|
||||
<span className="text-sm">
|
||||
<span className="text-textSubtle">Agent</span>{' '}
|
||||
<span className="text-textStandard">{title}</span>
|
||||
</span>
|
||||
</div>
|
||||
{profileInfo && (
|
||||
<div className="flex items-center text-sm">
|
||||
<span className="text-textSubtle">{profileInfo}</span>
|
||||
{onChangeProfile && (
|
||||
<button onClick={onChangeProfile} className="ml-2 text-blockTeal hover:underline">
|
||||
change profile
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -44,7 +44,7 @@
|
|||
import React, { createContext, useContext, useEffect, useRef } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { SearchView } from './conversation/SearchView';
|
||||
import { AgentHeader } from './AgentHeader';
|
||||
import { RecipeHeader } from './RecipeHeader';
|
||||
import LoadingGoose from './LoadingGoose';
|
||||
import RecipeActivities from './recipes/RecipeActivities';
|
||||
import PopularChatTopics from './PopularChatTopics';
|
||||
|
|
@ -315,16 +315,7 @@ function BaseChatContent({
|
|||
{/* Recipe agent header - sticky at top of chat container */}
|
||||
{recipe?.title && (
|
||||
<div className="sticky top-0 z-10 bg-background-default px-0 -mx-6 mb-6 pt-6">
|
||||
<AgentHeader
|
||||
title={recipe.title}
|
||||
profileInfo={
|
||||
recipe.profile ? `${recipe.profile} - ${recipe.mcps || 12} MCPs` : undefined
|
||||
}
|
||||
onChangeProfile={() => {
|
||||
console.log('Change profile clicked');
|
||||
}}
|
||||
showBorder={true}
|
||||
/>
|
||||
<RecipeHeader title={recipe.title} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,172 +1,121 @@
|
|||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { SearchView } from './conversation/SearchView';
|
||||
import LoadingGoose from './LoadingGoose';
|
||||
import PopularChatTopics from './PopularChatTopics';
|
||||
import ProgressiveMessageList from './ProgressiveMessageList';
|
||||
import { View, ViewOptions } from '../utils/navigationUtils';
|
||||
import { ContextManagerProvider } from './context_management/ContextManager';
|
||||
import { MainPanelLayout } from './Layout/MainPanelLayout';
|
||||
import ChatInput from './ChatInput';
|
||||
import { ScrollArea, ScrollAreaHandle } from './ui/scroll-area';
|
||||
import { useFileDrop } from '../hooks/useFileDrop';
|
||||
import { Message, Session } from '../api';
|
||||
import { Message } from '../api';
|
||||
import { ChatState } from '../types/chatState';
|
||||
import { ChatType } from '../types/chat';
|
||||
import { useIsMobile } from '../hooks/use-mobile';
|
||||
import { useSidebar } from './ui/sidebar';
|
||||
import { cn } from '../utils';
|
||||
import { useChatStream } from '../hooks/useChatStream';
|
||||
import { loadSession } from '../utils/sessionCache';
|
||||
import { useNavigation } from '../hooks/useNavigation';
|
||||
import { RecipeHeader } from './RecipeHeader';
|
||||
import { RecipeWarningModal } from './ui/RecipeWarningModal';
|
||||
import { scanRecipe } from '../recipe';
|
||||
import { useCostTracking } from '../hooks/useCostTracking';
|
||||
import RecipeActivities from './recipes/RecipeActivities';
|
||||
import { useToolCount } from './alerts/useToolCount';
|
||||
|
||||
interface BaseChatProps {
|
||||
chat: ChatType;
|
||||
setChat: (chat: ChatType) => void;
|
||||
setView: (view: View, viewOptions?: ViewOptions) => void;
|
||||
setIsGoosehintsModalOpen?: (isOpen: boolean) => void;
|
||||
onMessageStreamFinish?: () => void;
|
||||
onMessageSubmit?: (message: string) => void;
|
||||
renderHeader?: () => React.ReactNode;
|
||||
renderBeforeMessages?: () => React.ReactNode;
|
||||
renderAfterMessages?: () => React.ReactNode;
|
||||
customChatInputProps?: Record<string, unknown>;
|
||||
customMainLayoutProps?: Record<string, unknown>;
|
||||
contentClassName?: string;
|
||||
disableSearch?: boolean;
|
||||
showPopularTopics?: boolean;
|
||||
suppressEmptyState?: boolean;
|
||||
autoSubmit?: boolean;
|
||||
resumeSessionId?: string; // Optional session ID to resume on mount
|
||||
suppressEmptyState: boolean;
|
||||
sessionId: string;
|
||||
initialMessage?: string;
|
||||
}
|
||||
|
||||
function BaseChatContent({
|
||||
chat,
|
||||
setChat,
|
||||
setView,
|
||||
setIsGoosehintsModalOpen,
|
||||
renderHeader,
|
||||
renderBeforeMessages,
|
||||
renderAfterMessages,
|
||||
customChatInputProps = {},
|
||||
customMainLayoutProps = {},
|
||||
disableSearch = false,
|
||||
resumeSessionId,
|
||||
sessionId,
|
||||
initialMessage,
|
||||
}: BaseChatProps) {
|
||||
const location = useLocation();
|
||||
const scrollRef = useRef<ScrollAreaHandle>(null);
|
||||
|
||||
const disableAnimation = location.state?.disableAnimation || false;
|
||||
// const [hasStartedUsingRecipe, setHasStartedUsingRecipe] = React.useState(false);
|
||||
// const [currentRecipeTitle, setCurrentRecipeTitle] = React.useState<string | null>(null);
|
||||
// const { isCompacting, handleManualCompaction } = useContextManager();
|
||||
const [hasStartedUsingRecipe, setHasStartedUsingRecipe] = React.useState(false);
|
||||
const [hasAcceptedRecipe, setHasAcceptedRecipe] = useState<boolean>();
|
||||
const [hasRecipeSecurityWarnings, setHasRecipeSecurityWarnings] = useState(false);
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
const { state: sidebarState } = useSidebar();
|
||||
const setView = useNavigation();
|
||||
|
||||
const contentClassName = cn('pr-1 pb-10', (isMobile || sidebarState === 'collapsed') && 'pt-11');
|
||||
|
||||
// Use shared file drop
|
||||
const { droppedFiles, setDroppedFiles, handleDrop, handleDragOver } = useFileDrop();
|
||||
|
||||
// Use shared cost tracking
|
||||
// const { sessionCosts } = useCostTracking({
|
||||
// sessionInputTokens,
|
||||
// sessionOutputTokens,
|
||||
// localInputTokens,
|
||||
// localOutputTokens,
|
||||
// session: sessionMetadata,
|
||||
// });
|
||||
const onStreamFinish = useCallback(() => {}, []);
|
||||
|
||||
// Session loading state
|
||||
const [sessionLoadError, setSessionLoadError] = useState<string | null>(null);
|
||||
const hasLoadedSessionRef = useRef(false);
|
||||
|
||||
const [messages, setMessages] = useState(chat.messages || []);
|
||||
|
||||
// Load session on mount if resumeSessionId is provided
|
||||
useEffect(() => {
|
||||
const needsLoad = resumeSessionId && !hasLoadedSessionRef.current;
|
||||
|
||||
if (needsLoad) {
|
||||
hasLoadedSessionRef.current = true;
|
||||
setSessionLoadError(null);
|
||||
|
||||
// Set chat to empty session to indicate loading state
|
||||
// todo: set to null instead and handle that in other places
|
||||
const emptyChat: ChatType = {
|
||||
sessionId: resumeSessionId,
|
||||
title: 'Loading...',
|
||||
messageHistoryIndex: 0,
|
||||
messages: [],
|
||||
recipe: null,
|
||||
recipeParameterValues: null,
|
||||
};
|
||||
setChat(emptyChat);
|
||||
|
||||
loadSession(resumeSessionId)
|
||||
.then((session: Session) => {
|
||||
const conversation = session.conversation || [];
|
||||
const loadedChat: ChatType = {
|
||||
sessionId: session.id,
|
||||
title: session.description || 'Untitled Chat',
|
||||
messageHistoryIndex: 0,
|
||||
messages: conversation,
|
||||
recipe: null,
|
||||
recipeParameterValues: null,
|
||||
};
|
||||
|
||||
setChat(loadedChat);
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
const errorMessage = error.message || 'Failed to load session';
|
||||
setSessionLoadError(errorMessage);
|
||||
});
|
||||
}
|
||||
}, [resumeSessionId, setChat]);
|
||||
|
||||
// Update messages when chat changes (e.g., when resuming a session)
|
||||
useEffect(() => {
|
||||
if (chat.messages) {
|
||||
setMessages(chat.messages);
|
||||
}
|
||||
}, [chat.messages, chat.sessionId]);
|
||||
|
||||
const { chatState, handleSubmit, stopStreaming } = useChatStream({
|
||||
sessionId: chat.sessionId || '',
|
||||
messages,
|
||||
setMessages,
|
||||
onStreamFinish: () => {},
|
||||
});
|
||||
const { session, messages, chatState, handleSubmit, stopStreaming, sessionLoadError } =
|
||||
useChatStream({
|
||||
sessionId,
|
||||
onStreamFinish,
|
||||
initialMessage,
|
||||
});
|
||||
|
||||
const handleFormSubmit = (e: React.FormEvent) => {
|
||||
const customEvent = e as unknown as CustomEvent;
|
||||
const textValue = customEvent.detail?.value || '';
|
||||
|
||||
// if (recipe && textValue.trim()) {
|
||||
// setHasStartedUsingRecipe(true);
|
||||
// }
|
||||
//
|
||||
// if (onMessageSubmit && textValue.trim()) {
|
||||
// onMessageSubmit(textValue);
|
||||
// }
|
||||
|
||||
if (recipe && textValue.trim()) {
|
||||
setHasStartedUsingRecipe(true);
|
||||
}
|
||||
handleSubmit(textValue);
|
||||
};
|
||||
|
||||
// TODO(Douwe): send this to the chatbox instead, possibly autosubmit? or backend
|
||||
const append = (_txt: string) => {};
|
||||
const { sessionCosts } = useCostTracking({
|
||||
sessionInputTokens: session?.accumulated_input_tokens || 0,
|
||||
sessionOutputTokens: session?.accumulated_output_tokens || 0,
|
||||
localInputTokens: 0,
|
||||
localOutputTokens: 0,
|
||||
session,
|
||||
});
|
||||
|
||||
const recipe = session?.recipe;
|
||||
|
||||
useEffect(() => {
|
||||
window.electron.logInfo(
|
||||
'Initial messages when resuming session: ' + JSON.stringify(messages, null, 2)
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
if (!recipe) return;
|
||||
|
||||
(async () => {
|
||||
const accepted = await window.electron.hasAcceptedRecipeBefore(recipe);
|
||||
setHasAcceptedRecipe(accepted);
|
||||
|
||||
if (!accepted) {
|
||||
const scanResult = await scanRecipe(recipe);
|
||||
setHasRecipeSecurityWarnings(scanResult.has_security_warnings);
|
||||
}
|
||||
})();
|
||||
}, [recipe]);
|
||||
|
||||
const handleRecipeAccept = async (accept: boolean) => {
|
||||
if (recipe && accept) {
|
||||
await window.electron.recordRecipeHash(recipe);
|
||||
setHasAcceptedRecipe(true);
|
||||
} else {
|
||||
setView('chat');
|
||||
}
|
||||
};
|
||||
|
||||
// Track if this is the initial render for session resuming
|
||||
const initialRenderRef = useRef(true);
|
||||
|
||||
const recipe = chat?.recipe;
|
||||
|
||||
// Auto-scroll when messages are loaded (for session resuming)
|
||||
const handleRenderingComplete = React.useCallback(() => {
|
||||
// Only force scroll on the very first render
|
||||
|
|
@ -182,16 +131,7 @@ function BaseChatContent({
|
|||
}
|
||||
}, [messages.length]);
|
||||
|
||||
//const toolCount = useToolCount(chat.sessionId);
|
||||
|
||||
// Wrapper for append that tracks recipe usage
|
||||
// const appendWithTracking = (text: string | Message) => {
|
||||
// // Mark that user has started using the recipe when they use append
|
||||
// if (recipe) {
|
||||
// setHasStartedUsingRecipe(true);
|
||||
// }
|
||||
// append(text);
|
||||
// };
|
||||
const toolCount = useToolCount(sessionId);
|
||||
|
||||
// Listen for global scroll-to-bottom requests (e.g., from MCP UI prompt actions)
|
||||
useEffect(() => {
|
||||
|
|
@ -226,11 +166,29 @@ function BaseChatContent({
|
|||
</>
|
||||
);
|
||||
|
||||
const showPopularTopics = messages.length === 0;
|
||||
const showPopularTopics =
|
||||
messages.length === 0 && !initialMessage && chatState === ChatState.Idle;
|
||||
// TODO(Douwe): get this from the backend
|
||||
const isCompacting = false;
|
||||
|
||||
const chat: ChatType = {
|
||||
messageHistoryIndex: 0,
|
||||
messages,
|
||||
recipe,
|
||||
sessionId,
|
||||
title: session?.description || 'No Session',
|
||||
};
|
||||
|
||||
const initialPrompt = messages.length == 0 && recipe?.prompt ? recipe.prompt : '';
|
||||
|
||||
// Map chatState to LoadingGoose message
|
||||
const getLoadingMessage = (): string | undefined => {
|
||||
if (isCompacting) return 'goose is compacting the conversation...';
|
||||
if (messages.length === 0 && chatState === ChatState.Thinking) {
|
||||
return 'loading conversation...';
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
return (
|
||||
<div className="h-full flex flex-col min-h-0">
|
||||
<h2>Warning: BaseChat2!</h2>
|
||||
|
|
@ -254,36 +212,22 @@ function BaseChatContent({
|
|||
paddingX={6}
|
||||
paddingY={0}
|
||||
>
|
||||
{/*/!* Recipe agent header - sticky at top of chat container *!/*/}
|
||||
{/*{recipe?.title && (*/}
|
||||
{/* <div className="sticky top-0 z-10 bg-background-default px-0 -mx-6 mb-6 pt-6">*/}
|
||||
{/* <AgentHeader*/}
|
||||
{/* title={recipe.title}*/}
|
||||
{/* profileInfo={*/}
|
||||
{/* recipe.profile ? `${recipe.profile} - ${recipe.mcps || 12} MCPs` : undefined*/}
|
||||
{/* }*/}
|
||||
{/* onChangeProfile={() => {*/}
|
||||
{/* console.log('Change profile clicked');*/}
|
||||
{/* }}*/}
|
||||
{/* showBorder={true}*/}
|
||||
{/* />*/}
|
||||
{/* </div>*/}
|
||||
{/*)}*/}
|
||||
{recipe?.title && (
|
||||
<div className="sticky top-0 z-10 bg-background-default px-0 -mx-6 mb-6 pt-6">
|
||||
<RecipeHeader title={recipe.title} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Custom content before messages */}
|
||||
{renderBeforeMessages && renderBeforeMessages()}
|
||||
|
||||
{/*/!* Recipe Activities - always show when recipe is active and accepted *!/*/}
|
||||
{/*{recipe && recipeAccepted && !suppressEmptyState && (*/}
|
||||
{/* <div className={hasStartedUsingRecipe ? 'mb-6' : ''}>*/}
|
||||
{/* <RecipeActivities*/}
|
||||
{/* append={(text: string) => appendWithTracking(text)}*/}
|
||||
{/* activities={Array.isArray(recipe.activities) ? recipe.activities : null}*/}
|
||||
{/* title={recipe.title}*/}
|
||||
{/* parameterValues={recipeParameters || {}}*/}
|
||||
{/* />*/}
|
||||
{/* </div>*/}
|
||||
{/*)}*/}
|
||||
{recipe && (
|
||||
<div className={hasStartedUsingRecipe ? 'mb-6' : ''}>
|
||||
<RecipeActivities
|
||||
append={(text: string) => handleSubmit(text)}
|
||||
activities={Array.isArray(recipe.activities) ? recipe.activities : null}
|
||||
title={recipe.title}
|
||||
//parameterValues={recipeParameters || {}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sessionLoadError && (
|
||||
<div className="flex flex-col items-center justify-center p-8">
|
||||
|
|
@ -293,96 +237,30 @@ function BaseChatContent({
|
|||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSessionLoadError(null);
|
||||
hasLoadedSessionRef.current = false;
|
||||
setView('chat');
|
||||
}}
|
||||
className="px-4 py-2 text-center cursor-pointer text-textStandard border border-borderSubtle hover:bg-bgSubtle rounded-lg transition-all duration-150"
|
||||
>
|
||||
Retry
|
||||
Go home
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Messages or Popular Topics */}
|
||||
{
|
||||
messages.length > 0 || recipe ? (
|
||||
<>
|
||||
{disableSearch ? (
|
||||
renderProgressiveMessageList(chat)
|
||||
) : (
|
||||
// Render messages with SearchView wrapper when search is enabled
|
||||
<SearchView>{renderProgressiveMessageList(chat)}</SearchView>
|
||||
)}
|
||||
{messages.length > 0 || recipe ? (
|
||||
<>
|
||||
<SearchView>{renderProgressiveMessageList(chat)}</SearchView>
|
||||
|
||||
{/*{error && (*/}
|
||||
{/* <>*/}
|
||||
{/* <div className="flex flex-col items-center justify-center p-4">*/}
|
||||
{/* <div className="text-red-700 dark:text-red-300 bg-red-400/50 p-3 rounded-lg mb-2">*/}
|
||||
{/* {error.message || 'Honk! Goose experienced an error while responding'}*/}
|
||||
{/* </div>*/}
|
||||
|
||||
{/* /!* Action buttons for all errors including token limit errors *!/*/}
|
||||
{/* <div className="flex gap-2 mt-2">*/}
|
||||
{/* <div*/}
|
||||
{/* className="px-3 py-2 text-center whitespace-nowrap cursor-pointer text-textStandard border border-borderSubtle hover:bg-bgSubtle rounded-full inline-block transition-all duration-150"*/}
|
||||
{/* onClick={async () => {*/}
|
||||
{/* clearError();*/}
|
||||
|
||||
{/* await handleManualCompaction(*/}
|
||||
{/* messages,*/}
|
||||
{/* setMessages,*/}
|
||||
{/* append,*/}
|
||||
{/* chat.sessionId*/}
|
||||
{/* );*/}
|
||||
{/* }}*/}
|
||||
{/* >*/}
|
||||
{/* Summarize Conversation*/}
|
||||
{/* </div>*/}
|
||||
{/* <div*/}
|
||||
{/* className="px-3 py-2 text-center whitespace-nowrap cursor-pointer text-textStandard border border-borderSubtle hover:bg-bgSubtle rounded-full inline-block transition-all duration-150"*/}
|
||||
{/* onClick={async () => {*/}
|
||||
{/* // Find the last user message*/}
|
||||
{/* const lastUserMessage = messages.reduceRight(*/}
|
||||
{/* (found, m) => found || (m.role === 'user' ? m : null),*/}
|
||||
{/* null as Message | null*/}
|
||||
{/* );*/}
|
||||
{/* if (lastUserMessage) {*/}
|
||||
{/* await append(lastUserMessage);*/}
|
||||
{/* }*/}
|
||||
{/* }}*/}
|
||||
{/* >*/}
|
||||
{/* Retry Last Message*/}
|
||||
{/* </div>*/}
|
||||
{/* </div>*/}
|
||||
{/* </div>*/}
|
||||
{/* </>*/}
|
||||
{/*)}*/}
|
||||
|
||||
<div className="block h-8" />
|
||||
</>
|
||||
) : !recipe && showPopularTopics ? (
|
||||
/* Show PopularChatTopics when no messages, no recipe, and showPopularTopics is true (Pair view) */
|
||||
<PopularChatTopics append={(text: string) => append(text)} />
|
||||
) : null /* Show nothing when messages.length === 0 && suppressEmptyState === true */
|
||||
}
|
||||
|
||||
{/* Custom content after messages */}
|
||||
{renderAfterMessages && renderAfterMessages()}
|
||||
<div className="block h-8" />
|
||||
</>
|
||||
) : !recipe && showPopularTopics ? (
|
||||
<PopularChatTopics append={(text: string) => handleSubmit(text)} />
|
||||
) : null}
|
||||
</ScrollArea>
|
||||
|
||||
{/* Fixed loading indicator at bottom left of chat container */}
|
||||
{(messages.length === 0 || isCompacting) && !sessionLoadError && (
|
||||
{(chatState !== ChatState.Idle || isCompacting) && !sessionLoadError && (
|
||||
<div className="absolute bottom-1 left-4 z-20 pointer-events-none">
|
||||
<LoadingGoose
|
||||
message={
|
||||
messages.length === 0
|
||||
? 'loading conversation...'
|
||||
: isCompacting
|
||||
? 'goose is compacting the conversation...'
|
||||
: undefined
|
||||
}
|
||||
chatState={chatState}
|
||||
/>
|
||||
<LoadingGoose message={getLoadingMessage()} chatState={chatState} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -391,48 +269,46 @@ function BaseChatContent({
|
|||
className={`relative z-10 ${disableAnimation ? '' : 'animate-[fadein_400ms_ease-in_forwards]'}`}
|
||||
>
|
||||
<ChatInput
|
||||
sessionId={chat?.sessionId || ''}
|
||||
sessionId={sessionId}
|
||||
handleSubmit={handleFormSubmit}
|
||||
chatState={chatState}
|
||||
onStop={stopStreaming}
|
||||
//commandHistory={commandHistory}
|
||||
initialValue={initialPrompt}
|
||||
setView={setView}
|
||||
// numTokens={sessionTokenCount}
|
||||
// inputTokens={sessionInputTokens || localInputTokens}
|
||||
// outputTokens={sessionOutputTokens || localOutputTokens}
|
||||
numTokens={session?.total_tokens || undefined}
|
||||
inputTokens={session?.input_tokens || undefined}
|
||||
outputTokens={session?.output_tokens || undefined}
|
||||
droppedFiles={droppedFiles}
|
||||
onFilesProcessed={() => setDroppedFiles([])} // Clear dropped files after processing
|
||||
messages={messages}
|
||||
setMessages={(_m) => {}}
|
||||
disableAnimation={disableAnimation}
|
||||
//sessionCosts={sessionCosts}
|
||||
sessionCosts={sessionCosts}
|
||||
setIsGoosehintsModalOpen={setIsGoosehintsModalOpen}
|
||||
recipe={recipe}
|
||||
//recipeAccepted={recipeAccepted}
|
||||
recipeAccepted={hasAcceptedRecipe}
|
||||
initialPrompt={initialPrompt}
|
||||
//toolCount={toolCount || 0}
|
||||
toolCount={0}
|
||||
//autoSubmit={autoSubmit}
|
||||
toolCount={toolCount || 0}
|
||||
autoSubmit={false}
|
||||
//append={append}
|
||||
{...customChatInputProps}
|
||||
/>
|
||||
</div>
|
||||
</MainPanelLayout>
|
||||
|
||||
{/*/!* Recipe Warning Modal *!/*/}
|
||||
{/*<RecipeWarningModal*/}
|
||||
{/* isOpen={isRecipeWarningModalOpen}*/}
|
||||
{/* onConfirm={handleRecipeAccept}*/}
|
||||
{/* onCancel={handleRecipeCancel}*/}
|
||||
{/* recipeDetails={{*/}
|
||||
{/* title: recipe?.title,*/}
|
||||
{/* description: recipe?.description,*/}
|
||||
{/* instructions: recipe?.instructions || undefined,*/}
|
||||
{/* }}*/}
|
||||
{/* hasSecurityWarnings={hasSecurityWarnings}*/}
|
||||
{/*/>*/}
|
||||
{recipe && (
|
||||
<RecipeWarningModal
|
||||
isOpen={!hasAcceptedRecipe}
|
||||
onConfirm={() => handleRecipeAccept(true)}
|
||||
onCancel={() => handleRecipeAccept(false)}
|
||||
recipeDetails={{
|
||||
title: recipe.title,
|
||||
description: recipe.description,
|
||||
instructions: recipe.instructions || undefined,
|
||||
}}
|
||||
hasSecurityWarnings={hasRecipeSecurityWarnings}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/*/!* Recipe Parameter Modal *!/*/}
|
||||
{/*{isParameterModalOpen && filteredParameters.length > 0 && (*/}
|
||||
|
|
|
|||
|
|
@ -1,35 +1,28 @@
|
|||
import { View, ViewOptions } from '../utils/navigationUtils';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
|
||||
import { ChatType } from '../types/chat';
|
||||
import BaseChat2 from './BaseChat2';
|
||||
|
||||
export interface PairRouteState {
|
||||
resumeSessionId?: string;
|
||||
interface PairProps {
|
||||
setChat: (chat: ChatType) => void;
|
||||
setIsGoosehintsModalOpen: (isOpen: boolean) => void;
|
||||
sessionId: string;
|
||||
initialMessage?: string;
|
||||
}
|
||||
|
||||
interface PairProps {
|
||||
chat: ChatType;
|
||||
setChat: (chat: ChatType) => void;
|
||||
setView: (view: View, viewOptions?: ViewOptions) => void;
|
||||
setIsGoosehintsModalOpen: (isOpen: boolean) => void;
|
||||
}
|
||||
|
||||
export default function Pair({
|
||||
chat,
|
||||
setChat,
|
||||
setView,
|
||||
setIsGoosehintsModalOpen,
|
||||
resumeSessionId,
|
||||
}: PairProps & PairRouteState) {
|
||||
sessionId,
|
||||
initialMessage,
|
||||
}: PairProps) {
|
||||
return (
|
||||
<BaseChat2
|
||||
chat={chat}
|
||||
setChat={setChat}
|
||||
setView={setView}
|
||||
setIsGoosehintsModalOpen={setIsGoosehintsModalOpen}
|
||||
resumeSessionId={resumeSessionId}
|
||||
sessionId={sessionId}
|
||||
initialMessage={initialMessage}
|
||||
suppressEmptyState={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
17
ui/desktop/src/components/RecipeHeader.tsx
Normal file
17
ui/desktop/src/components/RecipeHeader.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
interface RecipeHeaderProps {
|
||||
title: string;
|
||||
}
|
||||
|
||||
export function RecipeHeader({ title }: RecipeHeaderProps) {
|
||||
return (
|
||||
<div className={`flex items-center justify-between px-4 py-2 border-b border-borderSubtle'}`}>
|
||||
<div className="flex items-center ml-6">
|
||||
<span className="w-2 h-2 rounded-full bg-green-500 mr-2" />
|
||||
<span className="text-sm">
|
||||
<span className="text-textSubtle">Recipe</span>{' '}
|
||||
<span className="text-textStandard">{title}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,35 +1,22 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { getTools } from '../../api';
|
||||
|
||||
const { clearTimeout } = window;
|
||||
|
||||
// TODO(Douwe): return this as part of the start agent request
|
||||
export const useToolCount = (sessionId: string) => {
|
||||
const [toolCount, setToolCount] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let timeoutId: ReturnType<typeof setTimeout>;
|
||||
|
||||
const fetchTools = async () => {
|
||||
try {
|
||||
const response = await getTools({ query: { session_id: sessionId } });
|
||||
if (!response.error && response.data) {
|
||||
setToolCount(response.data.length);
|
||||
} else {
|
||||
setToolCount(0);
|
||||
}
|
||||
setToolCount(response.error || !response.data ? 0 : response.data.length);
|
||||
} catch (err) {
|
||||
console.error('Error fetching tools:', err);
|
||||
setToolCount(0);
|
||||
}
|
||||
};
|
||||
|
||||
// Add initial 1s delay before first fetch
|
||||
timeoutId = setTimeout(fetchTools, 1000);
|
||||
|
||||
// Cleanup timeouts on unmount
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
};
|
||||
fetchTools();
|
||||
}, [sessionId]);
|
||||
|
||||
return toolCount;
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import { ChatState } from '../types/chatState';
|
|||
import { ContextManagerProvider } from './context_management/ContextManager';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
import { View, ViewOptions } from '../utils/navigationUtils';
|
||||
import { startAgent } from '../api';
|
||||
|
||||
export default function Hub({
|
||||
setView,
|
||||
|
|
@ -32,22 +33,35 @@ export default function Hub({
|
|||
isExtensionsLoading: boolean;
|
||||
resetChat: () => void;
|
||||
}) {
|
||||
// Handle chat input submission - create new chat and navigate to pair
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
const customEvent = e as unknown as CustomEvent;
|
||||
const combinedTextFromInput = customEvent.detail?.value || '';
|
||||
|
||||
if (combinedTextFromInput.trim()) {
|
||||
// Navigate to pair page with the message to be submitted
|
||||
// Pair will handle creating the new chat session
|
||||
resetChat();
|
||||
setView('pair', {
|
||||
disableAnimation: true,
|
||||
initialMessage: combinedTextFromInput,
|
||||
});
|
||||
if (process.env.ALPHA) {
|
||||
const newAgent = await startAgent({
|
||||
body: {
|
||||
working_dir: window.appConfig.get('GOOSE_WORKING_DIR') as string,
|
||||
},
|
||||
throwOnError: true,
|
||||
});
|
||||
const session = newAgent.data;
|
||||
setView('pair', {
|
||||
disableAnimation: true,
|
||||
initialMessage: combinedTextFromInput,
|
||||
resumeSessionId: session.id,
|
||||
});
|
||||
} else {
|
||||
// Navigate to pair page with the message to be submitted
|
||||
// Pair will handle creating the new chat session
|
||||
resetChat();
|
||||
setView('pair', {
|
||||
disableAnimation: true,
|
||||
initialMessage: combinedTextFromInput,
|
||||
});
|
||||
}
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { Skeleton } from '../ui/skeleton';
|
|||
import { MainPanelLayout } from '../Layout/MainPanelLayout';
|
||||
import { toastSuccess } from '../../toasts';
|
||||
import { useEscapeKey } from '../../hooks/useEscapeKey';
|
||||
import { deleteRecipe, RecipeManifestResponse } from '../../api';
|
||||
import { deleteRecipe, RecipeManifestResponse, startAgent } from '../../api';
|
||||
import ImportRecipeForm, { ImportRecipeButton } from './ImportRecipeForm';
|
||||
import CreateEditRecipeModal from './CreateEditRecipeModal';
|
||||
import { generateDeepLink, Recipe } from '../../recipe';
|
||||
|
|
@ -70,30 +70,50 @@ export default function RecipesView() {
|
|||
}
|
||||
};
|
||||
|
||||
const handleLoadRecipe = async (recipe: Recipe, recipeId: string) => {
|
||||
try {
|
||||
// onLoadRecipe is not working for loading recipes. It looks correct
|
||||
// but the instructions are not flowing through to the server.
|
||||
// Needs a fix but commenting out to get prod back up and running.
|
||||
//
|
||||
// if (onLoadRecipe) {
|
||||
// // Use the callback to navigate within the same window
|
||||
// onLoadRecipe(savedRecipe.recipe);
|
||||
// } else {
|
||||
// Fallback to creating a new window (for backwards compatibility)
|
||||
window.electron.createChatWindow(
|
||||
undefined, // query
|
||||
undefined, // dir
|
||||
undefined, // version
|
||||
undefined, // resumeSessionId
|
||||
recipe, // recipe config
|
||||
undefined, // view type,
|
||||
recipeId // recipe id
|
||||
);
|
||||
// }
|
||||
} catch (err) {
|
||||
console.error('Failed to load recipe:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to load recipe');
|
||||
const handleStartRecipeChat = async (recipe: Recipe, recipeId: string) => {
|
||||
if (process.env.ALPHA) {
|
||||
try {
|
||||
const newAgent = await startAgent({
|
||||
body: {
|
||||
working_dir: window.appConfig.get('GOOSE_WORKING_DIR') as string,
|
||||
recipe,
|
||||
},
|
||||
throwOnError: true,
|
||||
});
|
||||
const session = newAgent.data;
|
||||
setView('pair', {
|
||||
disableAnimation: true,
|
||||
resumeSessionId: session.id,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to load recipe:', error);
|
||||
setError(error instanceof Error ? error.message : 'Failed to load recipe');
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
// onLoadRecipe is not working for loading recipes. It looks correct
|
||||
// but the instructions are not flowing through to the server.
|
||||
// Needs a fix but commenting out to get prod back up and running.
|
||||
//
|
||||
// if (onLoadRecipe) {
|
||||
// // Use the callback to navigate within the same window
|
||||
// onLoadRecipe(savedRecipe.recipe);
|
||||
// } else {
|
||||
// Fallback to creating a new window (for backwards compatibility)
|
||||
window.electron.createChatWindow(
|
||||
undefined, // query
|
||||
undefined, // dir
|
||||
undefined, // version
|
||||
undefined, // resumeSessionId
|
||||
recipe, // recipe config
|
||||
undefined, // view type,
|
||||
recipeId // recipe id
|
||||
);
|
||||
// }
|
||||
} catch (err) {
|
||||
console.error('Failed to load recipe:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to load recipe');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -193,7 +213,7 @@ export default function RecipesView() {
|
|||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleLoadRecipe(recipe, recipeManifestResponse.id);
|
||||
handleStartRecipeChat(recipe, recipeManifestResponse.id);
|
||||
}}
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
|
|
|
|||
|
|
@ -238,18 +238,10 @@ function recipeToYaml(recipe: Recipe, executionMode: ExecutionMode): string {
|
|||
});
|
||||
}
|
||||
|
||||
if (recipe.goosehints) {
|
||||
cleanRecipe.goosehints = recipe.goosehints;
|
||||
}
|
||||
|
||||
if (recipe.context && recipe.context.length > 0) {
|
||||
cleanRecipe.context = recipe.context;
|
||||
}
|
||||
|
||||
if (recipe.profile) {
|
||||
cleanRecipe.profile = recipe.profile;
|
||||
}
|
||||
|
||||
if (recipe.author) {
|
||||
cleanRecipe.author = {
|
||||
contact: recipe.author.contact || undefined,
|
||||
|
|
|
|||
|
|
@ -589,7 +589,6 @@ const SessionListView: React.FC<SessionListViewProps> = React.memo(
|
|||
);
|
||||
});
|
||||
|
||||
// Render skeleton loader for session items with variations
|
||||
const SessionSkeleton = React.memo(({ variant = 0 }: { variant?: number }) => {
|
||||
const titleWidths = ['w-3/4', 'w-2/3', 'w-4/5', 'w-1/2'];
|
||||
const pathWidths = ['w-32', 'w-28', 'w-36', 'w-24'];
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import SessionListView from './SessionListView';
|
|||
import SessionHistoryView from './SessionHistoryView';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { getSession, Session } from '../../api';
|
||||
import { useNavigation } from '../../hooks/useNavigation';
|
||||
|
||||
const SessionsView: React.FC = () => {
|
||||
const [selectedSession, setSelectedSession] = useState<Session | null>(null);
|
||||
|
|
@ -11,6 +12,7 @@ const SessionsView: React.FC = () => {
|
|||
const [error, setError] = useState<string | null>(null);
|
||||
const [initialSessionId, setInitialSessionId] = useState<string | null>(null);
|
||||
const location = useLocation();
|
||||
const setView = useNavigation();
|
||||
|
||||
const loadSessionDetails = async (sessionId: string) => {
|
||||
setIsLoadingSession(true);
|
||||
|
|
@ -34,9 +36,19 @@ const SessionsView: React.FC = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const handleSelectSession = useCallback(async (sessionId: string) => {
|
||||
await loadSessionDetails(sessionId);
|
||||
}, []);
|
||||
const handleSelectSession = useCallback(
|
||||
async (sessionId: string) => {
|
||||
if (process.env.ALPHA) {
|
||||
setView('pair', {
|
||||
disableAnimation: true,
|
||||
resumeSessionId: sessionId,
|
||||
});
|
||||
} else {
|
||||
await loadSessionDetails(sessionId);
|
||||
}
|
||||
},
|
||||
[setView]
|
||||
);
|
||||
|
||||
// Check if a session ID was passed in the location state (from SessionsInsights)
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ export function useAgent(): UseAgentReturn {
|
|||
const agentResponse = await resumeAgent({
|
||||
body: {
|
||||
session_id: sessionId,
|
||||
load_model_and_extensions: false,
|
||||
},
|
||||
throwOnError: true,
|
||||
});
|
||||
|
|
@ -112,6 +113,7 @@ export function useAgent(): UseAgentReturn {
|
|||
? await resumeAgent({
|
||||
body: {
|
||||
session_id: initContext.resumeSessionId,
|
||||
load_model_and_extensions: false,
|
||||
},
|
||||
throwOnError: true,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,15 +1,78 @@
|
|||
import { useState, useCallback, useRef } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { ChatState } from '../types/chatState';
|
||||
import { Message } from '../api';
|
||||
import { Conversation, Message, resumeAgent, Session } from '../api';
|
||||
import { getApiUrl } from '../config';
|
||||
import { createUserMessage } from '../types/message';
|
||||
|
||||
const TextDecoder = globalThis.TextDecoder;
|
||||
const resultsCache = new Map<string, { messages: Message[]; session: Session }>();
|
||||
|
||||
// Debug logging - set to false in production
|
||||
const DEBUG_CHAT_STREAM = true;
|
||||
|
||||
const log = {
|
||||
session: (action: string, sessionId: string, details?: Record<string, unknown>) => {
|
||||
if (!DEBUG_CHAT_STREAM) return;
|
||||
console.log(`[useChatStream:session] ${action}`, {
|
||||
sessionId: sessionId.slice(0, 8),
|
||||
...details,
|
||||
});
|
||||
},
|
||||
messages: (action: string, count: number, details?: Record<string, unknown>) => {
|
||||
if (!DEBUG_CHAT_STREAM) return;
|
||||
console.log(`[useChatStream:messages] ${action}`, {
|
||||
count,
|
||||
...details,
|
||||
});
|
||||
},
|
||||
stream: (action: string, details?: Record<string, unknown>) => {
|
||||
if (!DEBUG_CHAT_STREAM) return;
|
||||
console.log(`[useChatStream:stream] ${action}`, details);
|
||||
},
|
||||
state: (newState: ChatState, details?: Record<string, unknown>) => {
|
||||
if (!DEBUG_CHAT_STREAM) return;
|
||||
console.log(`[useChatStream:state] → ${newState}`, details);
|
||||
},
|
||||
error: (context: string, error: unknown) => {
|
||||
console.error(`[useChatStream:error] ${context}`, error);
|
||||
},
|
||||
};
|
||||
|
||||
type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
|
||||
|
||||
interface NotificationEvent {
|
||||
type: 'Notification';
|
||||
request_id: string;
|
||||
message: {
|
||||
method: string;
|
||||
params: {
|
||||
[key: string]: JsonValue;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
type MessageEvent =
|
||||
| { type: 'Message'; message: Message }
|
||||
| { type: 'Error'; error: string }
|
||||
| { type: 'Ping' }
|
||||
| { type: 'Finish'; reason: string }
|
||||
| { type: 'ModelChange'; model: string; mode: string }
|
||||
| { type: 'UpdateConversation'; conversation: Conversation }
|
||||
| NotificationEvent;
|
||||
|
||||
interface UseChatStreamProps {
|
||||
sessionId: string;
|
||||
onStreamFinish: () => void;
|
||||
initialMessage?: string;
|
||||
}
|
||||
|
||||
interface UseChatStreamReturn {
|
||||
session?: Session;
|
||||
messages: Message[];
|
||||
setMessages: (messages: Message[]) => void;
|
||||
onStreamFinish?: () => void;
|
||||
chatState: ChatState;
|
||||
handleSubmit: (userMessage: string) => Promise<void>;
|
||||
stopStreaming: () => void;
|
||||
sessionLoadError?: string;
|
||||
}
|
||||
|
||||
function pushMessage(currentMessages: Message[], incomingMsg: Message): Message[] {
|
||||
|
|
@ -34,33 +97,228 @@ function pushMessage(currentMessages: Message[], incomingMsg: Message): Message[
|
|||
}
|
||||
}
|
||||
|
||||
async function streamFromResponse(
|
||||
response: Response,
|
||||
initialMessages: Message[],
|
||||
updateMessages: (messages: Message[]) => void,
|
||||
onFinish: (error?: string) => void
|
||||
): Promise<void> {
|
||||
let chunkCount = 0;
|
||||
let messageEventCount = 0;
|
||||
|
||||
try {
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
if (!response.body) throw new Error('No response body');
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let currentMessages = initialMessages;
|
||||
|
||||
log.stream('reading-chunks');
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
log.stream('chunks-complete', {
|
||||
totalChunks: chunkCount,
|
||||
messageEvents: messageEventCount,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
chunkCount++;
|
||||
const chunk = decoder.decode(value);
|
||||
const lines = chunk.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data: ')) continue;
|
||||
|
||||
const data = line.slice(6);
|
||||
if (data === '[DONE]') continue;
|
||||
|
||||
try {
|
||||
const event = JSON.parse(data) as MessageEvent;
|
||||
|
||||
switch (event.type) {
|
||||
case 'Message': {
|
||||
messageEventCount++;
|
||||
const msg = event.message;
|
||||
currentMessages = pushMessage(currentMessages, msg);
|
||||
|
||||
// Only log every 10th message event to avoid spam
|
||||
if (messageEventCount % 10 === 0) {
|
||||
log.stream('message-chunk', {
|
||||
eventCount: messageEventCount,
|
||||
messageCount: currentMessages.length,
|
||||
});
|
||||
}
|
||||
|
||||
// This calls the wrapped setMessagesAndLog with 'streaming' context
|
||||
updateMessages(currentMessages);
|
||||
break;
|
||||
}
|
||||
case 'Error': {
|
||||
log.error('stream event error', event.error);
|
||||
onFinish('Stream error: ' + event.error);
|
||||
return;
|
||||
}
|
||||
case 'Finish': {
|
||||
log.stream('finish-event', { reason: event.reason });
|
||||
onFinish();
|
||||
return;
|
||||
}
|
||||
case 'ModelChange': {
|
||||
log.stream('model-change', {
|
||||
model: event.model,
|
||||
mode: event.mode,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'UpdateConversation': {
|
||||
log.messages('conversation-update', event.conversation.length);
|
||||
// This calls the wrapped setMessagesAndLog with 'streaming' context
|
||||
updateMessages(event.conversation);
|
||||
break;
|
||||
}
|
||||
case 'Notification': {
|
||||
// Don't log notifications, too noisy
|
||||
break;
|
||||
}
|
||||
case 'Ping': {
|
||||
// Don't log pings
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
console.warn('Unhandled event type:', event['type']);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
log.error('SSE parse failed', e);
|
||||
onFinish('Failed to parse SSE:' + e);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name !== 'AbortError') {
|
||||
log.error('stream read error', error);
|
||||
onFinish('Stream error:' + error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function useChatStream({
|
||||
sessionId,
|
||||
messages,
|
||||
setMessages,
|
||||
onStreamFinish,
|
||||
}: UseChatStreamProps) {
|
||||
initialMessage,
|
||||
}: UseChatStreamProps): UseChatStreamReturn {
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const messagesRef = useRef<Message[]>([]);
|
||||
const [session, setSession] = useState<Session>();
|
||||
const [sessionLoadError, setSessionLoadError] = useState<string>();
|
||||
const [chatState, setChatState] = useState<ChatState>(ChatState.Idle);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (session) {
|
||||
resultsCache.set(sessionId, { session, messages });
|
||||
}
|
||||
}, [sessionId, session, messages]);
|
||||
|
||||
const renderCountRef = useRef(0);
|
||||
renderCountRef.current += 1;
|
||||
console.log(`useChatStream render #${renderCountRef.current}, ${session?.id}`);
|
||||
|
||||
const setMessagesAndLog = useCallback((newMessages: Message[], logContext: string) => {
|
||||
log.messages(logContext, newMessages.length, {
|
||||
lastMessageRole: newMessages[newMessages.length - 1]?.role,
|
||||
lastMessageId: newMessages[newMessages.length - 1]?.id?.slice(0, 8),
|
||||
});
|
||||
setMessages(newMessages);
|
||||
messagesRef.current = newMessages;
|
||||
}, []);
|
||||
|
||||
const onFinish = useCallback(
|
||||
(error?: string): void => {
|
||||
if (error) {
|
||||
setSessionLoadError(error);
|
||||
}
|
||||
setChatState(ChatState.Idle);
|
||||
onStreamFinish();
|
||||
},
|
||||
[onStreamFinish]
|
||||
);
|
||||
|
||||
// Load session on mount or sessionId change
|
||||
useEffect(() => {
|
||||
if (!sessionId) return;
|
||||
|
||||
// Reset state when sessionId changes
|
||||
log.session('loading', sessionId);
|
||||
setMessagesAndLog([], 'session-reset');
|
||||
setSession(undefined);
|
||||
setSessionLoadError(undefined);
|
||||
setChatState(ChatState.Thinking);
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
log.state(ChatState.Thinking, { reason: 'session load start' });
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const response = await resumeAgent({
|
||||
body: {
|
||||
session_id: sessionId,
|
||||
load_model_and_extensions: true,
|
||||
},
|
||||
throwOnError: true,
|
||||
});
|
||||
if (cancelled) return;
|
||||
|
||||
const session = response.data;
|
||||
log.session('loaded', sessionId, {
|
||||
messageCount: session?.conversation?.length || 0,
|
||||
description: session?.description,
|
||||
});
|
||||
|
||||
setSession(session);
|
||||
setMessagesAndLog(session?.conversation || [], 'load-session');
|
||||
|
||||
log.state(ChatState.Idle, { reason: 'session load complete' });
|
||||
setChatState(ChatState.Idle);
|
||||
} catch (error) {
|
||||
if (cancelled) return;
|
||||
|
||||
log.error('session load failed', error);
|
||||
setSessionLoadError(error instanceof Error ? error.message : String(error));
|
||||
|
||||
log.state(ChatState.Idle, { reason: 'session load error' });
|
||||
setChatState(ChatState.Idle);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [sessionId, setMessagesAndLog]);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (userMessage: string) => {
|
||||
const newMessage: Message = {
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: userMessage }],
|
||||
created: Date.now(),
|
||||
metadata: { userVisible: true, agentVisible: true },
|
||||
};
|
||||
log.messages('user-submit', messagesRef.current.length + 1, {
|
||||
userMessageLength: userMessage.length,
|
||||
});
|
||||
|
||||
let currentMessages = [...messages, newMessage];
|
||||
setMessages(currentMessages);
|
||||
const currentMessages = [...messagesRef.current, createUserMessage(userMessage)];
|
||||
setMessagesAndLog(currentMessages, 'user-entered');
|
||||
|
||||
log.state(ChatState.Streaming, { reason: 'user submit' });
|
||||
setChatState(ChatState.Streaming);
|
||||
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
try {
|
||||
// TODO(Douwe): this side steps our API. heyapi does support streaming though which should make
|
||||
// this all nice & typed
|
||||
log.stream('request-start', { sessionId: sessionId.slice(0, 8) });
|
||||
|
||||
const response = await fetch(getApiUrl('/reply'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
|
|
@ -74,66 +332,57 @@ export function useChatStream({
|
|||
signal: abortControllerRef.current.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
if (!response.body) throw new Error('No response body');
|
||||
log.stream('response-received', {
|
||||
status: response.status,
|
||||
ok: response.ok,
|
||||
});
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
await streamFromResponse(
|
||||
response,
|
||||
currentMessages,
|
||||
(messages: Message[]) => setMessagesAndLog(messages, 'streaming'),
|
||||
onFinish
|
||||
);
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
const chunk = decoder.decode(value);
|
||||
const lines = chunk.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data: ')) continue;
|
||||
|
||||
const data = line.slice(6);
|
||||
if (data === '[DONE]') continue;
|
||||
|
||||
try {
|
||||
const event = JSON.parse(data);
|
||||
|
||||
if (event.message) {
|
||||
const msg = event.message as Message;
|
||||
currentMessages = pushMessage(currentMessages, msg);
|
||||
setMessages(currentMessages);
|
||||
}
|
||||
|
||||
if (event.error) {
|
||||
console.error('Stream error:', event.error);
|
||||
setChatState(ChatState.Idle);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.finish) {
|
||||
setChatState(ChatState.Idle);
|
||||
onStreamFinish?.();
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to parse SSE:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
log.stream('stream-complete');
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name !== 'AbortError') {
|
||||
console.error('Stream error:', error);
|
||||
// AbortError is expected when user stops streaming
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
log.stream('stream-aborted');
|
||||
} else {
|
||||
// Unexpected error during fetch setup (streamFromResponse handles its own errors)
|
||||
log.error('submit failed', error);
|
||||
onFinish('Submit error: ' + (error instanceof Error ? error.message : String(error)));
|
||||
}
|
||||
setChatState(ChatState.Idle);
|
||||
}
|
||||
},
|
||||
[sessionId, messages, setMessages, onStreamFinish]
|
||||
[sessionId, setMessagesAndLog, onFinish]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialMessage && session && messages.length === 0 && chatState === ChatState.Idle) {
|
||||
log.messages('auto-submit-initial', 0, { initialMessage: initialMessage.slice(0, 50) });
|
||||
handleSubmit(initialMessage);
|
||||
}
|
||||
}, [initialMessage, session, messages.length, chatState, handleSubmit]);
|
||||
|
||||
const stopStreaming = useCallback(() => {
|
||||
log.stream('stop-requested');
|
||||
abortControllerRef.current?.abort();
|
||||
log.state(ChatState.Idle, { reason: 'user stopped streaming' });
|
||||
setChatState(ChatState.Idle);
|
||||
}, []);
|
||||
|
||||
const cached = resultsCache.get(sessionId);
|
||||
const maybe_cached_messages = session ? messages : cached?.messages || [];
|
||||
const maybe_cached_session = session ?? cached?.session;
|
||||
|
||||
console.log('>> returning', sessionId, Date.now(), maybe_cached_messages, chatState);
|
||||
|
||||
return {
|
||||
sessionLoadError,
|
||||
messages: maybe_cached_messages,
|
||||
session: maybe_cached_session,
|
||||
chatState,
|
||||
handleSubmit,
|
||||
stopStreaming,
|
||||
|
|
|
|||
|
|
@ -12,11 +12,6 @@ export type Recipe = import('../api').Recipe & {
|
|||
// Properties added for scheduled execution
|
||||
scheduledJobId?: string;
|
||||
isScheduledExecution?: boolean;
|
||||
// TODO: Separate these from the raw recipe type
|
||||
// Legacy frontend properties (not in OpenAPI schema)
|
||||
profile?: string;
|
||||
goosehints?: string;
|
||||
mcps?: number;
|
||||
};
|
||||
|
||||
export async function encodeRecipe(recipe: Recipe): Promise<string> {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ export type ToolResponseMessageContent = ToolResponse & { type: 'toolResponse' }
|
|||
|
||||
export function createUserMessage(text: string): Message {
|
||||
return {
|
||||
id: generateId(),
|
||||
id: generateMessageId(),
|
||||
role: 'user',
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
content: [{ type: 'text', text }],
|
||||
|
|
@ -15,7 +15,7 @@ export function createUserMessage(text: string): Message {
|
|||
|
||||
export function createToolErrorResponseMessage(id: string, error: string): Message {
|
||||
return {
|
||||
id: generateId(),
|
||||
id: generateMessageId(),
|
||||
role: 'user',
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
content: [
|
||||
|
|
@ -32,7 +32,7 @@ export function createToolErrorResponseMessage(id: string, error: string): Messa
|
|||
};
|
||||
}
|
||||
|
||||
function generateId(): string {
|
||||
export function generateMessageId(): string {
|
||||
return Math.random().toString(36).substring(2, 10);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue