diff --git a/.env.example b/.env.example index ad7752c1..23d5af23 100644 --- a/.env.example +++ b/.env.example @@ -33,6 +33,9 @@ WEBHOOK_BASE_URL= OPENAI_API_KEY= +# REQUIRED for widget generation feature +ANTHROPIC_API_KEY= + AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= diff --git a/.gitignore b/.gitignore index 625097a6..c3b8c631 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,5 @@ wheels/ config/ .docling.pid + +widgets/ diff --git a/frontend/components/markdown-renderer.tsx b/frontend/components/markdown-renderer.tsx index 1b2276db..ba7a7dc4 100644 --- a/frontend/components/markdown-renderer.tsx +++ b/frontend/components/markdown-renderer.tsx @@ -15,6 +15,12 @@ const preprocessChatMessage = (text: string): string => { .replace(//g, "``") .replace(/<\/think>/g, "``"); + // Replace widget URIs with italic "Showing widget" text + // Match ui://widget/xxx.html with or without backquotes, and remove the backquotes + // Use a comprehensive pattern that captures all variations + processed = processed.replace(/`+\s*ui:\/\/widget\/[^\s`]+\.html\s*`+/g, "_Showing widget_"); + processed = processed.replace(/ui:\/\/widget\/\S+\.html/g, "_Showing widget_"); + // Clean up tables if present if (isMarkdownTable(processed)) { processed = cleanupTableEmptyCells(processed); diff --git a/frontend/components/navigation.tsx b/frontend/components/navigation.tsx index 423172f5..77af7236 100644 --- a/frontend/components/navigation.tsx +++ b/frontend/components/navigation.tsx @@ -5,9 +5,11 @@ import { FileText, Library, MessageSquare, + Pencil, Plus, Settings2, Trash2, + Box, } from "lucide-react"; import Link from "next/link"; import { usePathname } from "next/navigation"; @@ -62,16 +64,51 @@ export interface ChatConversation { [key: string]: unknown; } +interface WidgetMcpMetadata { + widget_id: string; + identifier: string; + title: string; + template_uri: string; + invoking: string; + invoked: string; + response_text: string; + has_css: boolean; + description?: string | null; +} + +interface Widget { + widget_id: string; + prompt: string; + title?: string; + description?: string | null; + user_id: string; + created_at: string; + has_css: boolean; + mcp?: WidgetMcpMetadata; +} + interface NavigationProps { conversations?: ChatConversation[]; isConversationsLoading?: boolean; onNewConversation?: () => void; + widgets?: Widget[]; + selectedWidget?: string | null; + onWidgetSelect?: (widgetId: string) => void; + onDeleteWidget?: (widgetId: string) => void; + onRenameWidget?: (widgetId: string, newTitle: string) => void; + onNewWidget?: () => void; } export function Navigation({ conversations = [], isConversationsLoading = false, onNewConversation, + widgets = [], + selectedWidget = null, + onWidgetSelect, + onDeleteWidget, + onRenameWidget, + onNewWidget, }: NavigationProps = {}) { const pathname = usePathname(); const { @@ -94,7 +131,10 @@ export function Navigation({ const [deleteModalOpen, setDeleteModalOpen] = useState(false); const [conversationToDelete, setConversationToDelete] = useState(null); + const [renamingWidgetId, setRenamingWidgetId] = useState(null); + const [renameValue, setRenameValue] = useState(""); const fileInputRef = useRef(null); + const renameInputRef = useRef(null); const { selectedFilter, setSelectedFilter } = useKnowledgeFilter(); @@ -267,6 +307,34 @@ export function Navigation({ } }; + const handleStartRename = (widget: Widget, event?: React.MouseEvent) => { + if (event) { + event.stopPropagation(); + } + setRenamingWidgetId(widget.widget_id); + // Use custom title if available, otherwise use prompt + const currentTitle = widget.title || widget.prompt.substring(0, 50); + setRenameValue(currentTitle); + // Focus the input after state updates + setTimeout(() => { + renameInputRef.current?.focus(); + renameInputRef.current?.select(); + }, 0); + }; + + const handleRenameSubmit = (widgetId: string) => { + if (renameValue.trim() && onRenameWidget) { + onRenameWidget(widgetId, renameValue.trim()); + } + setRenamingWidgetId(null); + setRenameValue(""); + }; + + const handleRenameCancel = () => { + setRenamingWidgetId(null); + setRenameValue(""); + }; + const confirmDeleteConversation = () => { if (conversationToDelete) { deleteSessionMutation.mutate({ @@ -289,6 +357,12 @@ export function Navigation({ href: "/knowledge", active: pathname === "/knowledge", }, + { + label: "Widgets", + icon: Box, + href: "/widgets", + active: pathname === "/widgets", + }, { label: "Settings", icon: Settings2, @@ -299,6 +373,7 @@ export function Navigation({ const isOnChatPage = pathname === "/" || pathname === "/chat"; const isOnKnowledgePage = pathname.startsWith("/knowledge"); + const isOnWidgetsPage = pathname.startsWith("/widgets"); // Clear placeholder when conversation count increases (new conversation was created) useEffect(() => { @@ -371,6 +446,129 @@ export function Navigation({ /> )} + {/* Widgets Page Specific Sections */} + {isOnWidgetsPage && ( +
+
+
+

