diff --git a/components/frontend/src/app/api/projects/[name]/agentic-sessions/[sessionName]/mcp/invoke/route.ts b/components/frontend/src/app/api/projects/[name]/agentic-sessions/[sessionName]/mcp/invoke/route.ts new file mode 100644 index 000000000..b3fda7358 --- /dev/null +++ b/components/frontend/src/app/api/projects/[name]/agentic-sessions/[sessionName]/mcp/invoke/route.ts @@ -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' } } + ) + } +} diff --git a/components/frontend/src/app/globals.css b/components/frontend/src/app/globals.css index 7d56c04ca..e8e3c41b1 100644 --- a/components/frontend/src/app/globals.css +++ b/components/frontend/src/app/globals.css @@ -1,5 +1,4 @@ @import "tailwindcss"; -@import "tw-animate-css"; @import "../styles/syntax-highlighting.css"; @custom-variant dark (&:is(.dark *)); diff --git a/components/frontend/src/app/projects/[name]/sessions/[sessionName]/session-header.tsx b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/session-header.tsx index 985e49d87..e55393f43 100644 --- a/components/frontend/src/app/projects/[name]/sessions/[sessionName]/session-header.tsx +++ b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/session-header.tsx @@ -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; @@ -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( { @@ -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 = ( + + + + Export chat + + + void handleExport('markdown')} + disabled={exportLoading !== null} + > + {exportLoading === 'markdown' ? ( + + ) : ( + + )} + As Markdown + + void handleExport('pdf')} + disabled={exportLoading !== null} + > + {exportLoading === 'pdf' ? ( + + ) : ( + + )} + As PDF + + void handleExport('gdrive')} + disabled={exportLoading !== null} + > + {exportLoading === 'gdrive' ? ( + + ) : ( + + )} + Save to my Google Drive + + + + ); + // Kebab menu only (for breadcrumb line) if (renderMode === 'kebab-only') { return ( @@ -116,6 +232,7 @@ export function SessionHeader({ } projectName={projectName} /> + {exportSubMenu} {canDelete && ( <> @@ -131,14 +248,14 @@ export function SessionHeader({ )} - + - + )} - + {/* Actions dropdown menu */} @@ -252,6 +369,7 @@ export function SessionHeader({ } projectName={projectName} /> + {exportSubMenu} {canDelete && ( <> @@ -276,7 +394,7 @@ export function SessionHeader({ open={detailsModalOpen} onOpenChange={setDetailsModalOpen} /> - + { - 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 ( diff --git a/components/frontend/src/components/ui/toaster.tsx b/components/frontend/src/components/ui/toaster.tsx index f6adb9563..fbaa26fef 100644 --- a/components/frontend/src/components/ui/toaster.tsx +++ b/components/frontend/src/components/ui/toaster.tsx @@ -14,7 +14,7 @@ export function Toaster() { const { toasts } = useToast() return ( - + {toasts.map(function ({ id, title, description, action, ...props }) { return ( diff --git a/components/frontend/src/services/api/sessions.ts b/components/frontend/src/services/api/sessions.ts index bd55d9d28..e9d38bf9f 100644 --- a/components/frontend/src/services/api/sessions.ts +++ b/components/frontend/src/services/api/sessions.ts @@ -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 { + return apiClient.post( + `/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' }, + }, + ); +} diff --git a/components/frontend/src/services/queries/index.ts b/components/frontend/src/services/queries/index.ts index 84c55b558..f5064ee2f 100644 --- a/components/frontend/src/services/queries/index.ts +++ b/components/frontend/src/services/queries/index.ts @@ -12,3 +12,4 @@ export * from './use-secrets'; export * from './use-repo'; export * from './use-workspace'; export * from './use-auth'; +export * from './use-google'; diff --git a/components/frontend/src/utils/export-chat.ts b/components/frontend/src/utils/export-chat.ts new file mode 100644 index 000000000..e6fd632d7 --- /dev/null +++ b/components/frontend/src/utils/export-chat.ts @@ -0,0 +1,438 @@ +/** + * Chat export utilities + * Converts AG-UI events to human-readable Markdown and supports download/PDF export. + */ + +import { AGUIEventType } from '@/types/agui'; +import type { AgenticSession } from '@/types/agentic-session'; +import type { SessionExportResponse } from '@/services/api/sessions'; + +type ExportEvent = { + type: string; + role?: string; + delta?: string; + toolCallId?: string; + toolCallName?: string; + result?: string; + error?: string; + timestamp?: string; +}; + +function isExportEvent(raw: unknown): raw is ExportEvent { + if (typeof raw !== 'object' || raw === null || !('type' in raw)) return false; + const obj = raw as Record; + return typeof obj.type === 'string'; +} + +const MAX_TOOL_ARGS_LENGTH = 2000; +const MAX_ERROR_LENGTH = 1000; +const MAX_RESULT_LENGTH = 2000; + +type ConversationBlock = + | { kind: 'message'; role: string; content: string; timestamp?: string } + | { kind: 'tool'; name: string; args: string; result?: string; error?: string; timestamp?: string }; + +/** + * Walk the raw AG-UI event array and assemble conversation blocks. + */ +function assembleBlocks(events: unknown[]): ConversationBlock[] { + const blocks: ConversationBlock[] = []; + let currentRole: string | null = null; + let currentContent = ''; + let currentTimestamp: string | undefined; + const toolCalls = new Map(); + + for (const raw of events) { + if (!isExportEvent(raw)) continue; + const ev = raw; + + switch (ev.type) { + case AGUIEventType.TEXT_MESSAGE_START: + currentRole = ev.role ?? 'assistant'; + currentContent = ''; + currentTimestamp = ev.timestamp; + break; + + case AGUIEventType.TEXT_MESSAGE_CONTENT: + if (ev.delta) currentContent += ev.delta; + break; + + case AGUIEventType.TEXT_MESSAGE_END: + if (currentRole && currentContent.trim()) { + blocks.push({ kind: 'message', role: currentRole, content: currentContent.trim(), timestamp: currentTimestamp }); + } + currentRole = null; + currentContent = ''; + currentTimestamp = undefined; + break; + + case AGUIEventType.TOOL_CALL_START: + if (ev.toolCallId) { + toolCalls.set(ev.toolCallId, { name: ev.toolCallName ?? 'unknown', args: '', timestamp: ev.timestamp }); + } + break; + + case AGUIEventType.TOOL_CALL_ARGS: + if (ev.toolCallId) { + const tc = toolCalls.get(ev.toolCallId); + if (tc && ev.delta) tc.args += ev.delta; + } + break; + + case AGUIEventType.TOOL_CALL_END: + if (ev.toolCallId) { + const tc = toolCalls.get(ev.toolCallId); + if (tc) { + blocks.push({ + kind: 'tool', + name: tc.name, + args: tc.args, + result: ev.result, + error: ev.error, + timestamp: tc.timestamp, + }); + toolCalls.delete(ev.toolCallId); + } + } + break; + + default: + break; + } + } + + // Flush any trailing message that wasn't closed + if (currentRole && currentContent.trim()) { + blocks.push({ kind: 'message', role: currentRole, content: currentContent.trim(), timestamp: currentTimestamp }); + } + + return blocks; +} + +function formatTimestamp(ts?: string): string { + if (!ts) return ''; + try { + return new Date(ts).toLocaleString(); + } catch { + return ts; + } +} + +function prettyJson(raw: string): string { + try { + return JSON.stringify(JSON.parse(raw), null, 2); + } catch { + return raw; + } +} + +function truncate(s: string, max: number): string { + if (s.length <= max) return s; + return s.slice(0, max) + '\n... (truncated)'; +} + +/** + * Convert AG-UI events into a Markdown string. + */ +export function convertEventsToMarkdown( + exportData: SessionExportResponse, + session: AgenticSession, + options?: { username?: string; projectName?: string }, +): string { + const displayName = session.spec.displayName || session.metadata.name; + const model = session.spec.llmSettings.model; + const created = formatTimestamp(session.metadata.creationTimestamp); + const phase = session.status?.phase ?? 'Unknown'; + + const sessionUrl = + typeof window !== 'undefined' && options?.projectName + ? `${window.location.origin}/projects/${options.projectName}/sessions/${session.metadata.name}` + : ''; + const sessionCell = sessionUrl + ? `[${session.metadata.name}](${sessionUrl})` + : session.metadata.name; + + const lines: string[] = [ + `# ${displayName}`, + '', + `| Field | Value |`, + `|-------|-------|`, + `| Session | ${sessionCell} |`, + ...(options?.username ? [`| User | ${options.username} |`] : []), + `| Model | ${model} |`, + `| Status | ${phase} |`, + `| Created | ${created} |`, + `| Exported | ${new Date().toLocaleString()} |`, + '', + '---', + '', + ]; + + const blocks = assembleBlocks(exportData.aguiEvents ?? []); + + if (blocks.length === 0) { + lines.push('*No conversation content found.*'); + return lines.join('\n'); + } + + for (const block of blocks) { + if (block.kind === 'message') { + const roleLabel = + block.role === 'user' ? '\u{1F464} User' : block.role === 'assistant' ? '\u{1F916} Assistant' : block.role; + const ts = formatTimestamp(block.timestamp); + lines.push(`## ${roleLabel}`); + if (ts) lines.push(`*${ts}*`); + lines.push(''); + lines.push(block.content); + lines.push(''); + } else { + const ts = formatTimestamp(block.timestamp); + lines.push(`
`); + lines.push(`\u{1F527} Tool: ${block.name}${ts ? ` (${ts})` : ''}`); + lines.push(''); + if (block.args.trim()) { + lines.push('**Arguments:**'); + lines.push('```json'); + lines.push(truncate(prettyJson(block.args), MAX_TOOL_ARGS_LENGTH)); + lines.push('```'); + } + if (block.error) { + lines.push('**Error:**'); + lines.push('```'); + lines.push(truncate(block.error, MAX_ERROR_LENGTH)); + lines.push('```'); + } else if (block.result) { + lines.push('**Result:**'); + lines.push('```'); + lines.push(truncate(block.result, MAX_RESULT_LENGTH)); + lines.push('```'); + } + lines.push('
'); + lines.push(''); + } + } + + return lines.join('\n'); +} + +/** + * Trigger a browser file download with the given content. + */ +export function triggerDownload(content: string, filename: string, mimeType: string): void { + const blob = new Blob([content], { type: mimeType }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + link.click(); + setTimeout(() => URL.revokeObjectURL(url), 100); +} + +/** + * Download a Markdown string as a `.md` file. + */ +export function downloadAsMarkdown(markdown: string, filename: string): void { + triggerDownload(markdown, filename, 'text/markdown;charset=utf-8'); +} + +/** + * Render markdown as styled HTML in a new window and trigger the browser print dialog + * (which offers "Save as PDF"). + */ +export function exportAsPdf(markdown: string, sessionName: string): void { + const html = markdownToHtml(markdown); + + const printWindow = window.open('', '_blank'); + if (!printWindow) { + throw new Error('Failed to open print window. Please allow popups for this site.'); + } + + printWindow.document.write(` + + + + ${escapeHtml(sessionName)} - Chat Export + + + +${html} + +`); + + printWindow.document.close(); + + // Trigger print exactly once: load listener with timeout fallback and guard flag + let printed = false; + const doPrint = () => { + if (printed) return; + printed = true; + printWindow.print(); + }; + const timeoutId = setTimeout(doPrint, 500); + printWindow.addEventListener('load', () => { + clearTimeout(timeoutId); + doPrint(); + }, { once: true }); +} + +// --------------- Helpers --------------- + +function escapeHtml(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +/** + * Minimal markdown-to-HTML converter. + * Handles headings, tables, code blocks, paragraphs,
, hr, bold, italic, and inline code. + * Only processes the structured output we generate — not a general-purpose parser. + */ +function markdownToHtml(md: string): string { + const lines = md.split('\n'); + const out: string[] = []; + let i = 0; + + while (i < lines.length) { + const line = lines[i]; + + if (line.trim() === '') { + i++; + continue; + } + + // Pass through HTML tags directly (
, ,
) + // Add visual accent border to
blocks + if (/^\s*<\/?(?:details|summary)/.test(line)) { + out.push( + line.replace( + /^(\s*/, + '$1 style="border-left:3px solid #6b7280">', + ), + ); + i++; + continue; + } + + // Horizontal rule + if (/^-{3,}$/.test(line.trim())) { + out.push('
'); + i++; + continue; + } + + // Headings — apply role-specific colors for h2 + const headingMatch = line.match(/^(#{1,6})\s+(.*)$/); + if (headingMatch) { + const level = headingMatch[1].length; + const text = headingMatch[2]; + let style = ''; + if (level === 2) { + if (text.includes('\u{1F464}')) style = ' style="color:#1d4ed8"'; // blue for User + else if (text.includes('\u{1F916}')) style = ' style="color:#15803d"'; // green for Assistant + } + out.push(`${inlineFormat(text)}`); + i++; + continue; + } + + // Fenced code block + if (line.trim().startsWith('```')) { + i++; + const codeLines: string[] = []; + while (i < lines.length && !lines[i].trim().startsWith('```')) { + codeLines.push(lines[i]); + i++; + } + i++; // skip closing ``` + out.push(`
${escapeHtml(codeLines.join('\n'))}
`); + continue; + } + + // Table (starts with |) + if (line.trim().startsWith('|')) { + const tableLines: string[] = []; + while (i < lines.length && lines[i].trim().startsWith('|')) { + tableLines.push(lines[i]); + i++; + } + out.push(parseTable(tableLines)); + continue; + } + + // Paragraph + out.push(`

