Skip to content
Draft
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
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=

Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,5 @@ wheels/
config/

.docling.pid

widgets/
6 changes: 6 additions & 0 deletions frontend/components/markdown-renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ const preprocessChatMessage = (text: string): string => {
.replace(/<think>/g, "`<think>`")
.replace(/<\/think>/g, "`</think>`");

// 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);
Expand Down
198 changes: 198 additions & 0 deletions frontend/components/navigation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 {
Expand All @@ -94,7 +131,10 @@ export function Navigation({
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [conversationToDelete, setConversationToDelete] =
useState<ChatConversation | null>(null);
const [renamingWidgetId, setRenamingWidgetId] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState("");
const fileInputRef = useRef<HTMLInputElement>(null);
const renameInputRef = useRef<HTMLInputElement>(null);

const { selectedFilter, setSelectedFilter } = useKnowledgeFilter();

Expand Down Expand Up @@ -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({
Expand All @@ -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,
Expand All @@ -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(() => {
Expand Down Expand Up @@ -371,6 +446,129 @@ export function Navigation({
/>
)}

{/* Widgets Page Specific Sections */}
{isOnWidgetsPage && (
<div className="flex-1 min-h-0 flex flex-col px-4">
<div className="flex-shrink-0">
<div className="flex items-center justify-between mb-3 mx-3">
<h3 className="text-xs font-medium text-muted-foreground">
Widgets
</h3>
<button
type="button"
className="p-1 hover:bg-accent rounded"
onClick={onNewWidget}
title="Create new widget"
>
<Plus className="h-4 w-4 text-muted-foreground" />
</button>
</div>
</div>

<div className="flex-1 min-h-0 flex flex-col">
<div className="flex-shrink-0 overflow-y-auto scrollbar-hide space-y-1 max-h-full">
{widgets.length === 0 ? (
<div className="text-[13px] text-muted-foreground py-2 pl-3">
No widgets yet
</div>
) : (
widgets.map(widget => (
<div
key={widget.widget_id}
className={`w-full px-3 h-11 rounded-lg group relative ${
selectedWidget === widget.widget_id ? "bg-accent" : ""
}`}
>
{renamingWidgetId === widget.widget_id ? (
<div className="flex items-center h-full">
<input
ref={renameInputRef}
type="text"
value={renameValue}
onChange={e => 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()}
/>
</div>
) : (
<button
type="button"
className="w-full h-full text-left hover:bg-accent cursor-pointer rounded-lg"
onClick={() => onWidgetSelect?.(widget.widget_id)}
>
<div className="flex items-center justify-between h-full">
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-foreground truncate">
{widget.title || (widget.prompt.substring(0, 50) + (widget.prompt.length > 50 ? "..." : ""))}
</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<div
className="opacity-0 group-hover:opacity-100 data-[state=open]:opacity-100 data-[state=open]:text-foreground transition-opacity p-1 hover:bg-accent rounded text-muted-foreground hover:text-foreground ml-2 flex-shrink-0 cursor-pointer"
title="More options"
role="button"
tabIndex={0}
onClick={e => {
e.stopPropagation();
}}
onKeyDown={e => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
e.stopPropagation();
}
}}
>
<EllipsisVertical className="h-4 w-4" />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent
side="bottom"
align="end"
className="w-48"
onClick={e => e.stopPropagation()}
>
<DropdownMenuItem
onClick={e => {
e.stopPropagation();
handleStartRename(widget, e);
}}
className="cursor-pointer"
>
<Pencil className="mr-2 h-4 w-4" />
Rename widget
</DropdownMenuItem>
<DropdownMenuItem
onClick={e => {
e.stopPropagation();
onDeleteWidget?.(widget.widget_id);
}}
className="cursor-pointer text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
Delete widget
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</button>
)}
</div>
))
)}
</div>
</div>
</div>
)}

{/* Chat Page Specific Sections */}
{isOnChatPage && (
<div className="flex-1 min-h-0 flex flex-col px-4">
Expand Down
14 changes: 14 additions & 0 deletions frontend/next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
20 changes: 14 additions & 6 deletions frontend/src/app/api/[...path]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -129,4 +129,12 @@ async function proxyRequest(
{ status: 500 }
);
}
}
}

function parseBoolean(value?: string | null): boolean {
if (!value) {
return false;
}
const normalized = value.toLowerCase();
return normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on';
}
Loading