+ Widgets +

+ +
+
+ +
+
+ {widgets.length === 0 ? ( +
+ No widgets yet +
+ ) : ( + widgets.map(widget => ( +
+ {renamingWidgetId === widget.widget_id ? ( +
+ setRenameValue(e.target.value)} + onKeyDown={e => { + if (e.key === "Enter") { + handleRenameSubmit(widget.widget_id); + } else if (e.key === "Escape") { + handleRenameCancel(); + } + }} + onBlur={() => handleRenameSubmit(widget.widget_id)} + className="flex-1 bg-background border border-border rounded px-2 py-1 text-sm focus:outline-none focus:ring-1 focus:ring-ring" + onClick={e => e.stopPropagation()} + /> +
+ ) : ( + + )} +
+ )) + )} +
+
+
+ )} + {/* Chat Page Specific Sections */} {isOnChatPage && (
diff --git a/frontend/next.config.ts b/frontend/next.config.ts index 5f31c456..3d7cbd3a 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -9,6 +9,20 @@ const nextConfig: NextConfig = { eslint: { ignoreDuringBuilds: true, }, + async rewrites() { + const backendHost = process.env.OPENRAG_BACKEND_HOST || 'localhost'; + const backendPort = process.env.OPENRAG_BACKEND_PORT || '8000'; + const backendSSL = process.env.OPENRAG_BACKEND_SSL === 'true'; + const protocol = backendSSL ? 'https' : 'http'; + const backendBaseUrl = `${protocol}://${backendHost}:${backendPort}`; + + return [ + { + source: '/widgets/:path*', + destination: `${backendBaseUrl}/widgets/:path*`, + }, + ]; + }, }; export default nextConfig; \ No newline at end of file diff --git a/frontend/src/app/api/[...path]/route.ts b/frontend/src/app/api/[...path]/route.ts index 7e7a8dbb..c6737ad5 100644 --- a/frontend/src/app/api/[...path]/route.ts +++ b/frontend/src/app/api/[...path]/route.ts @@ -40,13 +40,13 @@ async function proxyRequest( params: { path: string[] } ) { const backendHost = process.env.OPENRAG_BACKEND_HOST || 'localhost'; - const backendSSL= process.env.OPENRAG_BACKEND_SSL || false; + const backendPort = process.env.OPENRAG_BACKEND_PORT || '8000'; + const backendSSL = parseBoolean(process.env.OPENRAG_BACKEND_SSL); + const protocol = backendSSL ? 'https' : 'http'; + const backendBaseUrl = `${protocol}://${backendHost}:${backendPort}`; const path = params.path.join('/'); const searchParams = request.nextUrl.searchParams.toString(); - let backendUrl = `http://${backendHost}:8000/${path}${searchParams ? `?${searchParams}` : ''}`; - if (backendSSL) { - backendUrl = `https://${backendHost}:8000/${path}${searchParams ? `?${searchParams}` : ''}`; - } + const backendUrl = `${backendBaseUrl}/${path}${searchParams ? `?${searchParams}` : ''}`; try { let body: string | ArrayBuffer | undefined = undefined; @@ -129,4 +129,12 @@ async function proxyRequest( { status: 500 } ); } -} \ No newline at end of file +} + +function parseBoolean(value?: string | null): boolean { + if (!value) { + return false; + } + const normalized = value.toLowerCase(); + return normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on'; +} diff --git a/frontend/src/app/chat/page.tsx b/frontend/src/app/chat/page.tsx index 8bae1e84..58848784 100644 --- a/frontend/src/app/chat/page.tsx +++ b/frontend/src/app/chat/page.tsx @@ -10,7 +10,9 @@ import { Loader2, Plus, Settings, + Box, User, + Wrench, X, Zap, } from "lucide-react"; @@ -26,9 +28,18 @@ import { PopoverAnchor, PopoverContent, } from "@/components/ui/popover"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { ScrollArea } from "@/components/ui/scroll-area"; import { useAuth } from "@/contexts/auth-context"; import { type EndpointType, useChat } from "@/contexts/chat-context"; import { useKnowledgeFilter } from "@/contexts/knowledge-filter-context"; +import { useWidget } from "@/contexts/widget-context"; import { useTask } from "@/contexts/task-context"; import { useLoadingStore } from "@/stores/loadingStore"; import { useGetNudgesQuery } from "../api/queries/useGetNudgesQuery"; @@ -89,9 +100,7 @@ interface RequestBody { } function ChatPage() { - const isDebugMode = - process.env.NODE_ENV === "development" || - process.env.NEXT_PUBLIC_OPENRAG_DEBUG === "true"; + const isDebugMode = process.env.NEXT_PUBLIC_OPENRAG_DEBUG === "true"; const { user } = useAuth(); const { endpoint, @@ -151,11 +160,144 @@ function ChatPage() { const { addTask } = useTask(); const { selectedFilter, parsedFilterData, setSelectedFilter } = useKnowledgeFilter(); + const { widgets, loadWidgets, isLoading: isWidgetListLoading } = useWidget(); + const [isWidgetModalOpen, setIsWidgetModalOpen] = useState(false); + const [isWidgetLoading, setIsWidgetLoading] = useState(false); + const [selectedWidgetId, setSelectedWidgetId] = useState(null); + const [widgetHtml, setWidgetHtml] = useState(null); + const [widgetError, setWidgetError] = useState(null); + const [inlineWidgets, setInlineWidgets] = useState>({}); + const [streamingWidgetHtml, setStreamingWidgetHtml] = useState(null); const scrollToBottom = () => { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); }; + // Extract widget URI from message content (handles backquotes) + const extractWidgetUri = (content: string): string | null => { + // Match ui://widget/xxx.html with or without backquotes + const patterns = [ + /`(ui:\/\/widget\/[^`]+\.html)`/, // In backquotes + /ui:\/\/widget\/\S+\.html/, // Without backquotes + ]; + + for (const pattern of patterns) { + const match = content.match(pattern); + if (match) { + return match[1] || match[0]; + } + } + return null; + }; + + // Load widget HTML from URI + const loadWidgetFromUri = async (uri: string, messageIndex: number): Promise => { + try { + const response = await fetch("/api/mcp/widgets/mcp", { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: `read-${uri}`, + method: "resources/read", + params: { uri }, + }), + }); + + const contentType = response.headers.get("content-type") || ""; + const rawBody = await response.text(); + + if (!response.ok) { + throw new Error( + `Server responded with ${response.status}${rawBody ? `: ${rawBody}` : ""}` + ); + } + + let data: any = null; + if (contentType.includes("text/event-stream")) { + const blocks = rawBody.split(/\n\n/); + let lastData: string | null = null; + for (const block of blocks) { + const dataLines = block + .split("\n") + .filter(line => line.startsWith("data:")) + .map(line => line.slice(5).trim()) + .filter(Boolean); + if (dataLines.length > 0) { + lastData = dataLines.join("\n"); + } + } + if (!lastData) { + throw new Error("No data payload in event stream response"); + } + data = JSON.parse(lastData); + } else if (rawBody) { + data = JSON.parse(rawBody); + } + + const contents = data?.result?.contents; + const html = contents?.[0]?.text; + if (!html) { + throw new Error("Widget resource response did not include HTML content"); + } + + // Store the loaded widget HTML for this message + if (messageIndex >= 0) { + setInlineWidgets(prev => ({ + ...prev, + [messageIndex]: html, + })); + } + + return html; + } catch (error: any) { + console.error("Failed to load widget from URI:", uri, error); + return null; + } + }; + + useEffect(() => { + if (isWidgetModalOpen) { + loadWidgets().catch(error => { + console.error("Failed to load widgets", error); + setWidgetError("Failed to load widgets"); + }); + } + }, [isWidgetModalOpen, loadWidgets]); + + // Auto-detect and load widgets from assistant messages + useEffect(() => { + messages.forEach((message, index) => { + if (message.role === "assistant" && !inlineWidgets[index]) { + const widgetUri = extractWidgetUri(message.content); + if (widgetUri) { + loadWidgetFromUri(widgetUri, index); + } + } + }); + }, [messages]); + + // Auto-detect and load widgets from streaming message + useEffect(() => { + if (streamingMessage && streamingMessage.content) { + const widgetUri = extractWidgetUri(streamingMessage.content); + if (widgetUri && !streamingWidgetHtml) { + // Load widget for streaming message + loadWidgetFromUri(widgetUri, -1).then((html) => { + if (html) { + setStreamingWidgetHtml(html); + } + }); + } + } else { + // Clear streaming widget when streaming stops + setStreamingWidgetHtml(null); + } + }, [streamingMessage]); + const getCursorPosition = (textarea: HTMLTextAreaElement) => { // Create a hidden div with the same styles as the textarea const div = document.createElement("div"); @@ -2042,6 +2184,101 @@ function ChatPage() { } }; + const handleOpenWidgetModal = () => { + setWidgetError(null); + setWidgetHtml(null); + setSelectedWidgetId(null); + setIsWidgetModalOpen(true); + }; + + const handleLoadWidgetPreview = async (widgetId: string) => { + const widget = widgets.find(item => item.widget_id === widgetId); + if (!widget) { + setWidgetError("Selected widget not found"); + return; + } + const templateUri = widget.mcp?.template_uri; + if (!templateUri) { + setWidgetError("Widget is missing MCP template metadata"); + return; + } + + setWidgetError(null); + setSelectedWidgetId(widgetId); + setIsWidgetLoading(true); + + try { + const response = await fetch("/api/mcp/widgets/mcp", { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: `read-${widgetId}`, + method: "resources/read", + params: { uri: templateUri }, + }), + }); + + const contentType = response.headers.get("content-type") || ""; + const rawBody = await response.text(); + + if (!response.ok) { + throw new Error( + `Server responded with ${response.status}${rawBody ? `: ${rawBody}` : ""}` + ); + } + + let data: any = null; + if (contentType.includes("text/event-stream")) { + const blocks = rawBody.split(/\n\n/); + let lastData: string | null = null; + for (const block of blocks) { + const dataLines = block + .split("\n") + .filter(line => line.startsWith("data:")) + .map(line => line.slice(5).trim()) + .filter(Boolean); + if (dataLines.length > 0) { + lastData = dataLines.join("\n"); + } + } + if (!lastData) { + throw new Error("No data payload in event stream response"); + } + try { + data = JSON.parse(lastData); + } catch (error) { + throw new Error("Failed to parse event stream data as JSON"); + } + } else if (rawBody) { + try { + data = JSON.parse(rawBody); + } catch (error) { + throw new Error("Failed to parse JSON response from widget endpoint"); + } + } + + const contents = data?.result?.contents; + const html = contents?.[0]?.text; + if (!html) { + throw new Error("Widget resource response did not include HTML content"); + } + + setWidgetHtml(html); + } catch (error: any) { + console.error("Failed to load widget resource", error); + setWidgetError( + error?.message || "Failed to load widget resource. Please try again." + ); + setWidgetHtml(null); + } finally { + setIsWidgetLoading(false); + } + }; + return (
{/* Debug header - only show in debug mode */} @@ -2155,6 +2392,18 @@ function ChatPage() { index )} + {/* Inline widget display */} + {inlineWidgets[index] && ( +
+