${inlineFormat(line)}

`); + i++; + } + + return out.join('\n'); +} + +function inlineFormat(s: string): string { + // Escape HTML first to prevent XSS from message content + let result = escapeHtml(s); + // Markdown links: [text](url) — render as clickable anchors in PDF (http/https only) + result = result.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_match: string, text: string, url: string) => { + try { + const parsed = new URL(url); + if (parsed.protocol === 'http:' || parsed.protocol === 'https:') { + return `${text}`; + } + } catch { + // invalid URL — fall through + } + return `${text} (${url})`; + }); + result = result.replace(/\*\*(.+?)\*\*/g, '$1'); + result = result.replace(/(?$1'); + result = result.replace(/`([^`]+?)`/g, '$1'); + return result; +} + +function parseTable(rows: string[]): string { + const parsed = rows + .filter((r) => !/^\|\s*[-:]+/.test(r)) + .map((r) => + r + .split('|') + .slice(1, -1) + .map((c) => c.trim()), + ); + + if (parsed.length === 0) return ''; + + const [header, ...body] = parsed; + let html = ''; + for (const cell of header) html += ``; + html += ''; + for (const row of body) { + html += ''; + for (const cell of row) html += ``; + html += ''; + } + html += '
${inlineFormat(cell)}
${inlineFormat(cell)}
'; + return html; +} diff --git a/components/frontend/tailwind.config.js b/components/frontend/tailwind.config.js index 7e3e0e0cc..bc21d100d 100644 --- a/components/frontend/tailwind.config.js +++ b/components/frontend/tailwind.config.js @@ -93,8 +93,5 @@ module.exports = { }, }, }, - plugins: [ - // eslint-disable-next-line @typescript-eslint/no-require-imports - require("tw-animate-css") - ], + plugins: [], }