Merge branch 'main' into feat-sharepoint-auth

This commit is contained in:
Eric Hare 2025-09-18 12:54:02 -07:00 committed by GitHub
commit 8c3b3cf1bb
39 changed files with 2391 additions and 1612 deletions

View file

@ -75,6 +75,14 @@ infra:
@echo " OpenSearch: http://localhost:9200" @echo " OpenSearch: http://localhost:9200"
@echo " Dashboards: http://localhost:5601" @echo " Dashboards: http://localhost:5601"
infra-cpu:
@echo "🔧 Starting infrastructure services only..."
docker-compose -f docker-compose-cpu.yml up -d opensearch dashboards langflow
@echo "✅ Infrastructure services started!"
@echo " Langflow: http://localhost:7860"
@echo " OpenSearch: http://localhost:9200"
@echo " Dashboards: http://localhost:5601"
# Container management # Container management
stop: stop:
@echo "🛑 Stopping all containers..." @echo "🛑 Stopping all containers..."

View file

@ -62,8 +62,7 @@ LANGFLOW_CHAT_FLOW_ID=your_chat_flow_id
LANGFLOW_INGEST_FLOW_ID=your_ingest_flow_id LANGFLOW_INGEST_FLOW_ID=your_ingest_flow_id
NUDGES_FLOW_ID=your_nudges_flow_id NUDGES_FLOW_ID=your_nudges_flow_id
``` ```
ee extended configuration, including ingestion and optional variables: [docs/configuration.md](docs/ See extended configuration, including ingestion and optional variables: [docs/configuration.md](docs/configuration.md)
configuration.md)
### 3. Start OpenRAG ### 3. Start OpenRAG
```bash ```bash

View file

@ -91,7 +91,7 @@ services:
langflow: langflow:
volumes: volumes:
- ./flows:/app/flows:Z - ./flows:/app/flows:Z
image: phact/langflow:${LANGFLOW_VERSION:-responses} image: phact/openrag-langflow:${LANGFLOW_VERSION:-latest}
container_name: langflow container_name: langflow
ports: ports:
- "7860:7860" - "7860:7860"

View file

@ -91,7 +91,7 @@ services:
langflow: langflow:
volumes: volumes:
- ./flows:/app/flows:Z - ./flows:/app/flows:Z
image: phact/langflow:${LANGFLOW_VERSION:-responses} image: phact/openrag-langflow:${LANGFLOW_VERSION:-latest}
container_name: langflow container_name: langflow
ports: ports:
- "7860:7860" - "7860:7860"

View file

@ -0,0 +1,27 @@
"use client";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { EllipsisVertical } from "lucide-react";
import { Button } from "./ui/button";
export function KnowledgeActionsDropdown() {
return (
<DropdownMenu>
<DropdownMenuTrigger>
<Button variant="ghost" className="hover:bg-transparent">
<EllipsisVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent side="right" sideOffset={-10}>
<DropdownMenuItem className="text-destructive focus:text-destructive-foreground focus:bg-destructive">
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View file

@ -62,7 +62,7 @@ export const MarkdownRenderer = ({ chatMessage }: MarkdownRendererProps) => {
<Markdown <Markdown
remarkPlugins={[remarkGfm]} remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeMathjax, rehypeRaw]} rehypePlugins={[rehypeMathjax, rehypeRaw]}
linkTarget="_blank" urlTransform={(url) => url}
components={{ components={{
p({ node, ...props }) { p({ node, ...props }) {
return <p className="w-fit max-w-full">{props.children}</p>; return <p className="w-fit max-w-full">{props.children}</p>;
@ -79,7 +79,7 @@ export const MarkdownRenderer = ({ chatMessage }: MarkdownRendererProps) => {
h3({ node, ...props }) { h3({ node, ...props }) {
return <h3 className="mb-2 mt-4">{props.children}</h3>; return <h3 className="mb-2 mt-4">{props.children}</h3>;
}, },
hr({ node, ...props }) { hr() {
return <hr className="w-full mt-4 mb-8" />; return <hr className="w-full mt-4 mb-8" />;
}, },
ul({ node, ...props }) { ul({ node, ...props }) {
@ -97,8 +97,12 @@ export const MarkdownRenderer = ({ chatMessage }: MarkdownRendererProps) => {
</div> </div>
); );
}, },
a({ node, ...props }) {
return <a {...props} target="_blank" rel="noopener noreferrer">{props.children}</a>;
},
code: ({ node, className, inline, children, ...props }) => { code(props) {
const { children, className, ...rest } = props;
let content = children as string; let content = children as string;
if ( if (
Array.isArray(children) && Array.isArray(children) &&
@ -120,14 +124,15 @@ export const MarkdownRenderer = ({ chatMessage }: MarkdownRendererProps) => {
} }
const match = /language-(\w+)/.exec(className || ""); const match = /language-(\w+)/.exec(className || "");
const isInline = !className?.startsWith("language-");
return !inline ? ( return !isInline ? (
<CodeComponent <CodeComponent
language={(match && match[1]) || ""} language={(match && match[1]) || ""}
code={String(content).replace(/\n$/, "")} code={String(content).replace(/\n$/, "")}
/> />
) : ( ) : (
<code className={className} {...props}> <code className={className} {...rest}>
{content} {content}
</code> </code>
); );

View file

@ -29,7 +29,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
/> />
<span <span
className={cn( className={cn(
"pointer-events-none absolute top-1/2 -translate-y-1/2 pl-px text-placeholder-foreground", "pointer-events-none absolute top-1/2 -translate-y-1/2 pl-px text-placeholder-foreground font-mono",
icon ? "left-9" : "left-3", icon ? "left-9" : "left-3",
props.value && "hidden", props.value && "hidden",
)} )}

File diff suppressed because it is too large Load diff

View file

@ -27,6 +27,8 @@
"@tailwindcss/forms": "^0.5.10", "@tailwindcss/forms": "^0.5.10",
"@tailwindcss/typography": "^0.5.16", "@tailwindcss/typography": "^0.5.16",
"@tanstack/react-query": "^5.86.0", "@tanstack/react-query": "^5.86.0",
"ag-grid-community": "^34.2.0",
"ag-grid-react": "^34.2.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
@ -37,11 +39,11 @@
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-icons": "^5.5.0", "react-icons": "^5.5.0",
"react-markdown": "^8.0.7", "react-markdown": "^10.1.0",
"react-syntax-highlighter": "^15.6.1", "react-syntax-highlighter": "^15.6.1",
"rehype-mathjax": "^4.0.3", "rehype-mathjax": "^7.1.0",
"rehype-raw": "^6.1.1", "rehype-raw": "^7.0.0",
"remark-gfm": "3.0.1", "remark-gfm": "^4.0.1",
"sonner": "^2.0.6", "sonner": "^2.0.6",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
@ -52,6 +54,7 @@
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"@types/react-syntax-highlighter": "^15.5.13",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "15.3.5", "eslint-config-next": "15.3.5",

View file

@ -8,7 +8,14 @@ import {
Loader2, Loader2,
Search, Search,
} from "lucide-react"; } from "lucide-react";
import { type FormEvent, useCallback, useEffect, useState } from "react"; import { AgGridReact, CustomCellRendererProps } from "ag-grid-react";
import {
type FormEvent,
useCallback,
useEffect,
useState,
useRef,
} from "react";
import { SiGoogledrive } from "react-icons/si"; import { SiGoogledrive } from "react-icons/si";
import { TbBrandOnedrive } from "react-icons/tb"; import { TbBrandOnedrive } from "react-icons/tb";
import { KnowledgeDropdown } from "@/components/knowledge-dropdown"; import { KnowledgeDropdown } from "@/components/knowledge-dropdown";
@ -18,6 +25,10 @@ import { Input } from "@/components/ui/input";
import { useKnowledgeFilter } from "@/contexts/knowledge-filter-context"; import { useKnowledgeFilter } from "@/contexts/knowledge-filter-context";
import { useTask } from "@/contexts/task-context"; import { useTask } from "@/contexts/task-context";
import { type File, useGetSearchQuery } from "../api/queries/useGetSearchQuery"; import { type File, useGetSearchQuery } from "../api/queries/useGetSearchQuery";
import { ColDef, RowClickedEvent } from "ag-grid-community";
import "@/components/AgGrid/registerAgGridModules";
import "@/components/AgGrid/agGridStyles.css";
import { KnowledgeActionsDropdown } from "@/components/knowledge-actions-dropdown";
// Function to get the appropriate icon for a connector type // Function to get the appropriate icon for a connector type
function getSourceIcon(connectorType?: string) { function getSourceIcon(connectorType?: string) {
@ -64,11 +75,94 @@ function SearchPage() {
} }
setQuery(queryInputText); setQuery(queryInputText);
}, },
[queryInputText, refetchSearch, query], [queryInputText, refetchSearch, query]
); );
const fileResults = data as File[]; const fileResults = data as File[];
const gridRef = useRef<AgGridReact>(null);
const [columnDefs] = useState<ColDef<File>[]>([
{
field: "filename",
headerName: "Source",
cellRenderer: ({ data, value }: CustomCellRendererProps<File>) => {
return (
<div className="flex items-center gap-2">
{getSourceIcon(data?.connector_type)}
<span className="font-medium text-foreground truncate">
{value}
</span>
</div>
);
},
},
{
field: "size",
headerName: "Size",
valueFormatter: (params) =>
params.value ? `${Math.round(params.value / 1024)} KB` : "-",
},
{
field: "mimetype",
headerName: "Type",
},
{
field: "owner",
headerName: "Owner",
valueFormatter: (params) =>
params.value ||
params.data?.owner_name ||
params.data?.owner_email ||
"—",
},
{
field: "chunkCount",
headerName: "Chunks",
},
{
field: "avgScore",
headerName: "Avg score",
cellRenderer: ({ value }: CustomCellRendererProps<File>) => {
return (
<span className="text-xs text-green-400 bg-green-400/20 px-2 py-1 rounded">
{value.toFixed(2)}
</span>
);
},
},
{
cellRenderer: () => {
return <KnowledgeActionsDropdown />;
},
cellStyle: {
alignItems: 'center',
display: 'flex',
justifyContent: 'center',
padding: 0,
},
colId: 'actions',
filter: false,
maxWidth: 60,
minWidth: 60,
resizable: false,
sortable: false,
initialFlex: 0,
},
]);
const defaultColDef: ColDef<File> = {
cellStyle: () => ({
display: "flex",
alignItems: "center",
}),
initialFlex: 1,
minWidth: 100,
resizable: false,
suppressMovable: true,
};
return ( return (
<div <div
className={`fixed inset-0 md:left-72 top-[53px] flex flex-col transition-all duration-300 ${ className={`fixed inset-0 md:left-72 top-[53px] flex flex-col transition-all duration-300 ${
@ -85,8 +179,12 @@ function SearchPage() {
}`} }`}
> >
<div className="flex-1 flex flex-col min-h-0 px-6 py-6"> <div className="flex-1 flex flex-col min-h-0 px-6 py-6">
<div className="flex items-center justify-between mb-6">
<h2 className="text-lg font-semibold">Project Knowledge</h2>
<KnowledgeDropdown variant="button" />
</div>
{/* Search Input Area */} {/* Search Input Area */}
<div className="flex-shrink-0 mb-6"> <div className="flex-shrink-0 mb-6 lg:max-w-[75%] xl:max-w-[50%]">
<form onSubmit={handleSearch} className="flex gap-3"> <form onSubmit={handleSearch} className="flex gap-3">
<Input <Input
name="search-query" name="search-query"
@ -100,7 +198,7 @@ function SearchPage() {
/> />
<Button <Button
type="submit" type="submit"
variant="secondary" variant="outline"
className="rounded-lg h-12 w-12 p-0 flex-shrink-0" className="rounded-lg h-12 w-12 p-0 flex-shrink-0"
> >
{isFetching ? ( {isFetching ? (
@ -109,17 +207,63 @@ function SearchPage() {
<Search className="h-4 w-4" /> <Search className="h-4 w-4" />
)} )}
</Button> </Button>
<div className="flex-shrink-0">
<KnowledgeDropdown variant="button" />
</div>
</form> </form>
</div> </div>
{selectedFile ? (
{/* Results Area */} // Show chunks for selected file
<div className="flex-1 overflow-y-auto"> <>
<div className="space-y-4"> <div className="flex items-center gap-2 mb-4">
{fileResults.length === 0 && !isFetching ? ( <Button
<div className="text-center py-12"> variant="ghost"
size="sm"
onClick={() => setSelectedFile(null)}
>
Back to files
</Button>
<span className="text-sm text-muted-foreground">
Chunks from {selectedFile}
</span>
</div>
{fileResults
.filter((file) => file.filename === selectedFile)
.flatMap((file) => file.chunks)
.map((chunk, index) => (
<div
key={chunk.filename + index}
className="bg-muted/20 rounded-lg p-4 border border-border/50"
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-blue-400" />
<span className="font-medium truncate">
{chunk.filename}
</span>
</div>
<span className="text-xs text-green-400 bg-green-400/20 px-2 py-1 rounded">
{chunk.score.toFixed(2)}
</span>
</div>
<div className="text-sm text-muted-foreground mb-2">
{chunk.mimetype} Page {chunk.page}
</div>
<p className="text-sm text-foreground/90 leading-relaxed">
{chunk.text}
</p>
</div>
))}
</>
) : (
<AgGridReact
columnDefs={columnDefs}
defaultColDef={defaultColDef}
loading={isFetching}
ref={gridRef}
rowData={fileResults}
onRowClicked={(params: RowClickedEvent<File>) => {
setSelectedFile(params.data?.filename ?? "");
}}
noRowsOverlayComponent={() => (
<div className="text-center">
<Search className="h-12 w-12 mx-auto mb-4 text-muted-foreground/50" /> <Search className="h-12 w-12 mx-auto mb-4 text-muted-foreground/50" />
<p className="text-lg text-muted-foreground"> <p className="text-lg text-muted-foreground">
No documents found No documents found
@ -128,140 +272,9 @@ function SearchPage() {
Try adjusting your search terms Try adjusting your search terms
</p> </p>
</div> </div>
) : (
<div className="space-y-4">
{/* Results Count */}
<div className="mb-4">
<div className="text-sm text-muted-foreground">
{fileResults.length} file
{fileResults.length !== 1 ? "s" : ""} found
</div>
</div>
{/* Results Display */}
<div
className={isFetching ? "opacity-50 pointer-events-none" : ""}
>
{selectedFile ? (
// Show chunks for selected file
<>
<div className="flex items-center gap-2 mb-4">
<Button
variant="ghost"
size="sm"
onClick={() => setSelectedFile(null)}
>
Back to files
</Button>
<span className="text-sm text-muted-foreground">
Chunks from {selectedFile}
</span>
</div>
{fileResults
.filter((file) => file.filename === selectedFile)
.flatMap((file) => file.chunks)
.map((chunk, index) => (
<div
key={chunk.filename + index}
className="bg-muted/20 rounded-lg p-4 border border-border/50"
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-blue-400" />
<span className="font-medium truncate">
{chunk.filename}
</span>
</div>
<span className="text-xs text-green-400 bg-green-400/20 px-2 py-1 rounded">
{chunk.score.toFixed(2)}
</span>
</div>
<div className="text-sm text-muted-foreground mb-2">
{chunk.mimetype} Page {chunk.page}
</div>
<p className="text-sm text-foreground/90 leading-relaxed">
{chunk.text}
</p>
</div>
))}
</>
) : (
// Show files table
<div className="bg-muted/20 rounded-lg border border-border/50 overflow-hidden">
<table className="w-full">
<thead>
<tr className="border-b border-border/50 bg-muted/10">
<th className="text-left p-3 text-sm font-medium text-muted-foreground">
Source
</th>
<th className="text-left p-3 text-sm font-medium text-muted-foreground">
Type
</th>
<th className="text-left p-3 text-sm font-medium text-muted-foreground">
Size
</th>
<th className="text-left p-3 text-sm font-medium text-muted-foreground">
Matching chunks
</th>
<th className="text-left p-3 text-sm font-medium text-muted-foreground">
Average score
</th>
<th className="text-left p-3 text-sm font-medium text-muted-foreground">
Owner
</th>
</tr>
</thead>
<tbody>
{fileResults.map((file) => (
<tr
key={file.filename}
className="border-b border-border/30 hover:bg-muted/20 cursor-pointer transition-colors"
onClick={() => setSelectedFile(file.filename)}
>
<td className="p-3">
<div className="flex items-center gap-2">
{getSourceIcon(file.connector_type)}
<span
className="font-medium truncate"
title={file.filename}
>
{file.filename}
</span>
</div>
</td>
<td className="p-3 text-sm text-muted-foreground">
{file.mimetype}
</td>
<td className="p-3 text-sm text-muted-foreground">
{file.size
? `${Math.round(file.size / 1024)} KB`
: "—"}
</td>
<td className="p-3 text-sm text-muted-foreground">
{file.chunkCount}
</td>
<td className="p-3">
<span className="text-xs text-green-400 bg-green-400/20 px-2 py-1 rounded">
{file.avgScore.toFixed(2)}
</span>
</td>
<td
className="p-3 text-sm text-muted-foreground"
title={file.owner_email}
>
{file.owner_name || file.owner || "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
)} )}
</div> />
</div> )}
</div> </div>
</div> </div>
); );

View file

@ -0,0 +1,21 @@
body {
--ag-text-color: hsl(var(--muted-foreground));
--ag-background-color: hsl(var(--background));
--ag-header-background-color: hsl(var(--background));
--ag-header-text-color: hsl(var(--muted-foreground));
--ag-header-column-resize-handle-color: hsl(var(--border));
--ag-header-row-border: hsl(var(--border));
--ag-header-font-weight: var(--font-medium);
--ag-row-border: undefined;
--ag-row-hover-color: hsl(var(--muted));
--ag-wrapper-border: none;
--ag-font-family: var(--font-sans);
.ag-header {
border-bottom: 1px solid hsl(var(--border));
margin-bottom: 0.5rem;
}
.ag-row {
cursor: pointer;
}
}

View file

@ -0,0 +1,33 @@
import {
ModuleRegistry,
ValidationModule,
ColumnAutoSizeModule,
ColumnApiModule,
PaginationModule,
CellStyleModule,
QuickFilterModule,
ClientSideRowModelModule,
TextFilterModule,
DateFilterModule,
EventApiModule,
GridStateModule,
} from 'ag-grid-community';
// Importing necessary modules from ag-grid-community
// https://www.ag-grid.com/javascript-data-grid/modules/#selecting-modules
ModuleRegistry.registerModules([
ColumnAutoSizeModule,
ColumnApiModule,
PaginationModule,
CellStyleModule,
QuickFilterModule,
ClientSideRowModelModule,
TextFilterModule,
DateFilterModule,
EventApiModule,
GridStateModule,
// The ValidationModule adds helpful console warnings/errors that can help identify bad configuration during development.
...(process.env.NODE_ENV !== 'production' ? [ValidationModule] : []),
]);

View file

@ -1,6 +1,6 @@
[project] [project]
name = "openrag" name = "openrag"
version = "0.1.3" version = "0.1.8"
description = "Add your description here" description = "Add your description here"
readme = "README.md" readme = "README.md"
requires-python = ">=3.13" requires-python = ">=3.13"
@ -37,6 +37,7 @@ openrag = "tui.main:run_tui"
[tool.uv] [tool.uv]
package = true package = true
[tool.uv.sources] [tool.uv.sources]
torch = [ torch = [
{ index = "pytorch-cu128", marker = "sys_platform == 'linux' and platform_machine == 'x86_64'" }, { index = "pytorch-cu128", marker = "sys_platform == 'linux' and platform_machine == 'x86_64'" },

View file

@ -18,8 +18,7 @@ async def chat_endpoint(request: Request, chat_service, session_manager):
user = request.state.user user = request.state.user
user_id = user.user_id user_id = user.user_id
# Get JWT token from auth middleware jwt_token = session_manager.get_effective_jwt_token(user_id, request.state.jwt_token)
jwt_token = request.state.jwt_token
if not prompt: if not prompt:
return JSONResponse({"error": "Prompt is required"}, status_code=400) return JSONResponse({"error": "Prompt is required"}, status_code=400)
@ -76,8 +75,7 @@ async def langflow_endpoint(request: Request, chat_service, session_manager):
user = request.state.user user = request.state.user
user_id = user.user_id user_id = user.user_id
# Get JWT token from auth middleware jwt_token = session_manager.get_effective_jwt_token(user_id, request.state.jwt_token)
jwt_token = request.state.jwt_token
if not prompt: if not prompt:
return JSONResponse({"error": "Prompt is required"}, status_code=400) return JSONResponse({"error": "Prompt is required"}, status_code=400)

View file

@ -13,8 +13,8 @@ async def list_connectors(request: Request, connector_service, session_manager):
) )
return JSONResponse({"connectors": connector_types}) return JSONResponse({"connectors": connector_types})
except Exception as e: except Exception as e:
logger.error("Error listing connectors", error=str(e)) logger.info("Error listing connectors", error=str(e))
return JSONResponse({"error": str(e)}, status_code=500) return JSONResponse({"connectors": []})
async def connector_sync(request: Request, connector_service, session_manager): async def connector_sync(request: Request, connector_service, session_manager):
@ -31,7 +31,7 @@ async def connector_sync(request: Request, connector_service, session_manager):
max_files=max_files, max_files=max_files,
) )
user = request.state.user user = request.state.user
jwt_token = request.state.jwt_token jwt_token = session_manager.get_effective_jwt_token(user.user_id, request.state.jwt_token)
# Get all active connections for this connector type and user # Get all active connections for this connector type and user
connections = await connector_service.connection_manager.list_connections( connections = await connector_service.connection_manager.list_connections(

View file

@ -26,7 +26,7 @@ async def create_knowledge_filter(
return JSONResponse({"error": "Query data is required"}, status_code=400) return JSONResponse({"error": "Query data is required"}, status_code=400)
user = request.state.user user = request.state.user
jwt_token = request.state.jwt_token jwt_token = session_manager.get_effective_jwt_token(user.user_id, request.state.jwt_token)
# Create knowledge filter document # Create knowledge filter document
filter_id = str(uuid.uuid4()) filter_id = str(uuid.uuid4())
@ -70,7 +70,7 @@ async def search_knowledge_filters(
limit = payload.get("limit", 20) limit = payload.get("limit", 20)
user = request.state.user user = request.state.user
jwt_token = request.state.jwt_token jwt_token = session_manager.get_effective_jwt_token(user.user_id, request.state.jwt_token)
result = await knowledge_filter_service.search_knowledge_filters( result = await knowledge_filter_service.search_knowledge_filters(
query, user_id=user.user_id, jwt_token=jwt_token, limit=limit query, user_id=user.user_id, jwt_token=jwt_token, limit=limit
@ -101,7 +101,7 @@ async def get_knowledge_filter(
) )
user = request.state.user user = request.state.user
jwt_token = request.state.jwt_token jwt_token = session_manager.get_effective_jwt_token(user.user_id, request.state.jwt_token)
result = await knowledge_filter_service.get_knowledge_filter( result = await knowledge_filter_service.get_knowledge_filter(
filter_id, user_id=user.user_id, jwt_token=jwt_token filter_id, user_id=user.user_id, jwt_token=jwt_token
@ -136,7 +136,7 @@ async def update_knowledge_filter(
payload = await request.json() payload = await request.json()
user = request.state.user user = request.state.user
jwt_token = request.state.jwt_token jwt_token = session_manager.get_effective_jwt_token(user.user_id, request.state.jwt_token)
# First, get the existing knowledge filter # First, get the existing knowledge filter
existing_result = await knowledge_filter_service.get_knowledge_filter( existing_result = await knowledge_filter_service.get_knowledge_filter(
@ -205,7 +205,7 @@ async def delete_knowledge_filter(
) )
user = request.state.user user = request.state.user
jwt_token = request.state.jwt_token jwt_token = session_manager.get_effective_jwt_token(user.user_id, request.state.jwt_token)
result = await knowledge_filter_service.delete_knowledge_filter( result = await knowledge_filter_service.delete_knowledge_filter(
filter_id, user_id=user.user_id, jwt_token=jwt_token filter_id, user_id=user.user_id, jwt_token=jwt_token
@ -239,7 +239,7 @@ async def subscribe_to_knowledge_filter(
payload = await request.json() payload = await request.json()
user = request.state.user user = request.state.user
jwt_token = request.state.jwt_token jwt_token = session_manager.get_effective_jwt_token(user.user_id, request.state.jwt_token)
# Get the knowledge filter to validate it exists and get its details # Get the knowledge filter to validate it exists and get its details
filter_result = await knowledge_filter_service.get_knowledge_filter( filter_result = await knowledge_filter_service.get_knowledge_filter(
@ -309,7 +309,7 @@ async def list_knowledge_filter_subscriptions(
) )
user = request.state.user user = request.state.user
jwt_token = request.state.jwt_token jwt_token = session_manager.get_effective_jwt_token(user.user_id, request.state.jwt_token)
result = await knowledge_filter_service.get_filter_subscriptions( result = await knowledge_filter_service.get_filter_subscriptions(
filter_id, user_id=user.user_id, jwt_token=jwt_token filter_id, user_id=user.user_id, jwt_token=jwt_token
@ -341,7 +341,7 @@ async def cancel_knowledge_filter_subscription(
) )
user = request.state.user user = request.state.user
jwt_token = request.state.jwt_token jwt_token = session_manager.get_effective_jwt_token(user.user_id, request.state.jwt_token)
# Get subscription details to find the monitor ID # Get subscription details to find the monitor ID
subscriptions_result = await knowledge_filter_service.get_filter_subscriptions( subscriptions_result = await knowledge_filter_service.get_filter_subscriptions(

View file

@ -9,7 +9,7 @@ async def nudges_from_kb_endpoint(request: Request, chat_service, session_manage
"""Get nudges for a user""" """Get nudges for a user"""
user = request.state.user user = request.state.user
user_id = user.user_id user_id = user.user_id
jwt_token = request.state.jwt_token jwt_token = session_manager.get_effective_jwt_token(user_id, request.state.jwt_token)
try: try:
result = await chat_service.langflow_nudges_chat( result = await chat_service.langflow_nudges_chat(
@ -28,7 +28,8 @@ async def nudges_from_chat_id_endpoint(request: Request, chat_service, session_m
user = request.state.user user = request.state.user
user_id = user.user_id user_id = user.user_id
chat_id = request.path_params["chat_id"] chat_id = request.path_params["chat_id"]
jwt_token = request.state.jwt_token
jwt_token = session_manager.get_effective_jwt_token(user_id, request.state.jwt_token)
try: try:
result = await chat_service.langflow_nudges_chat( result = await chat_service.langflow_nudges_chat(

View file

@ -20,8 +20,7 @@ async def search(request: Request, search_service, session_manager):
) # Optional score threshold, defaults to 0 ) # Optional score threshold, defaults to 0
user = request.state.user user = request.state.user
# Extract JWT token from auth middleware jwt_token = session_manager.get_effective_jwt_token(user.user_id, request.state.jwt_token)
jwt_token = request.state.jwt_token
logger.debug( logger.debug(
"Search API request", "Search API request",

View file

@ -11,7 +11,7 @@ async def upload(request: Request, document_service, session_manager):
form = await request.form() form = await request.form()
upload_file = form["file"] upload_file = form["file"]
user = request.state.user user = request.state.user
jwt_token = request.state.jwt_token jwt_token = session_manager.get_effective_jwt_token(user.user_id, request.state.jwt_token)
from config.settings import is_no_auth_mode from config.settings import is_no_auth_mode
@ -60,7 +60,7 @@ async def upload_path(request: Request, task_service, session_manager):
return JSONResponse({"error": "No files found in directory"}, status_code=400) return JSONResponse({"error": "No files found in directory"}, status_code=400)
user = request.state.user user = request.state.user
jwt_token = request.state.jwt_token jwt_token = session_manager.get_effective_jwt_token(user.user_id, request.state.jwt_token)
from config.settings import is_no_auth_mode from config.settings import is_no_auth_mode
@ -100,8 +100,7 @@ async def upload_context(
previous_response_id = form.get("previous_response_id") previous_response_id = form.get("previous_response_id")
endpoint = form.get("endpoint", "langflow") endpoint = form.get("endpoint", "langflow")
# Get JWT token from auth middleware jwt_token = session_manager.get_effective_jwt_token(user_id, request.state.jwt_token)
jwt_token = request.state.jwt_token
# Get user info from request state (set by auth middleware) # Get user info from request state (set by auth middleware)
user = request.state.user user = request.state.user
@ -169,7 +168,7 @@ async def upload_bucket(request: Request, task_service, session_manager):
return JSONResponse({"error": "No files found in bucket"}, status_code=400) return JSONResponse({"error": "No files found in bucket"}, status_code=400)
user = request.state.user user = request.state.user
jwt_token = request.state.jwt_token jwt_token = session_manager.get_effective_jwt_token(user.user_id, request.state.jwt_token)
from models.processors import S3FileProcessor from models.processors import S3FileProcessor
from config.settings import is_no_auth_mode from config.settings import is_no_auth_mode

View file

@ -321,7 +321,7 @@ class ConnectionManager:
return None return None
def get_available_connector_types(self) -> Dict[str, Dict[str, str]]: def get_available_connector_types(self) -> Dict[str, Dict[str, Any]]:
"""Get available connector types with their metadata""" """Get available connector types with their metadata"""
return { return {
"google_drive": { "google_drive": {

View file

@ -57,6 +57,7 @@ from config.settings import (
is_no_auth_mode, is_no_auth_mode,
) )
from services.auth_service import AuthService from services.auth_service import AuthService
from services.langflow_mcp_service import LangflowMCPService
from services.chat_service import ChatService from services.chat_service import ChatService
# Services # Services
@ -437,7 +438,11 @@ async def initialize_services():
) )
# Initialize auth service # Initialize auth service
auth_service = AuthService(session_manager, connector_service) auth_service = AuthService(
session_manager,
connector_service,
langflow_mcp_service=LangflowMCPService(),
)
# Load persisted connector connections at startup so webhooks and syncs # Load persisted connector connections at startup so webhooks and syncs
# can resolve existing subscriptions immediately after server boot # can resolve existing subscriptions immediately after server boot

View file

@ -5,9 +5,11 @@ import httpx
import aiofiles import aiofiles
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Optional from typing import Optional
import asyncio
from config.settings import WEBHOOK_BASE_URL, is_no_auth_mode from config.settings import WEBHOOK_BASE_URL, is_no_auth_mode
from session_manager import SessionManager from session_manager import SessionManager
from services.langflow_mcp_service import LangflowMCPService
from connectors.google_drive.oauth import GoogleDriveOAuth from connectors.google_drive.oauth import GoogleDriveOAuth
from connectors.onedrive.oauth import OneDriveOAuth from connectors.onedrive.oauth import OneDriveOAuth
from connectors.sharepoint.oauth import SharePointOAuth from connectors.sharepoint.oauth import SharePointOAuth
@ -17,10 +19,12 @@ from connectors.sharepoint import SharePointConnector
class AuthService: class AuthService:
def __init__(self, session_manager: SessionManager, connector_service=None): def __init__(self, session_manager: SessionManager, connector_service=None, langflow_mcp_service: LangflowMCPService | None = None):
self.session_manager = session_manager self.session_manager = session_manager
self.connector_service = connector_service self.connector_service = connector_service
self.used_auth_codes = set() # Track used authorization codes self.used_auth_codes = set() # Track used authorization codes
self.langflow_mcp_service = langflow_mcp_service
self._background_tasks = set()
async def init_oauth( async def init_oauth(
self, self,
@ -288,6 +292,20 @@ class AuthService:
token_data["access_token"] token_data["access_token"]
) )
# Best-effort: update Langflow MCP servers to include user's JWT header
try:
if self.langflow_mcp_service and isinstance(jwt_token, str) and jwt_token.strip():
# Run in background to avoid delaying login flow
task = asyncio.create_task(
self.langflow_mcp_service.update_mcp_servers_with_jwt(jwt_token)
)
# Keep reference until done to avoid premature GC
self._background_tasks.add(task)
task.add_done_callback(self._background_tasks.discard)
except Exception:
# Do not block login on MCP update issues
pass
response_data = { response_data = {
"status": "authenticated", "status": "authenticated",
"purpose": "app_auth", "purpose": "app_auth",

View file

@ -0,0 +1,147 @@
from typing import List, Dict, Any
from config.settings import clients
from utils.logging_config import get_logger
logger = get_logger(__name__)
class LangflowMCPService:
async def list_mcp_servers(self) -> List[Dict[str, Any]]:
"""Fetch list of MCP servers from Langflow (v2 API)."""
try:
response = await clients.langflow_request(
method="GET",
endpoint="/api/v2/mcp/servers",
params={"action_count": "false"},
)
response.raise_for_status()
data = response.json()
if isinstance(data, list):
return data
logger.warning("Unexpected response format for MCP servers list", data_type=type(data).__name__)
return []
except Exception as e:
logger.error("Failed to list MCP servers", error=str(e))
return []
async def get_mcp_server(self, server_name: str) -> Dict[str, Any]:
"""Get MCP server configuration by name."""
response = await clients.langflow_request(
method="GET",
endpoint=f"/api/v2/mcp/servers/{server_name}",
)
response.raise_for_status()
return response.json()
def _upsert_jwt_header_in_args(self, args: List[str], jwt_token: str) -> List[str]:
"""Ensure args contains a header triplet for X-Langflow-Global-Var-JWT with the provided JWT.
Args are expected in the pattern: [..., "--headers", key, value, ...].
If the header exists, update its value; otherwise append the triplet at the end.
"""
if not isinstance(args, list):
return [
"mcp-proxy",
"--headers",
"X-Langflow-Global-Var-JWT",
jwt_token,
]
updated_args = list(args)
i = 0
found_index = -1
while i < len(updated_args):
token = updated_args[i]
if token == "--headers" and i + 2 < len(updated_args):
header_key = updated_args[i + 1]
if isinstance(header_key, str) and header_key.lower() == "x-langflow-global-var-jwt".lower():
found_index = i
break
i += 3
continue
i += 1
if found_index >= 0:
# Replace existing value at found_index + 2
if found_index + 2 < len(updated_args):
updated_args[found_index + 2] = jwt_token
else:
# Malformed existing header triplet; make sure to append a value
updated_args.append(jwt_token)
else:
updated_args.extend([
"--headers",
"X-Langflow-Global-Var-JWT",
jwt_token,
])
return updated_args
async def patch_mcp_server_args_with_jwt(self, server_name: str, jwt_token: str) -> bool:
"""Patch a single MCP server to include/update the JWT header in args."""
try:
current = await self.get_mcp_server(server_name)
command = current.get("command")
args = current.get("args", [])
updated_args = self._upsert_jwt_header_in_args(args, jwt_token)
payload = {"command": command, "args": updated_args}
response = await clients.langflow_request(
method="PATCH",
endpoint=f"/api/v2/mcp/servers/{server_name}",
json=payload,
)
if response.status_code in (200, 201):
logger.info(
"Patched MCP server with JWT header",
server_name=server_name,
args_len=len(updated_args),
)
return True
else:
logger.warning(
"Failed to patch MCP server",
server_name=server_name,
status_code=response.status_code,
body=response.text,
)
return False
except Exception as e:
logger.error(
"Exception while patching MCP server",
server_name=server_name,
error=str(e),
)
return False
async def update_mcp_servers_with_jwt(self, jwt_token: str) -> Dict[str, Any]:
"""Fetch all MCP servers and ensure each includes the JWT header in args.
Returns a summary dict with counts.
"""
servers = await self.list_mcp_servers()
if not servers:
return {"updated": 0, "failed": 0, "total": 0}
updated = 0
failed = 0
for server in servers:
name = server.get("name") or server.get("server") or server.get("id")
if not name:
continue
ok = await self.patch_mcp_server_args_with_jwt(name, jwt_token)
if ok:
updated += 1
else:
failed += 1
summary = {"updated": updated, "failed": failed, "total": len(servers)}
if failed == 0:
logger.info("MCP servers updated with JWT header", **summary)
else:
logger.warning("MCP servers update had failures", **summary)
return summary

View file

@ -85,6 +85,8 @@ class TaskService:
async def create_custom_task(self, user_id: str, items: list, processor) -> str: async def create_custom_task(self, user_id: str, items: list, processor) -> str:
"""Create a new task with custom processor for any type of items""" """Create a new task with custom processor for any type of items"""
# Store anonymous tasks under a stable key so they can be retrieved later
store_user_id = user_id or AnonymousUser().user_id
task_id = str(uuid.uuid4()) task_id = str(uuid.uuid4())
upload_task = UploadTask( upload_task = UploadTask(
task_id=task_id, task_id=task_id,
@ -95,12 +97,14 @@ class TaskService:
# Attach the custom processor to the task # Attach the custom processor to the task
upload_task.processor = processor upload_task.processor = processor
if user_id not in self.task_store: if store_user_id not in self.task_store:
self.task_store[user_id] = {} self.task_store[store_user_id] = {}
self.task_store[user_id][task_id] = upload_task self.task_store[store_user_id][task_id] = upload_task
# Start background processing # Start background processing
background_task = asyncio.create_task(self.background_custom_processor(user_id, task_id, items)) background_task = asyncio.create_task(
self.background_custom_processor(store_user_id, task_id, items)
)
self.background_tasks.add(background_task) self.background_tasks.add(background_task)
background_task.add_done_callback(self.background_tasks.discard) background_task.add_done_callback(self.background_tasks.discard)

View file

@ -191,26 +191,8 @@ class SessionManager:
def get_user_opensearch_client(self, user_id: str, jwt_token: str): def get_user_opensearch_client(self, user_id: str, jwt_token: str):
"""Get or create OpenSearch client for user with their JWT""" """Get or create OpenSearch client for user with their JWT"""
from config.settings import is_no_auth_mode # Get the effective JWT token (handles anonymous JWT creation)
jwt_token = self.get_effective_jwt_token(user_id, jwt_token)
logger.debug(
"get_user_opensearch_client",
user_id=user_id,
jwt_token_present=(jwt_token is not None),
no_auth_mode=is_no_auth_mode(),
)
# In no-auth mode, create anonymous JWT for OpenSearch DLS
if jwt_token is None and (is_no_auth_mode() or user_id in (None, AnonymousUser().user_id)):
if not hasattr(self, "_anonymous_jwt"):
# Create anonymous JWT token for OpenSearch OIDC
logger.debug("Creating anonymous JWT")
self._anonymous_jwt = self._create_anonymous_jwt()
logger.debug(
"Anonymous JWT created", jwt_prefix=self._anonymous_jwt[:50]
)
jwt_token = self._anonymous_jwt
logger.debug("Using anonymous JWT for OpenSearch")
# Check if we have a cached client for this user # Check if we have a cached client for this user
if user_id not in self.user_opensearch_clients: if user_id not in self.user_opensearch_clients:
@ -222,6 +204,31 @@ class SessionManager:
return self.user_opensearch_clients[user_id] return self.user_opensearch_clients[user_id]
def get_effective_jwt_token(self, user_id: str, jwt_token: str) -> str:
"""Get the effective JWT token, creating anonymous JWT if needed in no-auth mode"""
from config.settings import is_no_auth_mode
logger.debug(
"get_effective_jwt_token",
user_id=user_id,
jwt_token_present=(jwt_token is not None),
no_auth_mode=is_no_auth_mode(),
)
# In no-auth mode, create anonymous JWT if needed
if jwt_token is None and (is_no_auth_mode() or user_id in (None, AnonymousUser().user_id)):
if not hasattr(self, "_anonymous_jwt"):
# Create anonymous JWT token for OpenSearch OIDC
logger.debug("Creating anonymous JWT")
self._anonymous_jwt = self._create_anonymous_jwt()
logger.debug(
"Anonymous JWT created", jwt_prefix=self._anonymous_jwt[:50]
)
jwt_token = self._anonymous_jwt
logger.debug("Using anonymous JWT")
return jwt_token
def _create_anonymous_jwt(self) -> str: def _create_anonymous_jwt(self) -> str:
"""Create JWT token for anonymous user in no-auth mode""" """Create JWT token for anonymous user in no-auth mode"""
anonymous_user = AnonymousUser() anonymous_user = AnonymousUser()

View file

@ -0,0 +1,111 @@
services:
opensearch:
image: phact/openrag-opensearch:${OPENRAG_VERSION:-latest}
#build:
# context: .
# dockerfile: Dockerfile
container_name: os
depends_on:
- openrag-backend
environment:
- discovery.type=single-node
- OPENSEARCH_INITIAL_ADMIN_PASSWORD=${OPENSEARCH_PASSWORD}
# Run security setup in background after OpenSearch starts
command: >
bash -c "
# Start OpenSearch in background
/usr/share/opensearch/opensearch-docker-entrypoint.sh opensearch &
# Wait a bit for OpenSearch to start, then apply security config
sleep 10 && /usr/share/opensearch/setup-security.sh &
# Wait for background processes
wait
"
ports:
- "9200:9200"
- "9600:9600"
dashboards:
image: opensearchproject/opensearch-dashboards:3.0.0
container_name: osdash
depends_on:
- opensearch
environment:
OPENSEARCH_HOSTS: '["https://opensearch:9200"]'
OPENSEARCH_USERNAME: "admin"
OPENSEARCH_PASSWORD: ${OPENSEARCH_PASSWORD}
ports:
- "5601:5601"
openrag-backend:
image: phact/openrag-backend:${OPENRAG_VERSION:-latest}
#build:
#context: .
#dockerfile: Dockerfile.backend
container_name: openrag-backend
depends_on:
- langflow
environment:
- OPENSEARCH_HOST=opensearch
- LANGFLOW_URL=http://langflow:7860
- LANGFLOW_PUBLIC_URL=${LANGFLOW_PUBLIC_URL}
- LANGFLOW_SECRET_KEY=${LANGFLOW_SECRET_KEY}
- LANGFLOW_SUPERUSER=${LANGFLOW_SUPERUSER}
- LANGFLOW_SUPERUSER_PASSWORD=${LANGFLOW_SUPERUSER_PASSWORD}
- LANGFLOW_CHAT_FLOW_ID=${LANGFLOW_CHAT_FLOW_ID}
- LANGFLOW_INGEST_FLOW_ID=${LANGFLOW_INGEST_FLOW_ID}
- DISABLE_INGEST_WITH_LANGFLOW=${DISABLE_INGEST_WITH_LANGFLOW:-false}
- NUDGES_FLOW_ID=${NUDGES_FLOW_ID}
- OPENSEARCH_PORT=9200
- OPENSEARCH_USERNAME=admin
- OPENSEARCH_PASSWORD=${OPENSEARCH_PASSWORD}
- OPENAI_API_KEY=${OPENAI_API_KEY}
- NVIDIA_DRIVER_CAPABILITIES=compute,utility
- NVIDIA_VISIBLE_DEVICES=all
- GOOGLE_OAUTH_CLIENT_ID=${GOOGLE_OAUTH_CLIENT_ID}
- GOOGLE_OAUTH_CLIENT_SECRET=${GOOGLE_OAUTH_CLIENT_SECRET}
- MICROSOFT_GRAPH_OAUTH_CLIENT_ID=${MICROSOFT_GRAPH_OAUTH_CLIENT_ID}
- MICROSOFT_GRAPH_OAUTH_CLIENT_SECRET=${MICROSOFT_GRAPH_OAUTH_CLIENT_SECRET}
- WEBHOOK_BASE_URL=${WEBHOOK_BASE_URL}
- AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID}
- AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY}
volumes:
- ./documents:/app/documents:Z
- ./keys:/app/keys:Z
- ./flows:/app/flows:Z
openrag-frontend:
image: phact/openrag-frontend:${OPENRAG_VERSION:-latest}
#build:
#context: .
#dockerfile: Dockerfile.frontend
container_name: openrag-frontend
depends_on:
- openrag-backend
environment:
- OPENRAG_BACKEND_HOST=openrag-backend
ports:
- "3000:3000"
langflow:
volumes:
- ./flows:/app/flows:Z
image: phact/openrag-langflow:${LANGFLOW_VERSION:-latest}
container_name: langflow
ports:
- "7860:7860"
environment:
- OPENAI_API_KEY=${OPENAI_API_KEY}
- LANGFLOW_LOAD_FLOWS_PATH=/app/flows
- LANGFLOW_SECRET_KEY=${LANGFLOW_SECRET_KEY}
- JWT="dummy"
- OPENRAG-QUERY-FILTER="{}"
- OPENSEARCH_PASSWORD=${OPENSEARCH_PASSWORD}
- LANGFLOW_VARIABLES_TO_GET_FROM_ENVIRONMENT=JWT,OPENRAG-QUERY-FILTER,OPENSEARCH_PASSWORD
- LANGFLOW_LOG_LEVEL=DEBUG
- LANGFLOW_AUTO_LOGIN=${LANGFLOW_AUTO_LOGIN}
- LANGFLOW_SUPERUSER=${LANGFLOW_SUPERUSER}
- LANGFLOW_SUPERUSER_PASSWORD=${LANGFLOW_SUPERUSER_PASSWORD}
- LANGFLOW_NEW_USER_IS_ACTIVE=${LANGFLOW_NEW_USER_IS_ACTIVE}
- LANGFLOW_ENABLE_SUPERUSER_CLI=${LANGFLOW_ENABLE_SUPERUSER_CLI}

View file

@ -0,0 +1,111 @@
services:
opensearch:
image: phact/openrag-opensearch:${OPENRAG_VERSION:-latest}
#build:
#context: .
#dockerfile: Dockerfile
container_name: os
depends_on:
- openrag-backend
environment:
- discovery.type=single-node
- OPENSEARCH_INITIAL_ADMIN_PASSWORD=${OPENSEARCH_PASSWORD}
# Run security setup in background after OpenSearch starts
command: >
bash -c "
# Start OpenSearch in background
/usr/share/opensearch/opensearch-docker-entrypoint.sh opensearch &
# Wait a bit for OpenSearch to start, then apply security config
sleep 10 && /usr/share/opensearch/setup-security.sh &
# Wait for background processes
wait
"
ports:
- "9200:9200"
- "9600:9600"
dashboards:
image: opensearchproject/opensearch-dashboards:3.0.0
container_name: osdash
depends_on:
- opensearch
environment:
OPENSEARCH_HOSTS: '["https://opensearch:9200"]'
OPENSEARCH_USERNAME: "admin"
OPENSEARCH_PASSWORD: ${OPENSEARCH_PASSWORD}
ports:
- "5601:5601"
openrag-backend:
image: phact/openrag-backend:${OPENRAG_VERSION:-latest}
#build:
#context: .
#dockerfile: Dockerfile.backend
container_name: openrag-backend
depends_on:
- langflow
environment:
- OPENSEARCH_HOST=opensearch
- LANGFLOW_URL=http://langflow:7860
- LANGFLOW_PUBLIC_URL=${LANGFLOW_PUBLIC_URL}
- LANGFLOW_SUPERUSER=${LANGFLOW_SUPERUSER}
- LANGFLOW_SUPERUSER_PASSWORD=${LANGFLOW_SUPERUSER_PASSWORD}
- LANGFLOW_CHAT_FLOW_ID=${LANGFLOW_CHAT_FLOW_ID}
- LANGFLOW_INGEST_FLOW_ID=${LANGFLOW_INGEST_FLOW_ID}
- DISABLE_INGEST_WITH_LANGFLOW=${DISABLE_INGEST_WITH_LANGFLOW:-false}
- NUDGES_FLOW_ID=${NUDGES_FLOW_ID}
- OPENSEARCH_PORT=9200
- OPENSEARCH_USERNAME=admin
- OPENSEARCH_PASSWORD=${OPENSEARCH_PASSWORD}
- OPENAI_API_KEY=${OPENAI_API_KEY}
- NVIDIA_DRIVER_CAPABILITIES=compute,utility
- NVIDIA_VISIBLE_DEVICES=all
- GOOGLE_OAUTH_CLIENT_ID=${GOOGLE_OAUTH_CLIENT_ID}
- GOOGLE_OAUTH_CLIENT_SECRET=${GOOGLE_OAUTH_CLIENT_SECRET}
- MICROSOFT_GRAPH_OAUTH_CLIENT_ID=${MICROSOFT_GRAPH_OAUTH_CLIENT_ID}
- MICROSOFT_GRAPH_OAUTH_CLIENT_SECRET=${MICROSOFT_GRAPH_OAUTH_CLIENT_SECRET}
- WEBHOOK_BASE_URL=${WEBHOOK_BASE_URL}
- AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID}
- AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY}
volumes:
- ./documents:/app/documents:Z
- ./keys:/app/keys:Z
- ./flows:/app/flows:Z
gpus: all
openrag-frontend:
image: phact/openrag-frontend:${OPENRAG_VERSION:-latest}
#build:
#context: .
#dockerfile: Dockerfile.frontend
container_name: openrag-frontend
depends_on:
- openrag-backend
environment:
- OPENRAG_BACKEND_HOST=openrag-backend
ports:
- "3000:3000"
langflow:
volumes:
- ./flows:/app/flows:Z
image: phact/openrag-langflow:${LANGFLOW_VERSION:-latest}
container_name: langflow
ports:
- "7860:7860"
environment:
- OPENAI_API_KEY=${OPENAI_API_KEY}
- LANGFLOW_LOAD_FLOWS_PATH=/app/flows
- LANGFLOW_SECRET_KEY=${LANGFLOW_SECRET_KEY}
- JWT="dummy"
- OPENRAG-QUERY-FILTER="{}"
- OPENSEARCH_PASSWORD=${OPENSEARCH_PASSWORD}
- LANGFLOW_VARIABLES_TO_GET_FROM_ENVIRONMENT=JWT,OPENRAG-QUERY-FILTER,OPENSEARCH_PASSWORD
- LANGFLOW_LOG_LEVEL=DEBUG
- LANGFLOW_AUTO_LOGIN=${LANGFLOW_AUTO_LOGIN}
- LANGFLOW_SUPERUSER=${LANGFLOW_SUPERUSER}
- LANGFLOW_SUPERUSER_PASSWORD=${LANGFLOW_SUPERUSER_PASSWORD}
- LANGFLOW_NEW_USER_IS_ACTIVE=${LANGFLOW_NEW_USER_IS_ACTIVE}
- LANGFLOW_ENABLE_SUPERUSER_CLI=${LANGFLOW_ENABLE_SUPERUSER_CLI}

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -4,6 +4,10 @@ import sys
from pathlib import Path from pathlib import Path
from textual.app import App, ComposeResult from textual.app import App, ComposeResult
from utils.logging_config import get_logger from utils.logging_config import get_logger
try:
from importlib.resources import files
except ImportError:
from importlib_resources import files
logger = get_logger(__name__) logger = get_logger(__name__)
@ -301,10 +305,42 @@ class OpenRAGTUI(App):
return True, "Runtime requirements satisfied" return True, "Runtime requirements satisfied"
def copy_sample_documents():
"""Copy sample documents from package to current directory if they don't exist."""
documents_dir = Path("documents")
# Check if documents directory already exists and has files
if documents_dir.exists() and any(documents_dir.glob("*.pdf")):
return # Documents already exist, don't overwrite
try:
# Get sample documents from package assets
assets_files = files("tui._assets.documents")
# Create documents directory if it doesn't exist
documents_dir.mkdir(exist_ok=True)
# Copy each sample document
for resource in assets_files.iterdir():
if resource.is_file() and resource.name.endswith('.pdf'):
dest_path = documents_dir / resource.name
if not dest_path.exists():
content = resource.read_bytes()
dest_path.write_bytes(content)
logger.info(f"Copied sample document: {resource.name}")
except Exception as e:
logger.debug(f"Could not copy sample documents: {e}")
# This is not a critical error - the app can work without sample documents
def run_tui(): def run_tui():
"""Run the OpenRAG TUI application.""" """Run the OpenRAG TUI application."""
app = None app = None
try: try:
# Copy sample documents on first run
copy_sample_documents()
app = OpenRAGTUI() app = OpenRAGTUI()
app.run() app.run()
except KeyboardInterrupt: except KeyboardInterrupt:

View file

@ -9,6 +9,10 @@ from enum import Enum
from pathlib import Path from pathlib import Path
from typing import Dict, List, Optional, AsyncIterator from typing import Dict, List, Optional, AsyncIterator
from utils.logging_config import get_logger from utils.logging_config import get_logger
try:
from importlib.resources import files
except ImportError:
from importlib_resources import files
logger = get_logger(__name__) logger = get_logger(__name__)
@ -51,8 +55,8 @@ class ContainerManager:
def __init__(self, compose_file: Optional[Path] = None): def __init__(self, compose_file: Optional[Path] = None):
self.platform_detector = PlatformDetector() self.platform_detector = PlatformDetector()
self.runtime_info = self.platform_detector.detect_runtime() self.runtime_info = self.platform_detector.detect_runtime()
self.compose_file = compose_file or Path("docker-compose.yml") self.compose_file = compose_file or self._find_compose_file("docker-compose.yml")
self.cpu_compose_file = Path("docker-compose-cpu.yml") self.cpu_compose_file = self._find_compose_file("docker-compose-cpu.yml")
self.services_cache: Dict[str, ServiceInfo] = {} self.services_cache: Dict[str, ServiceInfo] = {}
self.last_status_update = 0 self.last_status_update = 0
# Auto-select CPU compose if no GPU available # Auto-select CPU compose if no GPU available
@ -80,6 +84,42 @@ class ContainerManager:
"langflow": "langflow", "langflow": "langflow",
} }
def _find_compose_file(self, filename: str) -> Path:
"""Find compose file in current directory or package resources."""
# First check current working directory
cwd_path = Path(filename)
self._compose_search_log = f"Searching for {filename}:\n"
self._compose_search_log += f" 1. Current directory: {cwd_path.absolute()}"
if cwd_path.exists():
self._compose_search_log += " ✓ FOUND"
return cwd_path
else:
self._compose_search_log += " ✗ NOT FOUND"
# Then check package resources
self._compose_search_log += f"\n 2. Package resources: "
try:
pkg_files = files("tui._assets")
self._compose_search_log += f"{pkg_files}"
compose_resource = pkg_files / filename
if compose_resource.is_file():
self._compose_search_log += f" ✓ FOUND, copying to current directory"
# Copy to cwd for compose command to work
content = compose_resource.read_text()
cwd_path.write_text(content)
return cwd_path
else:
self._compose_search_log += f" ✗ NOT FOUND"
except Exception as e:
self._compose_search_log += f" ✗ SKIPPED ({e})"
# Don't log this as an error since it's expected when running from source
# Fall back to original path (will fail later if not found)
self._compose_search_log += f"\n 3. Falling back to: {cwd_path.absolute()}"
return Path(filename)
def is_available(self) -> bool: def is_available(self) -> bool:
"""Check if container runtime is available.""" """Check if container runtime is available."""
return self.runtime_info.runtime_type != RuntimeType.NONE return self.runtime_info.runtime_type != RuntimeType.NONE
@ -144,14 +184,15 @@ class ContainerManager:
) )
# Simple approach: read line by line and yield each one # Simple approach: read line by line and yield each one
while True: if process.stdout:
line = await process.stdout.readline() while True:
if not line: line = await process.stdout.readline()
break if not line:
break
line_text = line.decode().rstrip() line_text = line.decode(errors="ignore").rstrip()
if line_text: if line_text:
yield line_text yield line_text
# Wait for process to complete # Wait for process to complete
await process.wait() await process.wait()
@ -159,6 +200,59 @@ class ContainerManager:
except Exception as e: except Exception as e:
yield f"Command execution failed: {e}" yield f"Command execution failed: {e}"
async def _stream_compose_command(
self,
args: List[str],
success_flag: Dict[str, bool],
cpu_mode: Optional[bool] = None,
) -> AsyncIterator[str]:
"""Run compose command with live output and record success/failure."""
if not self.is_available():
success_flag["value"] = False
yield "No container runtime available"
return
if cpu_mode is None:
cpu_mode = self.use_cpu_compose
compose_file = self.cpu_compose_file if cpu_mode else self.compose_file
cmd = self.runtime_info.compose_command + ["-f", str(compose_file)] + args
try:
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
cwd=Path.cwd(),
)
except Exception as e:
success_flag["value"] = False
yield f"Command execution failed: {e}"
return
success_flag["value"] = True
if process.stdout:
while True:
line = await process.stdout.readline()
if not line:
break
line_text = line.decode(errors="ignore")
# Compose often uses carriage returns for progress bars; normalise them
for chunk in line_text.replace("\r", "\n").split("\n"):
chunk = chunk.strip()
if not chunk:
continue
yield chunk
lowered = chunk.lower()
if "error" in lowered or "failed" in lowered:
success_flag["value"] = False
returncode = await process.wait()
if returncode != 0:
success_flag["value"] = False
yield f"Command exited with status {returncode}"
async def _run_runtime_command(self, args: List[str]) -> tuple[bool, str, str]: async def _run_runtime_command(self, args: List[str]) -> tuple[bool, str, str]:
"""Run a runtime command (docker/podman) and return (success, stdout, stderr).""" """Run a runtime command (docker/podman) and return (success, stdout, stderr)."""
if not self.is_available(): if not self.is_available():
@ -408,19 +502,56 @@ class ContainerManager:
return results return results
async def start_services( async def start_services(
self, cpu_mode: bool = False self, cpu_mode: Optional[bool] = None
) -> AsyncIterator[tuple[bool, str]]: ) -> AsyncIterator[tuple[bool, str]]:
"""Start all services and yield progress updates.""" """Start all services and yield progress updates."""
if not self.is_available():
yield False, "No container runtime available"
return
# Diagnostic info about compose files
compose_file = self.cpu_compose_file if (cpu_mode if cpu_mode is not None else self.use_cpu_compose) else self.compose_file
# Show the search process for debugging
if hasattr(self, '_compose_search_log'):
for line in self._compose_search_log.split('\n'):
if line.strip():
yield False, line
yield False, f"Final compose file: {compose_file.absolute()}"
if not compose_file.exists():
yield False, f"ERROR: Compose file not found at {compose_file.absolute()}"
return
yield False, "Starting OpenRAG services..." yield False, "Starting OpenRAG services..."
success, stdout, stderr = await self._run_compose_command( missing_images: List[str] = []
["up", "-d"], cpu_mode try:
) images_info = await self.get_project_images_info()
missing_images = [image for image, digest in images_info if digest == "-"]
except Exception:
missing_images = []
if success: if missing_images:
images_list = ", ".join(missing_images)
yield False, f"Pulling container images ({images_list})..."
pull_success = {"value": True}
async for line in self._stream_compose_command(
["pull"], pull_success, cpu_mode
):
yield False, line
if not pull_success["value"]:
yield False, "Some images failed to pull; attempting to start services anyway..."
yield False, "Creating and starting containers..."
up_success = {"value": True}
async for line in self._stream_compose_command(["up", "-d"], up_success, cpu_mode):
yield False, line
if up_success["value"]:
yield True, "Services started successfully" yield True, "Services started successfully"
else: else:
yield False, f"Failed to start services: {stderr}" yield False, "Failed to start services. See output above for details."
async def stop_services(self) -> AsyncIterator[tuple[bool, str]]: async def stop_services(self) -> AsyncIterator[tuple[bool, str]]:
"""Stop all services and yield progress updates.""" """Stop all services and yield progress updates."""

View file

@ -79,6 +79,15 @@ class EnvManager:
"""Generate a secure secret key for Langflow.""" """Generate a secure secret key for Langflow."""
return secrets.token_urlsafe(32) return secrets.token_urlsafe(32)
def _quote_env_value(self, value: str) -> str:
"""Single quote all environment variable values for consistency."""
if not value:
return "''"
# Escape any existing single quotes by replacing ' with '\''
escaped_value = value.replace("'", "'\\''")
return f"'{escaped_value}'"
def load_existing_env(self) -> bool: def load_existing_env(self) -> bool:
"""Load existing .env file if it exists.""" """Load existing .env file if it exists."""
if not self.env_file.exists(): if not self.env_file.exists():
@ -237,36 +246,36 @@ class EnvManager:
# Core settings # Core settings
f.write("# Core settings\n") f.write("# Core settings\n")
f.write(f"LANGFLOW_SECRET_KEY={self.config.langflow_secret_key}\n") f.write(f"LANGFLOW_SECRET_KEY={self._quote_env_value(self.config.langflow_secret_key)}\n")
f.write(f"LANGFLOW_SUPERUSER={self.config.langflow_superuser}\n") f.write(f"LANGFLOW_SUPERUSER={self._quote_env_value(self.config.langflow_superuser)}\n")
f.write( f.write(
f"LANGFLOW_SUPERUSER_PASSWORD={self.config.langflow_superuser_password}\n" f"LANGFLOW_SUPERUSER_PASSWORD={self._quote_env_value(self.config.langflow_superuser_password)}\n"
) )
f.write(f"LANGFLOW_CHAT_FLOW_ID={self.config.langflow_chat_flow_id}\n") f.write(f"LANGFLOW_CHAT_FLOW_ID={self._quote_env_value(self.config.langflow_chat_flow_id)}\n")
f.write( f.write(
f"LANGFLOW_INGEST_FLOW_ID={self.config.langflow_ingest_flow_id}\n" f"LANGFLOW_INGEST_FLOW_ID={self._quote_env_value(self.config.langflow_ingest_flow_id)}\n"
) )
f.write(f"NUDGES_FLOW_ID={self.config.nudges_flow_id}\n") f.write(f"NUDGES_FLOW_ID={self._quote_env_value(self.config.nudges_flow_id)}\n")
f.write(f"OPENSEARCH_PASSWORD={self.config.opensearch_password}\n") f.write(f"OPENSEARCH_PASSWORD={self._quote_env_value(self.config.opensearch_password)}\n")
f.write(f"OPENAI_API_KEY={self.config.openai_api_key}\n") f.write(f"OPENAI_API_KEY={self._quote_env_value(self.config.openai_api_key)}\n")
f.write( f.write(
f"OPENRAG_DOCUMENTS_PATHS={self.config.openrag_documents_paths}\n" f"OPENRAG_DOCUMENTS_PATHS={self._quote_env_value(self.config.openrag_documents_paths)}\n"
) )
f.write("\n") f.write("\n")
# Ingestion settings # Ingestion settings
f.write("# Ingestion settings\n") f.write("# Ingestion settings\n")
f.write(f"DISABLE_INGEST_WITH_LANGFLOW={self.config.disable_ingest_with_langflow}\n") f.write(f"DISABLE_INGEST_WITH_LANGFLOW={self._quote_env_value(self.config.disable_ingest_with_langflow)}\n")
f.write("\n") f.write("\n")
# Langflow auth settings # Langflow auth settings
f.write("# Langflow auth settings\n") f.write("# Langflow auth settings\n")
f.write(f"LANGFLOW_AUTO_LOGIN={self.config.langflow_auto_login}\n") f.write(f"LANGFLOW_AUTO_LOGIN={self._quote_env_value(self.config.langflow_auto_login)}\n")
f.write( f.write(
f"LANGFLOW_NEW_USER_IS_ACTIVE={self.config.langflow_new_user_is_active}\n" f"LANGFLOW_NEW_USER_IS_ACTIVE={self._quote_env_value(self.config.langflow_new_user_is_active)}\n"
) )
f.write( f.write(
f"LANGFLOW_ENABLE_SUPERUSER_CLI={self.config.langflow_enable_superuser_cli}\n" f"LANGFLOW_ENABLE_SUPERUSER_CLI={self._quote_env_value(self.config.langflow_enable_superuser_cli)}\n"
) )
f.write("\n") f.write("\n")
@ -277,10 +286,10 @@ class EnvManager:
): ):
f.write("# Google OAuth settings\n") f.write("# Google OAuth settings\n")
f.write( f.write(
f"GOOGLE_OAUTH_CLIENT_ID={self.config.google_oauth_client_id}\n" f"GOOGLE_OAUTH_CLIENT_ID={self._quote_env_value(self.config.google_oauth_client_id)}\n"
) )
f.write( f.write(
f"GOOGLE_OAUTH_CLIENT_SECRET={self.config.google_oauth_client_secret}\n" f"GOOGLE_OAUTH_CLIENT_SECRET={self._quote_env_value(self.config.google_oauth_client_secret)}\n"
) )
f.write("\n") f.write("\n")
@ -290,10 +299,10 @@ class EnvManager:
): ):
f.write("# Microsoft Graph OAuth settings\n") f.write("# Microsoft Graph OAuth settings\n")
f.write( f.write(
f"MICROSOFT_GRAPH_OAUTH_CLIENT_ID={self.config.microsoft_graph_oauth_client_id}\n" f"MICROSOFT_GRAPH_OAUTH_CLIENT_ID={self._quote_env_value(self.config.microsoft_graph_oauth_client_id)}\n"
) )
f.write( f.write(
f"MICROSOFT_GRAPH_OAUTH_CLIENT_SECRET={self.config.microsoft_graph_oauth_client_secret}\n" f"MICROSOFT_GRAPH_OAUTH_CLIENT_SECRET={self._quote_env_value(self.config.microsoft_graph_oauth_client_secret)}\n"
) )
f.write("\n") f.write("\n")
@ -311,7 +320,7 @@ class EnvManager:
if not optional_written: if not optional_written:
f.write("# Optional settings\n") f.write("# Optional settings\n")
optional_written = True optional_written = True
f.write(f"{var_name}={var_value}\n") f.write(f"{var_name}={self._quote_env_value(var_value)}\n")
if optional_written: if optional_written:
f.write("\n") f.write("\n")

View file

@ -10,10 +10,11 @@ from typing import List, Optional
from textual.app import ComposeResult from textual.app import ComposeResult
from textual.containers import Container, Vertical, Horizontal, ScrollableContainer from textual.containers import Container, Vertical, Horizontal, ScrollableContainer
from textual.screen import Screen from textual.screen import Screen
from textual.widgets import Header, Footer, Static, Button, Label, Log from textual.widgets import Header, Footer, Static, Button, Log
from rich.text import Text from rich.text import Text
from ..managers.container_manager import ContainerManager from ..managers.container_manager import ContainerManager
from ..utils.clipboard import copy_text_to_clipboard
class DiagnosticsScreen(Screen): class DiagnosticsScreen(Screen):
@ -117,67 +118,13 @@ class DiagnosticsScreen(Screen):
content = "\n".join(str(line) for line in log.lines) content = "\n".join(str(line) for line in log.lines)
status = self.query_one("#copy-status", Static) status = self.query_one("#copy-status", Static)
# Try to use pyperclip if available success, message = copy_text_to_clipboard(content)
try: if success:
import pyperclip self.notify(message, severity="information")
status.update(f"{message}")
pyperclip.copy(content)
self.notify("Copied to clipboard", severity="information")
status.update("✓ Content copied to clipboard")
self._hide_status_after_delay(status)
return
except ImportError:
pass
# Fallback to platform-specific clipboard commands
import subprocess
import platform
system = platform.system()
if system == "Darwin": # macOS
process = subprocess.Popen(["pbcopy"], stdin=subprocess.PIPE, text=True)
process.communicate(input=content)
self.notify("Copied to clipboard", severity="information")
status.update("✓ Content copied to clipboard")
elif system == "Windows":
process = subprocess.Popen(["clip"], stdin=subprocess.PIPE, text=True)
process.communicate(input=content)
self.notify("Copied to clipboard", severity="information")
status.update("✓ Content copied to clipboard")
elif system == "Linux":
# Try xclip first, then xsel
try:
process = subprocess.Popen(
["xclip", "-selection", "clipboard"],
stdin=subprocess.PIPE,
text=True,
)
process.communicate(input=content)
self.notify("Copied to clipboard", severity="information")
status.update("✓ Content copied to clipboard")
except FileNotFoundError:
try:
process = subprocess.Popen(
["xsel", "--clipboard", "--input"],
stdin=subprocess.PIPE,
text=True,
)
process.communicate(input=content)
self.notify("Copied to clipboard", severity="information")
status.update("✓ Content copied to clipboard")
except FileNotFoundError:
self.notify(
"Clipboard utilities not found. Install xclip or xsel.",
severity="error",
)
status.update(
"❌ Clipboard utilities not found. Install xclip or xsel."
)
else: else:
self.notify( self.notify(message, severity="error")
"Clipboard not supported on this platform", severity="error" status.update(f"{message}")
)
status.update("❌ Clipboard not supported on this platform")
self._hide_status_after_delay(status) self._hide_status_after_delay(status)
except Exception as e: except Exception as e:

View file

@ -10,11 +10,32 @@ from rich.text import Text
from ..managers.container_manager import ContainerManager from ..managers.container_manager import ContainerManager
from ..managers.docling_manager import DoclingManager from ..managers.docling_manager import DoclingManager
from ..utils.clipboard import copy_text_to_clipboard
class LogsScreen(Screen): class LogsScreen(Screen):
"""Logs viewing and monitoring screen.""" """Logs viewing and monitoring screen."""
CSS = """
#main-container {
height: 1fr;
}
#logs-content {
height: 1fr;
padding: 1 1 0 1;
}
#logs-area {
height: 1fr;
min-height: 30;
}
#logs-button-row {
padding: 1 0 0 0;
}
"""
BINDINGS = [ BINDINGS = [
("escape", "back", "Back"), ("escape", "back", "Back"),
("f", "follow", "Follow Logs"), ("f", "follow", "Follow Logs"),
@ -27,6 +48,7 @@ class LogsScreen(Screen):
("k", "scroll_up", "Scroll Up"), ("k", "scroll_up", "Scroll Up"),
("ctrl+u", "scroll_page_up", "Page Up"), ("ctrl+u", "scroll_page_up", "Page Up"),
("ctrl+f", "scroll_page_down", "Page Down"), ("ctrl+f", "scroll_page_down", "Page Down"),
("ctrl+c", "copy_logs", "Copy Logs"),
] ]
def __init__(self, initial_service: str = "openrag-backend"): def __init__(self, initial_service: str = "openrag-backend"):
@ -51,17 +73,17 @@ class LogsScreen(Screen):
self.following = False self.following = False
self.follow_task = None self.follow_task = None
self.auto_scroll = True self.auto_scroll = True
self._status_task = None
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
"""Create the logs screen layout.""" """Create the logs screen layout."""
yield Container( with Container(id="main-container"):
Vertical( with Vertical(id="logs-content"):
Static(f"Service Logs: {self.current_service}", id="logs-title"), yield Static(f"Service Logs: {self.current_service}", id="logs-title")
self._create_logs_area(), yield self._create_logs_area()
id="logs-content", with Horizontal(id="logs-button-row"):
), yield Button("Copy to Clipboard", variant="default", id="copy-btn")
id="main-container", yield Static("", id="copy-status", classes="copy-indicator")
)
yield Footer() yield Footer()
def _create_logs_area(self) -> TextArea: def _create_logs_area(self) -> TextArea:
@ -108,6 +130,9 @@ class LogsScreen(Screen):
def on_unmount(self) -> None: def on_unmount(self) -> None:
"""Clean up when unmounting.""" """Clean up when unmounting."""
self._stop_following() self._stop_following()
if self._status_task:
self._status_task.cancel()
self._status_task = None
async def _load_logs(self, lines: int = 200) -> None: async def _load_logs(self, lines: int = 200) -> None:
"""Load recent logs for the current service.""" """Load recent logs for the current service."""
@ -235,6 +260,10 @@ class LogsScreen(Screen):
"""Clear the logs area.""" """Clear the logs area."""
self.logs_area.text = "" self.logs_area.text = ""
def action_copy_logs(self) -> None:
"""Copy log content to the clipboard."""
self._copy_logs_to_clipboard()
def action_toggle_auto_scroll(self) -> None: def action_toggle_auto_scroll(self) -> None:
"""Toggle auto scroll on/off.""" """Toggle auto scroll on/off."""
self.auto_scroll = not self.auto_scroll self.auto_scroll = not self.auto_scroll
@ -284,3 +313,44 @@ class LogsScreen(Screen):
"""Go back to previous screen.""" """Go back to previous screen."""
self._stop_following() self._stop_following()
self.app.pop_screen() self.app.pop_screen()
def _copy_logs_to_clipboard(self) -> None:
"""Copy the current log buffer to the clipboard."""
if not self.logs_area:
return
content = self.logs_area.text or ""
status_widget = self.query_one("#copy-status", Static)
if not content.strip():
message = "No logs to copy"
self.notify(message, severity="warning")
status_widget.update(Text("⚠ No logs to copy", style="bold yellow"))
self._schedule_status_clear(status_widget)
return
success, message = copy_text_to_clipboard(content)
self.notify(message, severity="information" if success else "error")
prefix = "" if success else ""
style = "bold green" if success else "bold red"
status_widget.update(Text(f"{prefix} {message}", style=style))
self._schedule_status_clear(status_widget)
def on_button_pressed(self, event: Button.Pressed) -> None:
"""Handle button presses."""
if event.button.id == "copy-btn":
self._copy_logs_to_clipboard()
def _schedule_status_clear(self, widget: Static, delay: float = 3.0) -> None:
"""Clear the status message after a short delay."""
if self._status_task:
self._status_task.cancel()
async def _clear() -> None:
try:
await asyncio.sleep(delay)
widget.update("")
except asyncio.CancelledError:
pass
self._status_task = asyncio.create_task(_clear())

View file

@ -145,11 +145,7 @@ class MonitorScreen(Screen):
# Set up auto-refresh every 5 seconds # Set up auto-refresh every 5 seconds
self.refresh_timer = self.set_interval(5.0, self._auto_refresh) self.refresh_timer = self.set_interval(5.0, self._auto_refresh)
# Focus the services table self._focus_services_table()
try:
self.services_table.focus()
except Exception:
pass
def on_unmount(self) -> None: def on_unmount(self) -> None:
"""Clean up when unmounting.""" """Clean up when unmounting."""
@ -224,6 +220,9 @@ class MonitorScreen(Screen):
docling_pid, docling_pid,
"Start/Stop/Logs" "Start/Stop/Logs"
) )
# Restore docling selection when it was the last active table
if self._last_selected_table == "docling":
self._focus_docling_table(focus=False, set_last=False)
# Populate images table (unique images as reported by runtime) # Populate images table (unique images as reported by runtime)
if self.images_table: if self.images_table:
for image in sorted(images): for image in sorted(images):
@ -509,16 +508,52 @@ class MonitorScreen(Screen):
self.run_worker(self._refresh_services()) self.run_worker(self._refresh_services())
def action_cursor_down(self) -> None: def action_cursor_down(self) -> None:
"""Move cursor down in services table.""" """Move selection down, handling both tables."""
active_table = self._get_active_table_name()
try: try:
self.services_table.action_cursor_down() if active_table == "docling":
return # Nothing to move within docling table
if not self.services_table:
return
row_count = self._table_row_count(self.services_table)
current = self._get_cursor_row(self.services_table)
if current is None:
current = 0
if current < row_count - 1:
self.services_table.action_cursor_down()
self._last_selected_table = "services"
elif self._table_row_count(self.docling_table):
self._focus_docling_table()
except Exception: except Exception:
pass pass
def action_cursor_up(self) -> None: def action_cursor_up(self) -> None:
"""Move cursor up in services table.""" """Move selection up, handling both tables."""
active_table = self._get_active_table_name()
try: try:
self.services_table.action_cursor_up() if active_table == "docling":
self._focus_services_table(row="last")
return
if not self.services_table:
return
current = self._get_cursor_row(self.services_table)
if current is None:
current = 0
if current > 0:
self.services_table.action_cursor_up()
else:
# Already at the top; nothing else to do
self._set_cursor_row(self.services_table, 0)
self._last_selected_table = "services"
except Exception: except Exception:
pass pass
@ -664,59 +699,37 @@ class MonitorScreen(Screen):
self.notify(f"Error opening logs: {e}", severity="error") self.notify(f"Error opening logs: {e}", severity="error")
def _get_selected_service(self) -> str | None: def _get_selected_service(self) -> str | None:
"""Get the currently selected service from either table.""" """Resolve the currently selected service based on active table."""
try: try:
# Check both tables regardless of last_selected_table to handle cursor navigation active_table = self._get_active_table_name()
services_table = self.query_one("#services-table", DataTable)
services_cursor = services_table.cursor_row
docling_cursor = None if active_table == "docling" and self.docling_table:
if self.docling_table: cursor = self._get_cursor_row(self.docling_table)
docling_cursor = self.docling_table.cursor_row if cursor is not None and cursor >= 0:
# If we have a last selected table preference, use it if that table has a valid selection
if self._last_selected_table == "docling" and self.docling_table:
if docling_cursor is not None and docling_cursor >= 0:
row_data = self.docling_table.get_row_at(docling_cursor)
if row_data:
return "docling-serve"
elif self._last_selected_table == "services":
if services_cursor is not None and services_cursor >= 0:
row_data = services_table.get_row_at(services_cursor)
if row_data:
service_name = str(row_data[0])
service_mapping = {
"openrag-backend": "openrag-backend",
"openrag-frontend": "openrag-frontend",
"opensearch": "opensearch",
"langflow": "langflow",
"dashboards": "dashboards",
}
selected_service = service_mapping.get(service_name, service_name)
return selected_service
# Fallback: check both tables if no last_selected_table or it doesn't have a selection
if self.docling_table and docling_cursor is not None and docling_cursor >= 0:
row_data = self.docling_table.get_row_at(docling_cursor)
if row_data:
return "docling-serve" return "docling-serve"
if services_cursor is not None and services_cursor >= 0: services_table = self.query_one("#services-table", DataTable)
row_data = services_table.get_row_at(services_cursor) row_count = self._table_row_count(services_table)
if row_data: if row_count == 0:
service_name = str(row_data[0]) return None
service_mapping = {
"openrag-backend": "openrag-backend",
"openrag-frontend": "openrag-frontend",
"opensearch": "opensearch",
"langflow": "langflow",
"dashboards": "dashboards",
}
selected_service = service_mapping.get(service_name, service_name)
return selected_service
return None cursor = self._get_cursor_row(services_table)
if cursor is None or cursor < 0 or cursor >= row_count:
cursor = 0
row_data = services_table.get_row_at(cursor)
if not row_data:
return None
service_name = str(row_data[0])
service_mapping = {
"openrag-backend": "openrag-backend",
"openrag-frontend": "openrag-frontend",
"opensearch": "opensearch",
"langflow": "langflow",
"dashboards": "dashboards",
}
return service_mapping.get(service_name, service_name)
except Exception as e: except Exception as e:
self.notify(f"Error getting selected service: {e}", severity="error") self.notify(f"Error getting selected service: {e}", severity="error")
return None return None
@ -728,15 +741,118 @@ class MonitorScreen(Screen):
try: try:
# Track which table was selected # Track which table was selected
if selected_table.id == "services-table": if selected_table.id == "services-table":
self._last_selected_table = "services" self._focus_services_table(row="current")
# Clear docling table selection
if self.docling_table:
self.docling_table.cursor_row = -1
elif selected_table.id == "docling-table": elif selected_table.id == "docling-table":
self._last_selected_table = "docling" self._focus_docling_table()
# Clear services table selection
services_table = self.query_one("#services-table", DataTable)
services_table.cursor_row = -1
except Exception: except Exception:
# Ignore errors during table manipulation # Ignore errors during table manipulation
pass pass
def _get_active_table_name(self) -> str:
"""Determine which table is currently active."""
if self.docling_table and self.docling_table.has_focus:
return "docling"
if self.services_table and self.services_table.has_focus:
return "services"
return self._last_selected_table or "services"
def _table_row_count(self, table: DataTable | None) -> int:
"""Safely compute the number of rows in a DataTable."""
if not table:
return 0
count_attr = getattr(table, "row_count", None)
if callable(count_attr):
try:
return int(count_attr())
except Exception:
pass
if isinstance(count_attr, int):
return count_attr
try:
rows = getattr(table, "rows", None)
if rows is not None:
return len(rows)
except Exception:
pass
return 0
def _get_cursor_row(self, table: DataTable | None) -> int | None:
"""Return the current cursor row for the given table."""
if not table:
return None
coord = getattr(table, "cursor_coordinate", None)
if coord is None:
return None
row = getattr(coord, "row", None)
if row is not None:
return row
if isinstance(coord, tuple) and coord:
return coord[0]
return None
def _set_cursor_row(self, table: DataTable | None, row: int) -> None:
"""Set the cursor row for the given table, if possible."""
if not table:
return
try:
table.cursor_coordinate = (row, 0)
except Exception:
move_cursor = getattr(table, "move_cursor", None)
if callable(move_cursor):
try:
move_cursor(row, 0, expand=False)
except Exception:
pass
def _focus_services_table(self, row: str | None = None, set_last: bool = True) -> None:
"""Focus the services table and update selection."""
if not self.services_table:
return
try:
self.services_table.focus()
row_count = self._table_row_count(self.services_table)
if row_count:
if row == "last":
self._set_cursor_row(self.services_table, row_count - 1)
elif row == "current":
# Keep existing cursor position if valid
cursor = self._get_cursor_row(self.services_table)
if cursor is None or cursor < 0 or cursor >= row_count:
self._set_cursor_row(self.services_table, 0)
else:
cursor = self._get_cursor_row(self.services_table)
if cursor is None or cursor < 0:
self._set_cursor_row(self.services_table, 0)
if set_last:
self._last_selected_table = "services"
except Exception:
pass
def _focus_docling_table(self, focus: bool = True, set_last: bool = True) -> None:
"""Focus the docling table and select its row."""
if not self.docling_table:
return
try:
if focus:
self.docling_table.focus()
if self._table_row_count(self.docling_table):
self._set_cursor_row(self.docling_table, 0)
if set_last:
self._last_selected_table = "docling"
except Exception:
pass

View file

@ -0,0 +1,50 @@
"""Clipboard helper utilities for the TUI."""
from __future__ import annotations
import platform
import subprocess
from typing import Tuple
def copy_text_to_clipboard(text: str) -> Tuple[bool, str]:
"""Copy ``text`` to the system clipboard.
Returns a tuple of (success, message) so callers can surface feedback to users.
"""
# Try optional dependency first for cross-platform consistency
try:
import pyperclip # type: ignore
pyperclip.copy(text)
return True, "Copied to clipboard"
except ImportError:
# Fall back to platform-specific commands
pass
except Exception as exc: # pragma: no cover - defensive catch for pyperclip edge cases
return False, f"Clipboard error: {exc}"
system = platform.system()
try:
if system == "Darwin":
process = subprocess.Popen(["pbcopy"], stdin=subprocess.PIPE, text=True)
process.communicate(input=text)
return True, "Copied to clipboard"
if system == "Windows":
process = subprocess.Popen(["clip"], stdin=subprocess.PIPE, text=True)
process.communicate(input=text)
return True, "Copied to clipboard"
if system == "Linux":
for command in (["xclip", "-selection", "clipboard"], ["xsel", "--clipboard", "--input"]):
try:
process = subprocess.Popen(command, stdin=subprocess.PIPE, text=True)
process.communicate(input=text)
return True, "Copied to clipboard"
except FileNotFoundError:
continue
return False, "Clipboard utilities not found. Install xclip or xsel."
return False, "Clipboard not supported on this platform"
except Exception as exc: # pragma: no cover - subprocess errors
return False, f"Clipboard error: {exc}"

View file

@ -2,14 +2,15 @@
import asyncio import asyncio
import inspect import inspect
from typing import Callable, List, Optional, AsyncIterator, Any from typing import Callable, Optional, AsyncIterator
from rich.text import Text
from textual.app import ComposeResult from textual.app import ComposeResult
from textual.worker import Worker
from textual.containers import Container, ScrollableContainer from textual.containers import Container, ScrollableContainer
from textual.screen import ModalScreen from textual.screen import ModalScreen
from textual.widgets import Button, Static, Label, RichLog from textual.widgets import Button, Static, Label, TextArea
from rich.console import Console
from ..utils.clipboard import copy_text_to_clipboard
class CommandOutputModal(ModalScreen): class CommandOutputModal(ModalScreen):
@ -46,11 +47,14 @@ class CommandOutputModal(ModalScreen):
#command-output { #command-output {
height: 100%; height: 100%;
border: solid $accent; border: solid $accent;
padding: 1 2;
margin: 1 0; margin: 1 0;
background: $surface-darken-1; background: $surface-darken-1;
} }
#command-output > .text-area--content {
padding: 1 2;
}
#button-row { #button-row {
width: 100%; width: 100%;
height: auto; height: auto;
@ -63,6 +67,11 @@ class CommandOutputModal(ModalScreen):
margin: 0 1; margin: 0 1;
min-width: 16; min-width: 16;
} }
#copy-status {
text-align: center;
margin-bottom: 1;
}
""" """
def __init__( def __init__(
@ -82,44 +91,66 @@ class CommandOutputModal(ModalScreen):
self.title_text = title self.title_text = title
self.command_generator = command_generator self.command_generator = command_generator
self.on_complete = on_complete self.on_complete = on_complete
self._output_text: str = ""
self._status_task: Optional[asyncio.Task] = None
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
"""Create the modal dialog layout.""" """Create the modal dialog layout."""
with Container(id="dialog"): with Container(id="dialog"):
yield Label(self.title_text, id="title") yield Label(self.title_text, id="title")
with ScrollableContainer(id="output-container"): with ScrollableContainer(id="output-container"):
yield RichLog(id="command-output", highlight=True, markup=True) yield TextArea(
text="",
read_only=True,
show_line_numbers=False,
id="command-output",
)
with Container(id="button-row"): with Container(id="button-row"):
yield Button("Close", variant="primary", id="close-btn") yield Button("Copy Output", variant="default", id="copy-btn")
yield Button(
"Close", variant="primary", id="close-btn", disabled=True
)
yield Static("", id="copy-status")
def on_mount(self) -> None: def on_mount(self) -> None:
"""Start the command when the modal is mounted.""" """Start the command when the modal is mounted."""
# Start the command but don't store the worker # Start the command but don't store the worker
self.run_worker(self._run_command(), exclusive=False) self.run_worker(self._run_command(), exclusive=False)
# Focus the output so users can select text immediately
try:
self.query_one("#command-output", TextArea).focus()
except Exception:
pass
def on_unmount(self) -> None:
"""Cancel any pending timers when modal closes."""
if self._status_task:
self._status_task.cancel()
self._status_task = None
def on_button_pressed(self, event: Button.Pressed) -> None: def on_button_pressed(self, event: Button.Pressed) -> None:
"""Handle button presses.""" """Handle button presses."""
if event.button.id == "close-btn": if event.button.id == "close-btn":
self.dismiss() self.dismiss()
elif event.button.id == "copy-btn":
self.copy_to_clipboard()
async def _run_command(self) -> None: async def _run_command(self) -> None:
"""Run the command and update the output in real-time.""" """Run the command and update the output in real-time."""
output = self.query_one("#command-output", RichLog) output = self.query_one("#command-output", TextArea)
container = self.query_one("#output-container", ScrollableContainer)
try: try:
async for is_complete, message in self.command_generator: async for is_complete, message in self.command_generator:
# Simple approach: just append each line as it comes self._append_output(message)
output.write(message + "\n") output.text = self._output_text
# Scroll to bottom
container = self.query_one("#output-container", ScrollableContainer)
container.scroll_end(animate=False) container.scroll_end(animate=False)
# If command is complete, update UI # If command is complete, update UI
if is_complete: if is_complete:
output.write( self._append_output("Command completed successfully")
"[bold green]Command completed successfully[/bold green]\n" output.text = self._output_text
) container.scroll_end(animate=False)
# Call the completion callback if provided # Call the completion callback if provided
if self.on_complete: if self.on_complete:
await asyncio.sleep(0.5) # Small delay for better UX await asyncio.sleep(0.5) # Small delay for better UX
@ -131,12 +162,57 @@ class CommandOutputModal(ModalScreen):
self.call_after_refresh(_invoke_callback) self.call_after_refresh(_invoke_callback)
except Exception as e: except Exception as e:
output.write(f"[bold red]Error: {e}[/bold red]\n") self._append_output(f"Error: {e}")
output.text = self._output_text
container.scroll_end(animate=False)
finally:
# Enable the close button and focus it
close_btn = self.query_one("#close-btn", Button)
close_btn.disabled = False
close_btn.focus()
# Enable the close button and focus it def _append_output(self, message: str) -> None:
close_btn = self.query_one("#close-btn", Button) """Append a message to the output buffer."""
close_btn.disabled = False if message is None:
close_btn.focus() return
message = message.rstrip("\n")
if not message:
return
if self._output_text:
self._output_text += "\n" + message
else:
self._output_text = message
def copy_to_clipboard(self) -> None:
"""Copy the modal output to the clipboard."""
if not self._output_text:
message = "No output to copy yet"
self.notify(message, severity="warning")
status = self.query_one("#copy-status", Static)
status.update(Text(message, style="bold yellow"))
self._schedule_status_clear(status)
return
success, message = copy_text_to_clipboard(self._output_text)
self.notify(message, severity="information" if success else "error")
status = self.query_one("#copy-status", Static)
style = "bold green" if success else "bold red"
status.update(Text(message, style=style))
self._schedule_status_clear(status)
def _schedule_status_clear(self, widget: Static, delay: float = 3.0) -> None:
"""Clear the status message after a delay."""
if self._status_task:
self._status_task.cancel()
async def _clear() -> None:
try:
await asyncio.sleep(delay)
widget.update("")
except asyncio.CancelledError:
pass
self._status_task = asyncio.create_task(_clear())
# Made with Bob # Made with Bob

4
uv.lock generated
View file

@ -1,5 +1,5 @@
version = 1 version = 1
revision = 3 revision = 2
requires-python = ">=3.13" requires-python = ">=3.13"
resolution-markers = [ resolution-markers = [
"sys_platform == 'darwin'", "sys_platform == 'darwin'",
@ -2282,7 +2282,7 @@ wheels = [
[[package]] [[package]]
name = "openrag" name = "openrag"
version = "0.1.3" version = "0.1.8"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "agentd" }, { name = "agentd" },