Merge branch 'main' into releases
This commit is contained in:
commit
579ffb3ee9
16 changed files with 2636 additions and 269 deletions
8
Makefile
8
Makefile
|
|
@ -75,6 +75,14 @@ infra:
|
|||
@echo " OpenSearch: http://localhost:9200"
|
||||
@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
|
||||
stop:
|
||||
@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
|
||||
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",
|
||||
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/typography": "^0.5.16",
|
||||
"@tanstack/react-query": "^5.86.0",
|
||||
"ag-grid-community": "^34.2.0",
|
||||
"ag-grid-react": "^34.2.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
|
|
@ -2961,6 +2963,32 @@
|
|||
"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": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
||||
|
|
|
|||
|
|
@ -26,6 +26,8 @@
|
|||
"@tailwindcss/forms": "^0.5.10",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@tanstack/react-query": "^5.86.0",
|
||||
"ag-grid-community": "^34.2.0",
|
||||
"ag-grid-react": "^34.2.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
|
|
|
|||
|
|
@ -8,7 +8,14 @@ import {
|
|||
Loader2,
|
||||
Search,
|
||||
} 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 { TbBrandOnedrive } from "react-icons/tb";
|
||||
import { KnowledgeDropdown } from "@/components/knowledge-dropdown";
|
||||
|
|
@ -18,6 +25,10 @@ import { Input } from "@/components/ui/input";
|
|||
import { useKnowledgeFilter } from "@/contexts/knowledge-filter-context";
|
||||
import { useTask } from "@/contexts/task-context";
|
||||
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 getSourceIcon(connectorType?: string) {
|
||||
|
|
@ -64,11 +75,94 @@ function SearchPage() {
|
|||
}
|
||||
setQuery(queryInputText);
|
||||
},
|
||||
[queryInputText, refetchSearch, query],
|
||||
[queryInputText, refetchSearch, query]
|
||||
);
|
||||
|
||||
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 (
|
||||
<div
|
||||
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 items-center justify-between mb-6">
|
||||
<h2 className="text-lg font-semibold">Project Knowledge</h2>
|
||||
<KnowledgeDropdown variant="button" />
|
||||
</div>
|
||||
{/* 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">
|
||||
<Input
|
||||
name="search-query"
|
||||
|
|
@ -100,7 +198,7 @@ function SearchPage() {
|
|||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="secondary"
|
||||
variant="outline"
|
||||
className="rounded-lg h-12 w-12 p-0 flex-shrink-0"
|
||||
>
|
||||
{isFetching ? (
|
||||
|
|
@ -109,17 +207,63 @@ function SearchPage() {
|
|||
<Search className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<div className="flex-shrink-0">
|
||||
<KnowledgeDropdown variant="button" />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Results Area */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="space-y-4">
|
||||
{fileResults.length === 0 && !isFetching ? (
|
||||
<div className="text-center py-12">
|
||||
{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>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<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" />
|
||||
<p className="text-lg text-muted-foreground">
|
||||
No documents found
|
||||
|
|
@ -128,140 +272,9 @@ function SearchPage() {
|
|||
Try adjusting your search terms
|
||||
</p>
|
||||
</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>
|
||||
);
|
||||
|
|
|
|||
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] : []),
|
||||
]);
|
||||
|
||||
|
|
@ -8,7 +8,8 @@ dependencies = [
|
|||
"agentd>=0.2.2",
|
||||
"aiofiles>=24.1.0",
|
||||
"cryptography>=45.0.6",
|
||||
"docling>=2.41.0",
|
||||
"docling[vlm]>=2.41.0; sys_platform != 'darwin'",
|
||||
"docling[ocrmac,vlm]>=2.41.0; sys_platform == 'darwin'",
|
||||
"google-api-python-client>=2.143.0",
|
||||
"google-auth-httplib2>=0.2.0",
|
||||
"google-auth-oauthlib>=1.2.0",
|
||||
|
|
@ -27,6 +28,7 @@ dependencies = [
|
|||
"python-dotenv>=1.0.0",
|
||||
"textual-fspicker>=0.6.0",
|
||||
"structlog>=25.4.0",
|
||||
"docling-serve>=1.4.1",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ from .screens.logs import LogsScreen
|
|||
from .screens.diagnostics import DiagnosticsScreen
|
||||
from .managers.env_manager import EnvManager
|
||||
from .managers.container_manager import ContainerManager
|
||||
from .managers.docling_manager import DoclingManager
|
||||
from .utils.platform import PlatformDetector
|
||||
from .widgets.diagnostics_notification import notify_with_diagnostics
|
||||
|
||||
|
|
@ -26,7 +27,7 @@ class OpenRAGTUI(App):
|
|||
|
||||
CSS = """
|
||||
Screen {
|
||||
background: $background;
|
||||
background: #0f172a;
|
||||
}
|
||||
|
||||
#main-container {
|
||||
|
|
@ -114,7 +115,8 @@ class OpenRAGTUI(App):
|
|||
}
|
||||
|
||||
#services-table {
|
||||
height: 1fr;
|
||||
height: auto;
|
||||
max-height: 12;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
|
|
@ -174,6 +176,82 @@ class OpenRAGTUI(App):
|
|||
height: 100%;
|
||||
padding: 1;
|
||||
}
|
||||
|
||||
/* Frontend-inspired color scheme */
|
||||
Static {
|
||||
color: #f1f5f9;
|
||||
}
|
||||
|
||||
Button.success {
|
||||
background: #4ade80;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
Button.error {
|
||||
background: #ef4444;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
Button.warning {
|
||||
background: #eab308;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
Button.primary {
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
Button.default {
|
||||
background: #475569;
|
||||
color: #f1f5f9;
|
||||
border: solid #64748b;
|
||||
}
|
||||
|
||||
DataTable {
|
||||
background: #1e293b;
|
||||
color: #f1f5f9;
|
||||
}
|
||||
|
||||
DataTable > .datatable--header {
|
||||
background: #334155;
|
||||
color: #f1f5f9;
|
||||
}
|
||||
|
||||
DataTable > .datatable--cursor {
|
||||
background: #475569;
|
||||
}
|
||||
|
||||
Input {
|
||||
background: #334155;
|
||||
color: #f1f5f9;
|
||||
border: solid #64748b;
|
||||
}
|
||||
|
||||
Label {
|
||||
color: #f1f5f9;
|
||||
}
|
||||
|
||||
Footer {
|
||||
background: #334155;
|
||||
color: #f1f5f9;
|
||||
}
|
||||
|
||||
#runtime-status {
|
||||
background: #1e293b;
|
||||
border: solid #64748b;
|
||||
color: #f1f5f9;
|
||||
}
|
||||
|
||||
#system-info {
|
||||
background: #1e293b;
|
||||
border: solid #64748b;
|
||||
color: #f1f5f9;
|
||||
}
|
||||
|
||||
#services-table, #images-table {
|
||||
background: #1e293b;
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
|
|
@ -181,6 +259,7 @@ class OpenRAGTUI(App):
|
|||
self.platform_detector = PlatformDetector()
|
||||
self.container_manager = ContainerManager()
|
||||
self.env_manager = EnvManager()
|
||||
self.docling_manager = DoclingManager() # Initialize singleton instance
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""Initialize the application."""
|
||||
|
|
@ -201,6 +280,8 @@ class OpenRAGTUI(App):
|
|||
|
||||
async def action_quit(self) -> None:
|
||||
"""Quit the application."""
|
||||
# Cleanup docling manager before exiting
|
||||
self.docling_manager.cleanup()
|
||||
self.exit()
|
||||
|
||||
def check_runtime_requirements(self) -> tuple[bool, str]:
|
||||
|
|
@ -222,15 +303,19 @@ class OpenRAGTUI(App):
|
|||
|
||||
def run_tui():
|
||||
"""Run the OpenRAG TUI application."""
|
||||
app = None
|
||||
try:
|
||||
app = OpenRAGTUI()
|
||||
app.run()
|
||||
except KeyboardInterrupt:
|
||||
logger.info("OpenRAG TUI interrupted by user")
|
||||
sys.exit(0)
|
||||
except Exception as e:
|
||||
logger.error("Error running OpenRAG TUI", error=str(e))
|
||||
sys.exit(1)
|
||||
finally:
|
||||
# Ensure cleanup happens even on exceptions
|
||||
if app and hasattr(app, 'docling_manager'):
|
||||
app.docling_manager.cleanup()
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
|
|||
403
src/tui/managers/docling_manager.py
Normal file
403
src/tui/managers/docling_manager.py
Normal file
|
|
@ -0,0 +1,403 @@
|
|||
"""Docling serve manager for local document processing service."""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from typing import Optional, Tuple, Dict, Any, List, AsyncIterator
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
|
||||
class DoclingManager:
|
||||
"""Manages local docling serve instance as external process."""
|
||||
|
||||
_instance = None
|
||||
_initialized = False
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
return cls._instance
|
||||
|
||||
def __init__(self):
|
||||
# Only initialize once
|
||||
if self._initialized:
|
||||
return
|
||||
|
||||
self._process: Optional[subprocess.Popen] = None
|
||||
self._port = 5001
|
||||
self._host = "127.0.0.1"
|
||||
self._running = False
|
||||
self._external_process = False
|
||||
|
||||
# Log storage - simplified, no queue
|
||||
self._log_buffer: List[str] = []
|
||||
self._max_log_lines = 1000
|
||||
self._log_lock = threading.Lock() # Thread-safe access to log buffer
|
||||
|
||||
self._initialized = True
|
||||
|
||||
def cleanup(self):
|
||||
"""Cleanup resources and stop any running processes."""
|
||||
if self._process and self._process.poll() is None:
|
||||
self._add_log_entry("Cleaning up docling-serve process on exit")
|
||||
try:
|
||||
self._process.terminate()
|
||||
self._process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
self._process.kill()
|
||||
self._process.wait()
|
||||
except Exception as e:
|
||||
self._add_log_entry(f"Error during cleanup: {e}")
|
||||
|
||||
self._running = False
|
||||
self._process = None
|
||||
|
||||
def _add_log_entry(self, message: str) -> None:
|
||||
"""Add a log entry to the buffer (thread-safe)."""
|
||||
timestamp = time.strftime("%Y-%m-%d %H:%M:%S")
|
||||
entry = f"[{timestamp}] {message}"
|
||||
|
||||
with self._log_lock:
|
||||
self._log_buffer.append(entry)
|
||||
# Keep buffer size limited
|
||||
if len(self._log_buffer) > self._max_log_lines:
|
||||
self._log_buffer = self._log_buffer[-self._max_log_lines:]
|
||||
|
||||
def is_running(self) -> bool:
|
||||
"""Check if docling serve is running."""
|
||||
# First check our internal state
|
||||
internal_running = self._running and self._process is not None and self._process.poll() is None
|
||||
|
||||
# If we think it's not running, check if something is listening on the port
|
||||
# This handles cases where docling-serve was started outside the TUI
|
||||
if not internal_running:
|
||||
try:
|
||||
import socket
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
s.settimeout(0.5)
|
||||
result = s.connect_ex((self._host, self._port))
|
||||
s.close()
|
||||
|
||||
# If port is in use, something is running there
|
||||
if result == 0:
|
||||
# Only log this once when we first detect external process
|
||||
if not self._external_process:
|
||||
self._add_log_entry(f"Detected external docling-serve running on {self._host}:{self._port}")
|
||||
# Set a flag to indicate this is an external process
|
||||
self._external_process = True
|
||||
return True
|
||||
except Exception as e:
|
||||
# Only log errors occasionally to avoid spam
|
||||
if not hasattr(self, '_last_port_error') or self._last_port_error != str(e):
|
||||
self._add_log_entry(f"Error checking port: {e}")
|
||||
self._last_port_error = str(e)
|
||||
else:
|
||||
# If we started it, it's not external
|
||||
self._external_process = False
|
||||
|
||||
return internal_running
|
||||
|
||||
def get_status(self) -> Dict[str, Any]:
|
||||
"""Get current status of docling serve."""
|
||||
if self.is_running():
|
||||
pid = self._process.pid if self._process else None
|
||||
return {
|
||||
"status": "running",
|
||||
"port": self._port,
|
||||
"host": self._host,
|
||||
"endpoint": f"http://{self._host}:{self._port}",
|
||||
"docs_url": f"http://{self._host}:{self._port}/docs",
|
||||
"ui_url": f"http://{self._host}:{self._port}/ui",
|
||||
"pid": pid
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"status": "stopped",
|
||||
"port": self._port,
|
||||
"host": self._host,
|
||||
"endpoint": None,
|
||||
"docs_url": None,
|
||||
"ui_url": None,
|
||||
"pid": None
|
||||
}
|
||||
|
||||
async def start(self, port: int = 5001, host: str = "127.0.0.1", enable_ui: bool = False) -> Tuple[bool, str]:
|
||||
"""Start docling serve as external process."""
|
||||
if self.is_running():
|
||||
return False, "Docling serve is already running"
|
||||
|
||||
self._port = port
|
||||
self._host = host
|
||||
|
||||
# Clear log buffer when starting
|
||||
self._log_buffer = []
|
||||
self._add_log_entry("Starting docling serve as external process...")
|
||||
|
||||
try:
|
||||
# Build command to run docling-serve
|
||||
# Check if we should use uv run (look for uv in environment or check if we're in a uv project)
|
||||
import shutil
|
||||
if shutil.which("uv") and (os.path.exists("pyproject.toml") or os.getenv("VIRTUAL_ENV")):
|
||||
cmd = [
|
||||
"uv", "run", "python", "-m", "docling_serve", "run",
|
||||
"--host", host,
|
||||
"--port", str(port),
|
||||
]
|
||||
else:
|
||||
cmd = [
|
||||
sys.executable, "-m", "docling_serve", "run",
|
||||
"--host", host,
|
||||
"--port", str(port),
|
||||
]
|
||||
|
||||
if enable_ui:
|
||||
cmd.append("--enable-ui")
|
||||
|
||||
self._add_log_entry(f"Starting process: {' '.join(cmd)}")
|
||||
|
||||
# Start as subprocess
|
||||
self._process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
universal_newlines=True,
|
||||
bufsize=0 # Unbuffered for real-time output
|
||||
)
|
||||
|
||||
self._running = True
|
||||
self._add_log_entry("External process started")
|
||||
|
||||
# Start a thread to capture output
|
||||
self._start_output_capture()
|
||||
|
||||
# Wait for the process to start and begin listening
|
||||
self._add_log_entry("Waiting for docling-serve to start listening...")
|
||||
|
||||
# Wait up to 10 seconds for the service to start listening
|
||||
for i in range(10):
|
||||
await asyncio.sleep(1.0)
|
||||
|
||||
# Check if process is still alive
|
||||
if self._process.poll() is not None:
|
||||
break
|
||||
|
||||
# Check if it's listening on the port
|
||||
try:
|
||||
import socket
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
s.settimeout(0.5)
|
||||
result = s.connect_ex((host, port))
|
||||
s.close()
|
||||
|
||||
if result == 0:
|
||||
self._add_log_entry(f"Docling-serve is now listening on {host}:{port}")
|
||||
break
|
||||
except:
|
||||
pass
|
||||
|
||||
self._add_log_entry(f"Waiting for startup... ({i+1}/10)")
|
||||
|
||||
# Add a test message to verify logging is working
|
||||
self._add_log_entry(f"Process PID: {self._process.pid}, Poll: {self._process.poll()}")
|
||||
|
||||
if self._process.poll() is not None:
|
||||
# Process already exited - get return code and any output
|
||||
return_code = self._process.returncode
|
||||
self._add_log_entry(f"Process exited with code: {return_code}")
|
||||
|
||||
try:
|
||||
# Try to read any remaining output
|
||||
stdout_data = ""
|
||||
stderr_data = ""
|
||||
|
||||
if self._process.stdout:
|
||||
stdout_data = self._process.stdout.read()
|
||||
if self._process.stderr:
|
||||
stderr_data = self._process.stderr.read()
|
||||
|
||||
if stdout_data:
|
||||
self._add_log_entry(f"Final stdout: {stdout_data[:500]}")
|
||||
if stderr_data:
|
||||
self._add_log_entry(f"Final stderr: {stderr_data[:500]}")
|
||||
|
||||
except Exception as e:
|
||||
self._add_log_entry(f"Error reading final output: {e}")
|
||||
|
||||
self._running = False
|
||||
return False, f"Docling serve process exited immediately (code: {return_code})"
|
||||
|
||||
return True, f"Docling serve starting on http://{host}:{port}"
|
||||
|
||||
except FileNotFoundError:
|
||||
return False, "docling-serve not available. Please install: uv add docling-serve"
|
||||
except Exception as e:
|
||||
self._running = False
|
||||
self._process = None
|
||||
return False, f"Error starting docling serve: {str(e)}"
|
||||
|
||||
def _start_output_capture(self):
|
||||
"""Start threads to capture subprocess stdout and stderr."""
|
||||
def capture_stdout():
|
||||
if not self._process or not self._process.stdout:
|
||||
self._add_log_entry("No stdout pipe available")
|
||||
return
|
||||
|
||||
self._add_log_entry("Starting stdout capture thread")
|
||||
try:
|
||||
while self._running and self._process and self._process.poll() is None:
|
||||
line = self._process.stdout.readline()
|
||||
if line:
|
||||
self._add_log_entry(f"STDOUT: {line.rstrip()}")
|
||||
else:
|
||||
# No more output, wait a bit
|
||||
time.sleep(0.1)
|
||||
except Exception as e:
|
||||
self._add_log_entry(f"Error capturing stdout: {e}")
|
||||
finally:
|
||||
self._add_log_entry("Stdout capture thread ended")
|
||||
|
||||
def capture_stderr():
|
||||
if not self._process or not self._process.stderr:
|
||||
self._add_log_entry("No stderr pipe available")
|
||||
return
|
||||
|
||||
self._add_log_entry("Starting stderr capture thread")
|
||||
try:
|
||||
while self._running and self._process and self._process.poll() is None:
|
||||
line = self._process.stderr.readline()
|
||||
if line:
|
||||
self._add_log_entry(f"STDERR: {line.rstrip()}")
|
||||
else:
|
||||
# No more output, wait a bit
|
||||
time.sleep(0.1)
|
||||
except Exception as e:
|
||||
self._add_log_entry(f"Error capturing stderr: {e}")
|
||||
finally:
|
||||
self._add_log_entry("Stderr capture thread ended")
|
||||
|
||||
# Start both capture threads
|
||||
stdout_thread = threading.Thread(target=capture_stdout, daemon=True)
|
||||
stderr_thread = threading.Thread(target=capture_stderr, daemon=True)
|
||||
|
||||
stdout_thread.start()
|
||||
stderr_thread.start()
|
||||
|
||||
self._add_log_entry("Output capture threads started")
|
||||
|
||||
async def stop(self) -> Tuple[bool, str]:
|
||||
"""Stop docling serve."""
|
||||
if not self.is_running():
|
||||
return False, "Docling serve is not running"
|
||||
|
||||
try:
|
||||
self._add_log_entry("Stopping docling-serve process")
|
||||
|
||||
if self._process:
|
||||
# We started this process, so we can stop it directly
|
||||
self._add_log_entry(f"Terminating our process (PID: {self._process.pid})")
|
||||
self._process.terminate()
|
||||
|
||||
# Wait for it to stop
|
||||
try:
|
||||
self._process.wait(timeout=10)
|
||||
self._add_log_entry("Process terminated gracefully")
|
||||
except subprocess.TimeoutExpired:
|
||||
# Force kill if it doesn't stop gracefully
|
||||
self._add_log_entry("Process didn't stop gracefully, force killing")
|
||||
self._process.kill()
|
||||
self._process.wait()
|
||||
self._add_log_entry("Process force killed")
|
||||
|
||||
elif self._external_process:
|
||||
# This is an external process, we can't stop it directly
|
||||
self._add_log_entry("Cannot stop external docling-serve process - it was started outside the TUI")
|
||||
self._running = False
|
||||
self._external_process = False
|
||||
return False, "Cannot stop external docling-serve process. Please stop it manually."
|
||||
|
||||
self._running = False
|
||||
self._process = None
|
||||
self._external_process = False
|
||||
|
||||
self._add_log_entry("Docling serve stopped successfully")
|
||||
return True, "Docling serve stopped successfully"
|
||||
|
||||
except Exception as e:
|
||||
self._add_log_entry(f"Error stopping docling serve: {e}")
|
||||
return False, f"Error stopping docling serve: {str(e)}"
|
||||
|
||||
async def restart(self, port: Optional[int] = None, host: Optional[str] = None, enable_ui: bool = False) -> Tuple[bool, str]:
|
||||
"""Restart docling serve."""
|
||||
# Use current settings if not specified
|
||||
if port is None:
|
||||
port = self._port
|
||||
if host is None:
|
||||
host = self._host
|
||||
|
||||
# Stop if running
|
||||
if self.is_running():
|
||||
success, msg = await self.stop()
|
||||
if not success:
|
||||
return False, f"Failed to stop: {msg}"
|
||||
|
||||
# Wait a moment for cleanup
|
||||
await asyncio.sleep(1)
|
||||
|
||||
# Start with new settings
|
||||
return await self.start(port, host, enable_ui)
|
||||
|
||||
def add_manual_log_entry(self, message: str) -> None:
|
||||
"""Add a manual log entry - useful for debugging."""
|
||||
self._add_log_entry(f"MANUAL: {message}")
|
||||
|
||||
def get_logs(self, lines: int = 50) -> Tuple[bool, str]:
|
||||
"""Get logs from the docling-serve process."""
|
||||
if self.is_running():
|
||||
with self._log_lock:
|
||||
# If we have no logs but the service is running, it might have been started externally
|
||||
if not self._log_buffer:
|
||||
return True, "No logs available yet..."
|
||||
|
||||
# Return the most recent logs
|
||||
log_count = min(lines, len(self._log_buffer))
|
||||
logs = "\n".join(self._log_buffer[-log_count:])
|
||||
return True, logs
|
||||
else:
|
||||
return True, "Docling serve is not running."
|
||||
|
||||
async def follow_logs(self) -> AsyncIterator[str]:
|
||||
"""Follow logs from the docling-serve process in real-time."""
|
||||
# First yield status message and any existing logs
|
||||
status_msg = f"Docling serve is running on http://{self._host}:{self._port}"
|
||||
|
||||
with self._log_lock:
|
||||
if self._log_buffer:
|
||||
yield "\n".join(self._log_buffer)
|
||||
last_log_index = len(self._log_buffer)
|
||||
else:
|
||||
yield "Waiting for logs..."
|
||||
last_log_index = 0
|
||||
|
||||
# Then start monitoring for new logs
|
||||
while self.is_running():
|
||||
with self._log_lock:
|
||||
# Check if we have new logs
|
||||
if len(self._log_buffer) > last_log_index:
|
||||
# Yield only the new logs
|
||||
new_logs = self._log_buffer[last_log_index:]
|
||||
yield "\n".join(new_logs)
|
||||
last_log_index = len(self._log_buffer)
|
||||
|
||||
# Wait a bit before checking again
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
# Final check for any logs that came in during shutdown
|
||||
with self._log_lock:
|
||||
if len(self._log_buffer) > last_log_index:
|
||||
yield "\n".join(self._log_buffer[last_log_index:])
|
||||
|
|
@ -9,6 +9,7 @@ from textual.timer import Timer
|
|||
from rich.text import Text
|
||||
|
||||
from ..managers.container_manager import ContainerManager
|
||||
from ..managers.docling_manager import DoclingManager
|
||||
|
||||
|
||||
class LogsScreen(Screen):
|
||||
|
|
@ -31,6 +32,7 @@ class LogsScreen(Screen):
|
|||
def __init__(self, initial_service: str = "openrag-backend"):
|
||||
super().__init__()
|
||||
self.container_manager = ContainerManager()
|
||||
self.docling_manager = DoclingManager()
|
||||
|
||||
# Validate the initial service against available options
|
||||
valid_services = [
|
||||
|
|
@ -39,6 +41,7 @@ class LogsScreen(Screen):
|
|||
"opensearch",
|
||||
"langflow",
|
||||
"dashboards",
|
||||
"docling-serve", # Add docling-serve as a valid service
|
||||
]
|
||||
if initial_service not in valid_services:
|
||||
initial_service = "openrag-backend" # fallback
|
||||
|
|
@ -92,6 +95,10 @@ class LogsScreen(Screen):
|
|||
|
||||
await self._load_logs()
|
||||
|
||||
# Start following logs by default
|
||||
if not self.following:
|
||||
self.action_follow()
|
||||
|
||||
# Focus the logs area since there are no buttons
|
||||
try:
|
||||
self.logs_area.focus()
|
||||
|
|
@ -104,6 +111,19 @@ class LogsScreen(Screen):
|
|||
|
||||
async def _load_logs(self, lines: int = 200) -> None:
|
||||
"""Load recent logs for the current service."""
|
||||
# Special handling for docling-serve
|
||||
if self.current_service == "docling-serve":
|
||||
success, logs = self.docling_manager.get_logs(lines)
|
||||
if success:
|
||||
self.logs_area.text = logs
|
||||
# Scroll to bottom if auto scroll is enabled
|
||||
if self.auto_scroll:
|
||||
self.logs_area.scroll_end()
|
||||
else:
|
||||
self.logs_area.text = f"Failed to load logs: {logs}"
|
||||
return
|
||||
|
||||
# Regular container services
|
||||
if not self.container_manager.is_available():
|
||||
self.logs_area.text = "No container runtime available"
|
||||
return
|
||||
|
|
@ -130,6 +150,37 @@ class LogsScreen(Screen):
|
|||
|
||||
async def _follow_logs(self) -> None:
|
||||
"""Follow logs in real-time."""
|
||||
# Special handling for docling-serve
|
||||
if self.current_service == "docling-serve":
|
||||
try:
|
||||
async for log_lines in self.docling_manager.follow_logs():
|
||||
if not self.following:
|
||||
break
|
||||
|
||||
# Update logs area with new content
|
||||
current_text = self.logs_area.text
|
||||
new_text = current_text + "\n" + log_lines if current_text else log_lines
|
||||
|
||||
# Keep only last 1000 lines to prevent memory issues
|
||||
lines = new_text.split("\n")
|
||||
if len(lines) > 1000:
|
||||
lines = lines[-1000:]
|
||||
new_text = "\n".join(lines)
|
||||
|
||||
self.logs_area.text = new_text
|
||||
# Scroll to bottom if auto scroll is enabled
|
||||
if self.auto_scroll:
|
||||
self.logs_area.scroll_end()
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception as e:
|
||||
if self.following: # Only show error if we're still supposed to be following
|
||||
self.notify(f"Error following docling logs: {e}", severity="error")
|
||||
finally:
|
||||
self.following = False
|
||||
return
|
||||
|
||||
# Regular container services
|
||||
if not self.container_manager.is_available():
|
||||
return
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ from rich.text import Text
|
|||
from rich.table import Table
|
||||
|
||||
from ..managers.container_manager import ContainerManager, ServiceStatus, ServiceInfo
|
||||
from ..managers.docling_manager import DoclingManager
|
||||
from ..utils.platform import RuntimeType
|
||||
from ..widgets.command_modal import CommandOutputModal
|
||||
from ..widgets.diagnostics_notification import notify_with_diagnostics
|
||||
|
|
@ -39,12 +40,23 @@ class MonitorScreen(Screen):
|
|||
def __init__(self):
|
||||
super().__init__()
|
||||
self.container_manager = ContainerManager()
|
||||
self.docling_manager = DoclingManager()
|
||||
self.services_table = None
|
||||
self.docling_table = None
|
||||
self.images_table = None
|
||||
self.status_text = None
|
||||
self.refresh_timer = None
|
||||
self.operation_in_progress = False
|
||||
self._follow_task = None
|
||||
|
||||
# Track which table was last selected for mutual exclusion
|
||||
self._last_selected_table = None
|
||||
|
||||
def on_unmount(self) -> None:
|
||||
"""Clean up when the screen is unmounted."""
|
||||
if hasattr(self, 'docling_manager'):
|
||||
self.docling_manager.cleanup()
|
||||
super().on_unmount()
|
||||
self._follow_service = None
|
||||
self._logs_buffer = []
|
||||
|
||||
|
|
@ -64,13 +76,17 @@ class MonitorScreen(Screen):
|
|||
classes="button-row",
|
||||
id="mode-row",
|
||||
)
|
||||
# Images summary table (above services)
|
||||
|
||||
# Container Images table
|
||||
yield Static("Container Images", classes="tab-header")
|
||||
self.images_table = DataTable(id="images-table", show_cursor=False)
|
||||
self.images_table.can_focus = False
|
||||
self.images_table.add_columns("Image", "Digest")
|
||||
yield self.images_table
|
||||
yield Static(" ")
|
||||
|
||||
# Container Services table
|
||||
yield Static("Container Services", classes="tab-header")
|
||||
# Dynamic controls container; populated based on running state
|
||||
yield Horizontal(id="services-controls", classes="button-row")
|
||||
# Create services table with image + digest info
|
||||
|
|
@ -79,6 +95,16 @@ class MonitorScreen(Screen):
|
|||
"Service", "Status", "Health", "Ports", "Image", "Digest"
|
||||
)
|
||||
yield self.services_table
|
||||
yield Static(" ")
|
||||
|
||||
# Docling Services table
|
||||
yield Static("Native Services", classes="tab-header")
|
||||
# Dynamic controls for docling service
|
||||
yield Horizontal(id="docling-controls", classes="button-row")
|
||||
# Create docling table with relevant columns only
|
||||
self.docling_table = DataTable(id="docling-table")
|
||||
self.docling_table.add_columns("Service", "Status", "Port", "PID", "Actions")
|
||||
yield self.docling_table
|
||||
|
||||
def _get_runtime_status(self) -> Text:
|
||||
"""Get container runtime status text."""
|
||||
|
|
@ -164,10 +190,12 @@ class MonitorScreen(Screen):
|
|||
|
||||
# Clear existing rows
|
||||
self.services_table.clear()
|
||||
if self.docling_table:
|
||||
self.docling_table.clear()
|
||||
if self.images_table:
|
||||
self.images_table.clear()
|
||||
|
||||
# Add service rows
|
||||
# Add container service rows
|
||||
for service_name, service_info in services.items():
|
||||
status_style = self._get_status_style(service_info.status)
|
||||
|
||||
|
|
@ -179,6 +207,23 @@ class MonitorScreen(Screen):
|
|||
service_info.image or "N/A",
|
||||
digest_map.get(service_info.image or "", "-"),
|
||||
)
|
||||
|
||||
# Add docling serve to its own table
|
||||
docling_status = self.docling_manager.get_status()
|
||||
docling_running = docling_status["status"] == "running"
|
||||
docling_status_text = "running" if docling_running else "stopped"
|
||||
docling_style = "bold green" if docling_running else "bold red"
|
||||
docling_port = f"{docling_status['host']}:{docling_status['port']}" if docling_running else "N/A"
|
||||
docling_pid = str(docling_status.get("pid")) if docling_status.get("pid") else "N/A"
|
||||
|
||||
if self.docling_table:
|
||||
self.docling_table.add_row(
|
||||
"docling-serve",
|
||||
Text(docling_status_text, style=docling_style),
|
||||
docling_port,
|
||||
docling_pid,
|
||||
"Start/Stop/Logs"
|
||||
)
|
||||
# Populate images table (unique images as reported by runtime)
|
||||
if self.images_table:
|
||||
for image in sorted(images):
|
||||
|
|
@ -222,6 +267,12 @@ class MonitorScreen(Screen):
|
|||
self.run_worker(self._upgrade_services())
|
||||
elif button_id.startswith("reset-btn"):
|
||||
self.run_worker(self._reset_services())
|
||||
elif button_id.startswith("docling-start-btn"):
|
||||
self.run_worker(self._start_docling_serve())
|
||||
elif button_id.startswith("docling-stop-btn"):
|
||||
self.run_worker(self._stop_docling_serve())
|
||||
elif button_id.startswith("docling-restart-btn"):
|
||||
self.run_worker(self._restart_docling_serve())
|
||||
elif button_id == "toggle-mode-btn":
|
||||
self.action_toggle_mode()
|
||||
elif button_id.startswith("refresh-btn"):
|
||||
|
|
@ -321,6 +372,59 @@ class MonitorScreen(Screen):
|
|||
finally:
|
||||
self.operation_in_progress = False
|
||||
|
||||
async def _start_docling_serve(self) -> None:
|
||||
"""Start docling serve."""
|
||||
self.operation_in_progress = True
|
||||
try:
|
||||
success, message = await self.docling_manager.start()
|
||||
if success:
|
||||
self.notify(message, severity="information")
|
||||
else:
|
||||
self.notify(f"Failed to start docling serve: {message}", severity="error")
|
||||
# Refresh the services table to show updated status
|
||||
await self._refresh_services()
|
||||
except Exception as e:
|
||||
self.notify(f"Error starting docling serve: {str(e)}", severity="error")
|
||||
finally:
|
||||
self.operation_in_progress = False
|
||||
|
||||
async def _stop_docling_serve(self) -> None:
|
||||
"""Stop docling serve."""
|
||||
self.operation_in_progress = True
|
||||
try:
|
||||
success, message = await self.docling_manager.stop()
|
||||
if success:
|
||||
self.notify(message, severity="information")
|
||||
else:
|
||||
self.notify(f"Failed to stop docling serve: {message}", severity="error")
|
||||
# Refresh the services table to show updated status
|
||||
await self._refresh_services()
|
||||
except Exception as e:
|
||||
self.notify(f"Error stopping docling serve: {str(e)}", severity="error")
|
||||
finally:
|
||||
self.operation_in_progress = False
|
||||
|
||||
async def _restart_docling_serve(self) -> None:
|
||||
"""Restart docling serve."""
|
||||
self.operation_in_progress = True
|
||||
try:
|
||||
success, message = await self.docling_manager.restart()
|
||||
if success:
|
||||
self.notify(message, severity="information")
|
||||
else:
|
||||
self.notify(f"Failed to restart docling serve: {message}", severity="error")
|
||||
# Refresh the services table to show updated status
|
||||
await self._refresh_services()
|
||||
except Exception as e:
|
||||
self.notify(f"Error restarting docling serve: {str(e)}", severity="error")
|
||||
finally:
|
||||
self.operation_in_progress = False
|
||||
|
||||
def _view_docling_logs(self) -> None:
|
||||
"""View docling serve logs."""
|
||||
from .logs import LogsScreen
|
||||
self.app.push_screen(LogsScreen(initial_service="docling-serve"))
|
||||
|
||||
def _strip_ansi_codes(self, text: str) -> str:
|
||||
"""Strip ANSI escape sequences from text."""
|
||||
ansi_escape = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
|
||||
|
|
@ -484,12 +588,47 @@ class MonitorScreen(Screen):
|
|||
Button("Upgrade", variant="warning", id=f"upgrade-btn{suffix}")
|
||||
)
|
||||
controls.mount(Button("Reset", variant="error", id=f"reset-btn{suffix}"))
|
||||
|
||||
|
||||
except Exception as e:
|
||||
notify_with_diagnostics(
|
||||
self.app, f"Error updating controls: {e}", severity="error"
|
||||
)
|
||||
|
||||
# Update docling controls separately
|
||||
self._update_docling_controls()
|
||||
|
||||
def _update_docling_controls(self) -> None:
|
||||
"""Update docling control buttons."""
|
||||
try:
|
||||
# Get the docling controls container
|
||||
docling_controls = self.query_one("#docling-controls", Horizontal)
|
||||
|
||||
# Clear existing buttons
|
||||
docling_controls.remove_children()
|
||||
|
||||
# Use a random suffix for unique IDs
|
||||
import random
|
||||
suffix = f"-{random.randint(10000, 99999)}"
|
||||
|
||||
# Add docling serve controls
|
||||
docling_running = self.docling_manager.is_running()
|
||||
if docling_running:
|
||||
docling_controls.mount(
|
||||
Button("Stop", variant="error", id=f"docling-stop-btn{suffix}")
|
||||
)
|
||||
docling_controls.mount(
|
||||
Button("Restart", variant="primary", id=f"docling-restart-btn{suffix}")
|
||||
)
|
||||
else:
|
||||
docling_controls.mount(
|
||||
Button("Start", variant="success", id=f"docling-start-btn{suffix}")
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
notify_with_diagnostics(
|
||||
self.app, f"Error updating docling controls: {e}", severity="error"
|
||||
)
|
||||
|
||||
def action_back(self) -> None:
|
||||
"""Go back to previous screen."""
|
||||
self.app.pop_screen()
|
||||
|
|
@ -513,16 +652,60 @@ class MonitorScreen(Screen):
|
|||
def action_logs(self) -> None:
|
||||
"""View logs for the selected service."""
|
||||
try:
|
||||
# Get the currently focused row in the services table
|
||||
table = self.query_one("#services-table", DataTable)
|
||||
selected_service = self._get_selected_service()
|
||||
if selected_service:
|
||||
# Push the logs screen with the selected service
|
||||
from .logs import LogsScreen
|
||||
logs_screen = LogsScreen(initial_service=selected_service)
|
||||
self.app.push_screen(logs_screen)
|
||||
else:
|
||||
self.notify("No service selected", severity="warning")
|
||||
except Exception as e:
|
||||
self.notify(f"Error opening logs: {e}", severity="error")
|
||||
|
||||
if table.cursor_row is not None and table.cursor_row >= 0:
|
||||
# Get the service name from the first column of the selected row
|
||||
row_data = table.get_row_at(table.cursor_row)
|
||||
def _get_selected_service(self) -> str | None:
|
||||
"""Get the currently selected service from either table."""
|
||||
try:
|
||||
# Check both tables regardless of last_selected_table to handle cursor navigation
|
||||
services_table = self.query_one("#services-table", DataTable)
|
||||
services_cursor = services_table.cursor_row
|
||||
|
||||
docling_cursor = None
|
||||
if self.docling_table:
|
||||
docling_cursor = self.docling_table.cursor_row
|
||||
|
||||
# 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:
|
||||
service_name = str(row_data[0]) # First column is service name
|
||||
return "docling-serve"
|
||||
|
||||
# Map display names to actual service names
|
||||
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",
|
||||
|
|
@ -530,19 +713,30 @@ class MonitorScreen(Screen):
|
|||
"langflow": "langflow",
|
||||
"dashboards": "dashboards",
|
||||
}
|
||||
selected_service = service_mapping.get(service_name, service_name)
|
||||
return selected_service
|
||||
|
||||
actual_service_name = service_mapping.get(
|
||||
service_name, service_name
|
||||
)
|
||||
|
||||
# Push the logs screen with the selected service
|
||||
from .logs import LogsScreen
|
||||
|
||||
logs_screen = LogsScreen(initial_service=actual_service_name)
|
||||
self.app.push_screen(logs_screen)
|
||||
else:
|
||||
self.notify("No service selected", severity="warning")
|
||||
else:
|
||||
self.notify("No service selected", severity="warning")
|
||||
return None
|
||||
except Exception as e:
|
||||
self.notify(f"Error opening logs: {e}", severity="error")
|
||||
self.notify(f"Error getting selected service: {e}", severity="error")
|
||||
return None
|
||||
|
||||
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
|
||||
"""Handle row selection events to ensure mutual exclusivity."""
|
||||
selected_table = event.data_table
|
||||
|
||||
try:
|
||||
# Track which table was selected
|
||||
if selected_table.id == "services-table":
|
||||
self._last_selected_table = "services"
|
||||
# Clear docling table selection
|
||||
if self.docling_table:
|
||||
self.docling_table.cursor_row = -1
|
||||
elif selected_table.id == "docling-table":
|
||||
self._last_selected_table = "docling"
|
||||
# Clear services table selection
|
||||
services_table = self.query_one("#services-table", DataTable)
|
||||
services_table.cursor_row = -1
|
||||
except Exception:
|
||||
# Ignore errors during table manipulation
|
||||
pass
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ from dotenv import load_dotenv
|
|||
|
||||
from ..managers.container_manager import ContainerManager, ServiceStatus
|
||||
from ..managers.env_manager import EnvManager
|
||||
from ..managers.docling_manager import DoclingManager
|
||||
from ..widgets.command_modal import CommandOutputModal
|
||||
|
||||
|
||||
class WelcomeScreen(Screen):
|
||||
|
|
@ -22,15 +24,19 @@ class WelcomeScreen(Screen):
|
|||
("enter", "default_action", "Continue"),
|
||||
("1", "no_auth_setup", "Basic Setup"),
|
||||
("2", "full_setup", "Advanced Setup"),
|
||||
("3", "monitor", "Monitor Services"),
|
||||
("3", "monitor", "Status"),
|
||||
("4", "diagnostics", "Diagnostics"),
|
||||
("5", "start_stop_services", "Start/Stop Services"),
|
||||
("6", "open_app", "Open App"),
|
||||
]
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.container_manager = ContainerManager()
|
||||
self.env_manager = EnvManager()
|
||||
self.docling_manager = DoclingManager()
|
||||
self.services_running = False
|
||||
self.docling_running = False
|
||||
self.has_oauth_config = False
|
||||
self.default_button_id = "basic-setup-btn"
|
||||
self._state_checked = False
|
||||
|
|
@ -38,8 +44,16 @@ class WelcomeScreen(Screen):
|
|||
# Load .env file if it exists
|
||||
load_dotenv()
|
||||
|
||||
# Check OAuth config immediately
|
||||
self.has_oauth_config = bool(os.getenv("GOOGLE_OAUTH_CLIENT_ID")) or bool(
|
||||
os.getenv("MICROSOFT_GRAPH_OAUTH_CLIENT_ID")
|
||||
)
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Create the welcome screen layout."""
|
||||
# Try to detect services synchronously before creating buttons
|
||||
self._detect_services_sync()
|
||||
|
||||
yield Container(
|
||||
Vertical(
|
||||
Static(self._create_welcome_text(), id="welcome-text"),
|
||||
|
|
@ -50,6 +64,46 @@ class WelcomeScreen(Screen):
|
|||
)
|
||||
yield Footer()
|
||||
|
||||
def _detect_services_sync(self) -> None:
|
||||
"""Synchronously detect if services are running."""
|
||||
if not self.container_manager.is_available():
|
||||
self.services_running = False
|
||||
self.docling_running = self.docling_manager.is_running()
|
||||
return
|
||||
|
||||
try:
|
||||
# Use synchronous docker command to check services
|
||||
import subprocess
|
||||
result = subprocess.run(
|
||||
["docker", "compose", "ps", "--format", "json"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
import json
|
||||
services = []
|
||||
for line in result.stdout.strip().split('\n'):
|
||||
if line.strip():
|
||||
try:
|
||||
service = json.loads(line)
|
||||
services.append(service)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
# Check if any services are running
|
||||
running_services = [s for s in services if s.get('State') == 'running']
|
||||
self.services_running = len(running_services) > 0
|
||||
else:
|
||||
self.services_running = False
|
||||
except Exception:
|
||||
# Fallback to False if detection fails
|
||||
self.services_running = False
|
||||
|
||||
# Update native service state as part of detection
|
||||
self.docling_running = self.docling_manager.is_running()
|
||||
|
||||
def _create_welcome_text(self) -> Text:
|
||||
"""Create a minimal welcome message."""
|
||||
welcome_text = Text()
|
||||
|
|
@ -61,7 +115,7 @@ class WelcomeScreen(Screen):
|
|||
╚██████╔╝██║ ███████╗██║ ╚████║██║ ██║██║ ██║╚██████╔╝
|
||||
╚═════╝ ╚═╝ ╚══════╝╚═╝ ╚═══╝╚═╝ ╚═╝╚═╝ ╚═╝╚═════╝
|
||||
"""
|
||||
welcome_text.append(ascii_art, style="bold blue")
|
||||
welcome_text.append(ascii_art, style="bold white")
|
||||
welcome_text.append("Terminal User Interface for OpenRAG\n\n", style="dim")
|
||||
|
||||
if self.services_running:
|
||||
|
|
@ -87,28 +141,56 @@ class WelcomeScreen(Screen):
|
|||
buttons = []
|
||||
|
||||
if self.services_running:
|
||||
# Services running - only show monitor
|
||||
# Services running - show app link first, then stop services
|
||||
buttons.append(
|
||||
Button("Monitor Services", variant="success", id="monitor-btn")
|
||||
Button("Launch OpenRAG", variant="success", id="open-app-btn")
|
||||
)
|
||||
buttons.append(
|
||||
Button("Stop Container Services", variant="error", id="stop-services-btn")
|
||||
)
|
||||
else:
|
||||
# Services not running - show setup options
|
||||
# Services not running - show setup options and start services
|
||||
if has_oauth:
|
||||
# Only show advanced setup if OAuth is configured
|
||||
# If OAuth is configured, only show advanced setup
|
||||
buttons.append(
|
||||
Button("Advanced Setup", variant="success", id="advanced-setup-btn")
|
||||
)
|
||||
else:
|
||||
# Only show basic setup if no OAuth
|
||||
# If no OAuth, show both options with basic as primary
|
||||
buttons.append(
|
||||
Button("Basic Setup", variant="success", id="basic-setup-btn")
|
||||
)
|
||||
buttons.append(
|
||||
Button("Advanced Setup", variant="default", id="advanced-setup-btn")
|
||||
)
|
||||
|
||||
# Always show monitor option
|
||||
buttons.append(
|
||||
Button("Monitor Services", variant="default", id="monitor-btn")
|
||||
Button("Start Container Services", variant="primary", id="start-services-btn")
|
||||
)
|
||||
|
||||
# Native services controls
|
||||
if self.docling_running:
|
||||
buttons.append(
|
||||
Button(
|
||||
"Stop Native Services",
|
||||
variant="warning",
|
||||
id="stop-native-services-btn",
|
||||
)
|
||||
)
|
||||
else:
|
||||
buttons.append(
|
||||
Button(
|
||||
"Start Native Services",
|
||||
variant="primary",
|
||||
id="start-native-services-btn",
|
||||
)
|
||||
)
|
||||
|
||||
# Always show status option
|
||||
buttons.append(
|
||||
Button("Status", variant="default", id="status-btn")
|
||||
)
|
||||
|
||||
return Horizontal(*buttons, classes="button-row")
|
||||
|
||||
async def on_mount(self) -> None:
|
||||
|
|
@ -121,6 +203,10 @@ class WelcomeScreen(Screen):
|
|||
]
|
||||
self.services_running = len(running_services) > 0
|
||||
|
||||
# Check native service state
|
||||
self.docling_running = self.docling_manager.is_running()
|
||||
|
||||
|
||||
# Check for OAuth configuration
|
||||
self.has_oauth_config = bool(os.getenv("GOOGLE_OAUTH_CLIENT_ID")) or bool(
|
||||
os.getenv("MICROSOFT_GRAPH_OAUTH_CLIENT_ID")
|
||||
|
|
@ -128,38 +214,34 @@ class WelcomeScreen(Screen):
|
|||
|
||||
# Set default button focus
|
||||
if self.services_running:
|
||||
self.default_button_id = "monitor-btn"
|
||||
self.default_button_id = "open-app-btn"
|
||||
elif self.has_oauth_config:
|
||||
self.default_button_id = "advanced-setup-btn"
|
||||
else:
|
||||
self.default_button_id = "basic-setup-btn"
|
||||
|
||||
# Update the welcome text and recompose with new state
|
||||
# Update the welcome text
|
||||
try:
|
||||
welcome_widget = self.query_one("#welcome-text")
|
||||
welcome_widget.update(
|
||||
self._create_welcome_text()
|
||||
) # This is fine for Static widgets
|
||||
|
||||
# Focus the appropriate button
|
||||
if self.services_running:
|
||||
try:
|
||||
self.query_one("#monitor-btn").focus()
|
||||
except:
|
||||
pass
|
||||
elif self.has_oauth_config:
|
||||
try:
|
||||
self.query_one("#advanced-setup-btn").focus()
|
||||
except:
|
||||
pass
|
||||
else:
|
||||
try:
|
||||
self.query_one("#basic-setup-btn").focus()
|
||||
except:
|
||||
pass
|
||||
|
||||
welcome_widget.update(self._create_welcome_text())
|
||||
except:
|
||||
pass # Widgets might not be mounted yet
|
||||
pass # Widget might not be mounted yet
|
||||
|
||||
# Focus the appropriate button (the buttons are created correctly in compose,
|
||||
# the issue was they weren't being updated after service operations)
|
||||
self.call_after_refresh(self._focus_appropriate_button)
|
||||
|
||||
def _focus_appropriate_button(self) -> None:
|
||||
"""Focus the appropriate button based on current state."""
|
||||
try:
|
||||
if self.services_running:
|
||||
self.query_one("#open-app-btn").focus()
|
||||
elif self.has_oauth_config:
|
||||
self.query_one("#advanced-setup-btn").focus()
|
||||
else:
|
||||
self.query_one("#basic-setup-btn").focus()
|
||||
except:
|
||||
pass # Button might not exist
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
"""Handle button presses."""
|
||||
|
|
@ -167,15 +249,25 @@ class WelcomeScreen(Screen):
|
|||
self.action_no_auth_setup()
|
||||
elif event.button.id == "advanced-setup-btn":
|
||||
self.action_full_setup()
|
||||
elif event.button.id == "monitor-btn":
|
||||
elif event.button.id == "status-btn":
|
||||
self.action_monitor()
|
||||
elif event.button.id == "diagnostics-btn":
|
||||
self.action_diagnostics()
|
||||
elif event.button.id == "start-services-btn":
|
||||
self.action_start_stop_services()
|
||||
elif event.button.id == "stop-services-btn":
|
||||
self.action_start_stop_services()
|
||||
elif event.button.id == "start-native-services-btn":
|
||||
self.action_start_native_services()
|
||||
elif event.button.id == "stop-native-services-btn":
|
||||
self.action_stop_native_services()
|
||||
elif event.button.id == "open-app-btn":
|
||||
self.action_open_app()
|
||||
|
||||
def action_default_action(self) -> None:
|
||||
"""Handle Enter key - go to default action based on state."""
|
||||
if self.services_running:
|
||||
self.action_monitor()
|
||||
self.action_open_app()
|
||||
elif self.has_oauth_config:
|
||||
self.action_full_setup()
|
||||
else:
|
||||
|
|
@ -205,6 +297,126 @@ class WelcomeScreen(Screen):
|
|||
|
||||
self.app.push_screen(DiagnosticsScreen())
|
||||
|
||||
def action_start_stop_services(self) -> None:
|
||||
"""Start or stop all services (containers + docling)."""
|
||||
if self.services_running:
|
||||
# Stop services - show modal with progress
|
||||
if self.container_manager.is_available():
|
||||
command_generator = self.container_manager.stop_services()
|
||||
modal = CommandOutputModal(
|
||||
"Stopping Services",
|
||||
command_generator,
|
||||
on_complete=self._on_services_operation_complete,
|
||||
)
|
||||
self.app.push_screen(modal)
|
||||
else:
|
||||
# Start services - show modal with progress
|
||||
if self.container_manager.is_available():
|
||||
command_generator = self.container_manager.start_services()
|
||||
modal = CommandOutputModal(
|
||||
"Starting Services",
|
||||
command_generator,
|
||||
on_complete=self._on_services_operation_complete,
|
||||
)
|
||||
self.app.push_screen(modal)
|
||||
|
||||
async def _on_services_operation_complete(self) -> None:
|
||||
"""Handle completion of services start/stop operation."""
|
||||
# Use the same sync detection method that worked on startup
|
||||
self._detect_services_sync()
|
||||
|
||||
# Update OAuth config state
|
||||
self.has_oauth_config = bool(os.getenv("GOOGLE_OAUTH_CLIENT_ID")) or bool(
|
||||
os.getenv("MICROSOFT_GRAPH_OAUTH_CLIENT_ID")
|
||||
)
|
||||
|
||||
await self._refresh_welcome_content()
|
||||
|
||||
def _update_default_button(self) -> None:
|
||||
"""Update the default button target based on state."""
|
||||
if self.services_running:
|
||||
self.default_button_id = "open-app-btn"
|
||||
elif self.has_oauth_config:
|
||||
self.default_button_id = "advanced-setup-btn"
|
||||
else:
|
||||
self.default_button_id = "basic-setup-btn"
|
||||
|
||||
async def _refresh_welcome_content(self) -> None:
|
||||
"""Refresh welcome text and buttons based on current state."""
|
||||
self._update_default_button()
|
||||
|
||||
try:
|
||||
welcome_widget = self.query_one("#welcome-text", Static)
|
||||
welcome_widget.update(self._create_welcome_text())
|
||||
|
||||
welcome_container = self.query_one("#welcome-container")
|
||||
|
||||
# Remove existing button rows before mounting updated row
|
||||
for button_row in list(welcome_container.query(".button-row")):
|
||||
await button_row.remove()
|
||||
|
||||
await welcome_container.mount(self._create_dynamic_buttons())
|
||||
except Exception:
|
||||
# Fallback - just refresh the whole screen
|
||||
self.refresh(layout=True)
|
||||
|
||||
self.call_after_refresh(self._focus_appropriate_button)
|
||||
|
||||
def action_start_native_services(self) -> None:
|
||||
"""Start native services (docling)."""
|
||||
if self.docling_running:
|
||||
self.notify("Native services are already running.", severity="warning")
|
||||
return
|
||||
|
||||
self.run_worker(self._start_native_services())
|
||||
|
||||
async def _start_native_services(self) -> None:
|
||||
"""Worker task to start native services."""
|
||||
try:
|
||||
success, message = await self.docling_manager.start()
|
||||
if success:
|
||||
self.docling_running = True
|
||||
self.notify(message, severity="information")
|
||||
else:
|
||||
self.notify(f"Failed to start native services: {message}", severity="error")
|
||||
except Exception as exc:
|
||||
self.notify(f"Error starting native services: {exc}", severity="error")
|
||||
finally:
|
||||
self.docling_running = self.docling_manager.is_running()
|
||||
await self._refresh_welcome_content()
|
||||
|
||||
def action_stop_native_services(self) -> None:
|
||||
"""Stop native services (docling)."""
|
||||
if not self.docling_running and not self.docling_manager.is_running():
|
||||
self.notify("Native services are not running.", severity="warning")
|
||||
return
|
||||
|
||||
self.run_worker(self._stop_native_services())
|
||||
|
||||
async def _stop_native_services(self) -> None:
|
||||
"""Worker task to stop native services."""
|
||||
try:
|
||||
success, message = await self.docling_manager.stop()
|
||||
if success:
|
||||
self.docling_running = False
|
||||
self.notify(message, severity="information")
|
||||
else:
|
||||
self.notify(f"Failed to stop native services: {message}", severity="error")
|
||||
except Exception as exc:
|
||||
self.notify(f"Error stopping native services: {exc}", severity="error")
|
||||
finally:
|
||||
self.docling_running = self.docling_manager.is_running()
|
||||
await self._refresh_welcome_content()
|
||||
|
||||
def action_open_app(self) -> None:
|
||||
"""Open the OpenRAG app in the default browser."""
|
||||
import webbrowser
|
||||
try:
|
||||
webbrowser.open("http://localhost:3000")
|
||||
self.notify("Opening OpenRAG app in browser...", severity="information")
|
||||
except Exception as e:
|
||||
self.notify(f"Error opening app: {e}", severity="error")
|
||||
|
||||
def action_quit(self) -> None:
|
||||
"""Quit the application."""
|
||||
self.app.exit()
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"""Command output modal dialog for OpenRAG TUI."""
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
from typing import Callable, List, Optional, AsyncIterator, Any
|
||||
|
||||
from textual.app import ComposeResult
|
||||
|
|
@ -122,7 +123,13 @@ class CommandOutputModal(ModalScreen):
|
|||
# Call the completion callback if provided
|
||||
if self.on_complete:
|
||||
await asyncio.sleep(0.5) # Small delay for better UX
|
||||
self.on_complete()
|
||||
|
||||
def _invoke_callback() -> None:
|
||||
callback_result = self.on_complete()
|
||||
if inspect.isawaitable(callback_result):
|
||||
asyncio.create_task(callback_result)
|
||||
|
||||
self.call_after_refresh(_invoke_callback)
|
||||
except Exception as e:
|
||||
output.write(f"[bold red]Error: {e}[/bold red]\n")
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue