Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/**
* MCP Invoke API Route
* POST /api/projects/:name/agentic-sessions/:sessionName/mcp/invoke
* Proxies to backend which proxies to runner to invoke an MCP tool
*/

import { BACKEND_URL } from '@/lib/config'
import { buildForwardHeadersAsync } from '@/lib/auth'

export const dynamic = 'force-dynamic'

export async function POST(
request: Request,
{ params }: { params: Promise<{ name: string; sessionName: string }> },
) {
const { name, sessionName } = await params

const headers = await buildForwardHeadersAsync(request, {
'Content-Type': 'application/json',
})

const body = await request.text()

const backendUrl = `${BACKEND_URL}/projects/${encodeURIComponent(name)}/agentic-sessions/${encodeURIComponent(sessionName)}/mcp/invoke`

try {
const response = await fetch(backendUrl, {
method: 'POST',
headers,
body,
})

if (!response.ok) {
const errorText = await response.text()
// Preserve structured JSON errors from backend; wrap plain text
let errorBody: string
try {
const parsed = JSON.parse(errorText)
errorBody = JSON.stringify(parsed)
} catch {
errorBody = JSON.stringify({ error: errorText || `HTTP ${response.status}` })
}
return new Response(errorBody, {
status: response.status,
headers: { 'Content-Type': 'application/json' },
})
}

const data = await response.json()
return Response.json(data)
} catch (error) {
console.error('MCP invoke proxy error:', error)
return new Response(
JSON.stringify({
error: error instanceof Error ? error.message : 'Failed to invoke MCP tool',
}),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
)
}
}
1 change: 0 additions & 1 deletion components/frontend/src/app/globals.css
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "../styles/syntax-highlighting.css";

@custom-variant dark (&:is(.dark *));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,21 @@

import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { RefreshCw, Octagon, Trash2, Copy, MoreVertical, Info, Play, Pencil } from 'lucide-react';
import { RefreshCw, Octagon, Trash2, Copy, MoreVertical, Info, Play, Pencil, Download, FileText, Printer, Loader2, HardDrive } from 'lucide-react';
import { CloneSessionDialog } from '@/components/clone-session-dialog';
import { SessionDetailsModal } from '@/components/session-details-modal';
import { EditSessionNameDialog } from '@/components/edit-session-name-dialog';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuSeparator } from '@/components/ui/dropdown-menu';
import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger,
DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubTrigger, DropdownMenuSubContent,
} from '@/components/ui/dropdown-menu';
import type { AgenticSession } from '@/types/agentic-session';
import { useUpdateSessionDisplayName } from '@/services/queries';
import { useUpdateSessionDisplayName, useCurrentUser, useSessionExport } from '@/services/queries';
import { useMcpStatus } from '@/services/queries/use-mcp';
import { useGoogleStatus } from '@/services/queries/use-google';
import { successToast, errorToast } from '@/hooks/use-toast';
import { saveToGoogleDrive } from '@/services/api/sessions';
import { convertEventsToMarkdown, downloadAsMarkdown, exportAsPdf } from '@/utils/export-chat';

type SessionHeaderProps = {
session: AgenticSession;
Expand All @@ -34,14 +41,25 @@ export function SessionHeader({
}: SessionHeaderProps) {
const [detailsModalOpen, setDetailsModalOpen] = useState(false);
const [editNameDialogOpen, setEditNameDialogOpen] = useState(false);

const [exportLoading, setExportLoading] = useState<'markdown' | 'pdf' | 'gdrive' | null>(null);

const updateDisplayNameMutation = useUpdateSessionDisplayName();

const { data: me } = useCurrentUser();

const phase = session.status?.phase || "Pending";
const canStop = phase === "Running" || phase === "Creating";
const isRunning = phase === "Running";
const canStop = isRunning || phase === "Creating";
const canResume = phase === "Stopped";
const canDelete = phase === "Completed" || phase === "Failed" || phase === "Stopped";


const { refetch: fetchExportData } = useSessionExport(projectName, session.metadata.name, false);
const { data: mcpStatus } = useMcpStatus(projectName, session.metadata.name, isRunning);
const { data: googleStatus } = useGoogleStatus();
const googleDriveServer = mcpStatus?.servers?.find(
(s) => s.name.includes('gdrive') || s.name.includes('google-drive') || s.name.includes('google-workspace')
);
const hasGdriveMcp = !!googleDriveServer;

const handleEditName = (newName: string) => {
updateDisplayNameMutation.mutate(
{
Expand All @@ -62,6 +80,104 @@ export function SessionHeader({
);
};

const handleExport = async (format: 'markdown' | 'pdf' | 'gdrive') => {
if (format === 'gdrive') {
if (!googleStatus?.connected) {
errorToast('Connect Google Drive in Integrations first');
return;
}
if (!isRunning || !hasGdriveMcp) {
errorToast('Session must be running with Google Drive MCP configured');
return;
}
}

setExportLoading(format);
try {
const { data: exportData } = await fetchExportData();
if (!exportData) {
throw new Error('No export data available');
}
const markdown = convertEventsToMarkdown(exportData, session, {
username: me?.displayName || me?.username || me?.email,
projectName,
});
const filename = session.spec.displayName || session.metadata.name;

switch (format) {
case 'markdown':
downloadAsMarkdown(markdown, `${filename}.md`);
successToast('Chat exported as Markdown');
break;
case 'pdf':
exportAsPdf(markdown, filename);
break;
case 'gdrive': {
const result = await saveToGoogleDrive(
projectName, session.metadata.name, markdown,
`${filename}.md`, me?.email ?? '', googleDriveServer?.name ?? 'google-workspace',
);
if (result.error) {
throw new Error(result.error);
}
if (!result.content) {
throw new Error('Failed to create file in Google Drive');
}
successToast('Saved to Google Drive');
break;
}
}
} catch (err) {
errorToast(err instanceof Error ? err.message : 'Failed to export chat');
} finally {
setExportLoading(null);
}
};

const exportSubMenu = (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Download className="w-4 h-4 mr-2" />
Export chat
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem
onClick={() => void handleExport('markdown')}
disabled={exportLoading !== null}
>
{exportLoading === 'markdown' ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<FileText className="w-4 h-4 mr-2" />
)}
As Markdown
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => void handleExport('pdf')}
disabled={exportLoading !== null}
>
{exportLoading === 'pdf' ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<Printer className="w-4 h-4 mr-2" />
)}
As PDF
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => void handleExport('gdrive')}
disabled={exportLoading !== null}
>
{exportLoading === 'gdrive' ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<HardDrive className="w-4 h-4 mr-2" />
)}
Save to my Google Drive
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
);

// Kebab menu only (for breadcrumb line)
if (renderMode === 'kebab-only') {
return (
Expand Down Expand Up @@ -116,6 +232,7 @@ export function SessionHeader({
}
projectName={projectName}
/>
{exportSubMenu}
{canDelete && (
<>
<DropdownMenuSeparator />
Expand All @@ -131,14 +248,14 @@ export function SessionHeader({
)}
</DropdownMenuContent>
</DropdownMenu>

<SessionDetailsModal
session={session}
projectName={projectName}
open={detailsModalOpen}
onOpenChange={setDetailsModalOpen}
/>

<EditSessionNameDialog
open={editNameDialogOpen}
onOpenChange={setEditNameDialogOpen}
Expand Down Expand Up @@ -224,7 +341,7 @@ export function SessionHeader({
Resume
</Button>
)}

{/* Actions dropdown menu */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
Expand Down Expand Up @@ -252,6 +369,7 @@ export function SessionHeader({
}
projectName={projectName}
/>
{exportSubMenu}
{canDelete && (
<>
<DropdownMenuSeparator />
Expand All @@ -276,7 +394,7 @@ export function SessionHeader({
open={detailsModalOpen}
onOpenChange={setDetailsModalOpen}
/>

<EditSessionNameDialog
open={editNameDialogOpen}
onOpenChange={setEditNameDialogOpen}
Expand Down
25 changes: 8 additions & 17 deletions components/frontend/src/components/session-details-modal.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useCallback, useState } from 'react';
import { useState } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
Expand All @@ -10,6 +10,7 @@ import type { AgenticSession } from '@/types/agentic-session';
import { getPhaseColor } from '@/utils/session-helpers';
import { successToast } from '@/hooks/use-toast';
import { useSessionExport } from '@/services/queries/use-sessions';
import { triggerDownload } from '@/utils/export-chat';

type SessionDetailsModalProps = {
session: AgenticSession;
Expand All @@ -35,37 +36,27 @@ export function SessionDetailsModal({
open // Only fetch when modal is open
);

const downloadFile = useCallback((data: unknown, filename: string) => {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.click();
URL.revokeObjectURL(url);
}, []);

const handleExportAgui = useCallback(() => {
const handleExportAgui = () => {
if (!exportData) return;
setExportingAgui(true);
try {
downloadFile(exportData.aguiEvents, `${sessionName}-chat.json`);
triggerDownload(JSON.stringify(exportData.aguiEvents, null, 2), `${sessionName}-chat.json`, 'application/json');
successToast('Chat exported successfully');
} finally {
setExportingAgui(false);
}
}, [exportData, sessionName, downloadFile]);
};

const handleExportLegacy = useCallback(() => {
const handleExportLegacy = () => {
if (!exportData?.legacyMessages) return;
setExportingLegacy(true);
try {
downloadFile(exportData.legacyMessages, `${sessionName}-legacy-messages.json`);
triggerDownload(JSON.stringify(exportData.legacyMessages, null, 2), `${sessionName}-legacy-messages.json`, 'application/json');
successToast('Legacy messages exported successfully');
} finally {
setExportingLegacy(false);
}
}, [exportData, sessionName, downloadFile]);
};

return (
<Dialog open={open} onOpenChange={onOpenChange}>
Expand Down
2 changes: 1 addition & 1 deletion components/frontend/src/components/ui/toaster.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export function Toaster() {
const { toasts } = useToast()

return (
<ToastProvider>
<ToastProvider duration={5000}>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
Expand Down
29 changes: 29 additions & 0 deletions components/frontend/src/services/api/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,3 +257,32 @@ export async function getReposStatus(
`/projects/${projectName}/agentic-sessions/${sessionName}/repos/status`
);
}

/**
* Response from Google Drive file creation
*/
export type GoogleDriveFileResponse = {
content?: string;
error?: string;
};

/**
* Save content to Google Drive via the session's MCP server
*/
export async function saveToGoogleDrive(
projectName: string,
sessionName: string,
content: string,
filename: string,
userEmail: string,
serverName: string = 'google-workspace',
): Promise<GoogleDriveFileResponse> {
return apiClient.post<GoogleDriveFileResponse>(
`/projects/${projectName}/agentic-sessions/${sessionName}/mcp/invoke`,
{
server: serverName,
tool: 'create_drive_file',
args: { user_google_email: userEmail, file_name: filename, content, mime_type: 'text/markdown' },
},
);
}
1 change: 1 addition & 0 deletions components/frontend/src/services/queries/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@ export * from './use-secrets';
export * from './use-repo';
export * from './use-workspace';
export * from './use-auth';
export * from './use-google';
Loading
Loading