Merge branch 'main' into feat-add-langflow-mcp-service
This commit is contained in:
commit
e89c4fa40f
24 changed files with 1134 additions and 342 deletions
8
Makefile
8
Makefile
|
|
@ -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..."
|
||||||
|
|
|
||||||
25
frontend/components/knowledge-actions-dropdown.tsx
Normal file
25
frontend/components/knowledge-actions-dropdown.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { EllipsisVertical } from "lucide-react";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
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 variant="destructive">Delete</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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",
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
28
frontend/package-lock.json
generated
28
frontend/package-lock.json
generated
|
|
@ -25,6 +25,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",
|
||||||
|
|
@ -2950,6 +2952,32 @@
|
||||||
"node": ">=0.4.0"
|
"node": ">=0.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ag-charts-types": {
|
||||||
|
"version": "12.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ag-charts-types/-/ag-charts-types-12.2.0.tgz",
|
||||||
|
"integrity": "sha512-d2qQrQirt9wP36YW5HPuOvXsiajyiFnr1CTsoCbs02bavPDz7Lk2jHp64+waM4YKgXb3GN7gafbBI9Qgk33BmQ=="
|
||||||
|
},
|
||||||
|
"node_modules/ag-grid-community": {
|
||||||
|
"version": "34.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ag-grid-community/-/ag-grid-community-34.2.0.tgz",
|
||||||
|
"integrity": "sha512-peS7THEMYwpIrwLQHmkRxw/TlOnddD/F5A88RqlBxf8j+WqVYRWMOOhU5TqymGcha7z2oZ8IoL9ROl3gvtdEjg==",
|
||||||
|
"dependencies": {
|
||||||
|
"ag-charts-types": "12.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ag-grid-react": {
|
||||||
|
"version": "34.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ag-grid-react/-/ag-grid-react-34.2.0.tgz",
|
||||||
|
"integrity": "sha512-dLKFw6hz75S0HLuZvtcwjm+gyiI4gXVzHEu7lWNafWAX0mb8DhogEOP5wbzAlsN6iCfi7bK/cgZImZFjenlqwg==",
|
||||||
|
"dependencies": {
|
||||||
|
"ag-grid-community": "34.2.0",
|
||||||
|
"prop-types": "^15.8.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/agent-base": {
|
"node_modules/agent-base": {
|
||||||
"version": "6.0.2",
|
"version": "6.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,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",
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
21
frontend/src/components/AgGrid/agGridStyles.css
Normal file
21
frontend/src/components/AgGrid/agGridStyles.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
33
frontend/src/components/AgGrid/registerAgGridModules.ts
Normal file
33
frontend/src/components/AgGrid/registerAgGridModules.ts
Normal 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] : []),
|
||||||
|
]);
|
||||||
|
|
||||||
|
|
@ -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'" },
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
111
src/tui/_assets/docker-compose-cpu.yml
Normal file
111
src/tui/_assets/docker-compose-cpu.yml
Normal 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/langflow:${LANGFLOW_VERSION:-responses}
|
||||||
|
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}
|
||||||
111
src/tui/_assets/docker-compose.yml
Normal file
111
src/tui/_assets/docker-compose.yml
Normal 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/langflow:${LANGFLOW_VERSION:-responses}
|
||||||
|
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}
|
||||||
BIN
src/tui/_assets/documents/2506.08231v1.pdf
Normal file
BIN
src/tui/_assets/documents/2506.08231v1.pdf
Normal file
Binary file not shown.
BIN
src/tui/_assets/documents/ai-human-resources.pdf
Normal file
BIN
src/tui/_assets/documents/ai-human-resources.pdf
Normal file
Binary file not shown.
BIN
src/tui/_assets/documents/warmup_ocr.pdf
Normal file
BIN
src/tui/_assets/documents/warmup_ocr.pdf
Normal file
Binary file not shown.
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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."""
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
50
src/tui/utils/clipboard.py
Normal file
50
src/tui/utils/clipboard.py
Normal 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}"
|
||||||
|
|
||||||
|
|
@ -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
4
uv.lock
generated
|
|
@ -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" },
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue