Merge branch 'main' of github.com:langflow-ai/openrag
This commit is contained in:
commit
e95ade58a5
29 changed files with 3848 additions and 3457 deletions
|
|
@ -21,7 +21,7 @@ COPY pyproject.toml uv.lock ./
|
||||||
RUN uv sync
|
RUN uv sync
|
||||||
|
|
||||||
# Copy sample document and warmup script for docling
|
# Copy sample document and warmup script for docling
|
||||||
COPY documents/warmup_ocr.pdf ./
|
COPY openrag-documents/warmup_ocr.pdf ./
|
||||||
COPY warm_up_docling.py ./
|
COPY warm_up_docling.py ./
|
||||||
RUN uv run docling-tools models download
|
RUN uv run docling-tools models download
|
||||||
RUN uv run python - <<'PY'
|
RUN uv run python - <<'PY'
|
||||||
|
|
|
||||||
|
|
@ -81,9 +81,10 @@ services:
|
||||||
- AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID}
|
- AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID}
|
||||||
- AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY}
|
- AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY}
|
||||||
volumes:
|
volumes:
|
||||||
- ./openrag-documents:/app/documents:Z
|
- ./openrag-documents:/app/openrag-documents:Z
|
||||||
- ./keys:/app/keys:Z
|
- ./keys:/app/keys:Z
|
||||||
- ./flows:/app/flows:U,z
|
- ./flows:/app/flows:U,z
|
||||||
|
- ./config:/app/config:Z
|
||||||
|
|
||||||
openrag-frontend:
|
openrag-frontend:
|
||||||
image: langflowai/openrag-frontend:${OPENRAG_VERSION:-latest}
|
image: langflowai/openrag-frontend:${OPENRAG_VERSION:-latest}
|
||||||
|
|
|
||||||
|
|
@ -80,9 +80,10 @@ services:
|
||||||
- AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID}
|
- AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID}
|
||||||
- AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY}
|
- AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY}
|
||||||
volumes:
|
volumes:
|
||||||
- ./openrag-documents:/app/documents:Z
|
- ./openrag-documents:/app/openrag-documents:Z
|
||||||
- ./keys:/app/keys:Z
|
- ./keys:/app/keys:Z
|
||||||
- ./flows:/app/flows:U,z
|
- ./flows:/app/flows:U,z
|
||||||
|
- ./config:/app/config:Z
|
||||||
|
|
||||||
openrag-frontend:
|
openrag-frontend:
|
||||||
image: langflowai/openrag-frontend:${OPENRAG_VERSION:-latest}
|
image: langflowai/openrag-frontend:${OPENRAG_VERSION:-latest}
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ To configure the knowledge ingestion pipeline parameters, see [Docling Ingestion
|
||||||
|
|
||||||
The **Knowledge Ingest** flow uses Langflow's [**File** component](https://docs.langflow.org/components-data#file) to split and embed files loaded from your local machine into the OpenSearch database.
|
The **Knowledge Ingest** flow uses Langflow's [**File** component](https://docs.langflow.org/components-data#file) to split and embed files loaded from your local machine into the OpenSearch database.
|
||||||
|
|
||||||
The default path to your local folder is mounted from the `./openrag-documents` folder in your OpenRAG project directory to the `/app/documents/` directory inside the Docker container. Files added to the host or the container will be visible in both locations. To configure this location, modify the **Documents Paths** variable in either the TUI's [Advanced Setup](/install#setup) menu or in the `.env` used by Docker Compose.
|
The default path to your local folder is mounted from the `./openrag-documents` folder in your OpenRAG project directory to the `/app/openrag-documents/` directory inside the Docker container. Files added to the host or the container will be visible in both locations. To configure this location, modify the **Documents Paths** variable in either the TUI's [Advanced Setup](/install#setup) menu or in the `.env` used by Docker Compose.
|
||||||
|
|
||||||
To load and process a single file from the mapped location, click **Add Knowledge**, and then click <Icon name="File" aria-hidden="true"/> **File**.
|
To load and process a single file from the mapped location, click **Add Knowledge**, and then click <Icon name="File" aria-hidden="true"/> **File**.
|
||||||
The file is loaded into your OpenSearch database, and appears in the Knowledge page.
|
The file is loaded into your OpenSearch database, and appears in the Knowledge page.
|
||||||
|
|
|
||||||
|
|
@ -5712,7 +5712,7 @@
|
||||||
"endpoint_name": null,
|
"endpoint_name": null,
|
||||||
"id": "5488df7c-b93f-4f87-a446-b67028bc0813",
|
"id": "5488df7c-b93f-4f87-a446-b67028bc0813",
|
||||||
"is_component": false,
|
"is_component": false,
|
||||||
"last_tested_version": "1.7.0.dev21",
|
"last_tested_version": "1.7.0.dev19",
|
||||||
"name": "OpenSearch Ingestion Flow",
|
"name": "OpenSearch Ingestion Flow",
|
||||||
"tags": [
|
"tags": [
|
||||||
"openai",
|
"openai",
|
||||||
|
|
|
||||||
|
|
@ -4507,6 +4507,7 @@
|
||||||
"endpoint_name": null,
|
"endpoint_name": null,
|
||||||
"id": "1098eea1-6649-4e1d-aed1-b77249fb8dd0",
|
"id": "1098eea1-6649-4e1d-aed1-b77249fb8dd0",
|
||||||
"is_component": false,
|
"is_component": false,
|
||||||
|
"locked": true,
|
||||||
"last_tested_version": "1.7.0.dev21",
|
"last_tested_version": "1.7.0.dev21",
|
||||||
"name": "OpenRAG OpenSearch Agent",
|
"name": "OpenRAG OpenSearch Agent",
|
||||||
"tags": [
|
"tags": [
|
||||||
|
|
|
||||||
|
|
@ -4088,6 +4088,7 @@
|
||||||
"endpoint_name": null,
|
"endpoint_name": null,
|
||||||
"id": "ebc01d31-1976-46ce-a385-b0240327226c",
|
"id": "ebc01d31-1976-46ce-a385-b0240327226c",
|
||||||
"is_component": false,
|
"is_component": false,
|
||||||
|
"locked": true,
|
||||||
"last_tested_version": "1.7.0.dev21",
|
"last_tested_version": "1.7.0.dev21",
|
||||||
"name": "OpenRAG OpenSearch Nudges",
|
"name": "OpenRAG OpenSearch Nudges",
|
||||||
"tags": [
|
"tags": [
|
||||||
|
|
|
||||||
|
|
@ -6052,6 +6052,7 @@
|
||||||
"endpoint_name": null,
|
"endpoint_name": null,
|
||||||
"id": "72c3d17c-2dac-4a73-b48a-6518473d7830",
|
"id": "72c3d17c-2dac-4a73-b48a-6518473d7830",
|
||||||
"is_component": false,
|
"is_component": false,
|
||||||
|
"locked": true,
|
||||||
"mcp_enabled": true,
|
"mcp_enabled": true,
|
||||||
"last_tested_version": "1.7.0.dev21",
|
"last_tested_version": "1.7.0.dev21",
|
||||||
"name": "OpenSearch URL Ingestion Flow",
|
"name": "OpenSearch URL Ingestion Flow",
|
||||||
|
|
|
||||||
|
|
@ -1,364 +1,364 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { Cloud, FolderOpen, Loader2, Upload } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { ProtectedRoute } from "@/components/protected-route";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardDescription,
|
CardDescription,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Upload, FolderOpen, Loader2, Cloud } from "lucide-react";
|
|
||||||
import { ProtectedRoute } from "@/components/protected-route";
|
|
||||||
import { useTask } from "@/contexts/task-context";
|
import { useTask } from "@/contexts/task-context";
|
||||||
|
|
||||||
function AdminPage() {
|
function AdminPage() {
|
||||||
console.log("AdminPage component rendered!");
|
console.log("AdminPage component rendered!");
|
||||||
const [fileUploadLoading, setFileUploadLoading] = useState(false);
|
const [fileUploadLoading, setFileUploadLoading] = useState(false);
|
||||||
const [pathUploadLoading, setPathUploadLoading] = useState(false);
|
const [pathUploadLoading, setPathUploadLoading] = useState(false);
|
||||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||||
const [folderPath, setFolderPath] = useState("/app/documents/");
|
const [folderPath, setFolderPath] = useState("/app/openrag-documents/");
|
||||||
const [bucketUploadLoading, setBucketUploadLoading] = useState(false);
|
const [bucketUploadLoading, setBucketUploadLoading] = useState(false);
|
||||||
const [bucketUrl, setBucketUrl] = useState("s3://");
|
const [bucketUrl, setBucketUrl] = useState("s3://");
|
||||||
const [uploadStatus, setUploadStatus] = useState<string>("");
|
const [uploadStatus, setUploadStatus] = useState<string>("");
|
||||||
const [awsEnabled, setAwsEnabled] = useState(false);
|
const [awsEnabled, setAwsEnabled] = useState(false);
|
||||||
const { addTask } = useTask();
|
const { addTask } = useTask();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log("AdminPage useEffect running - checking AWS availability");
|
console.log("AdminPage useEffect running - checking AWS availability");
|
||||||
const checkAws = async () => {
|
const checkAws = async () => {
|
||||||
try {
|
try {
|
||||||
console.log("Making request to /api/upload_options");
|
console.log("Making request to /api/upload_options");
|
||||||
const res = await fetch("/api/upload_options");
|
const res = await fetch("/api/upload_options");
|
||||||
console.log("Response status:", res.status, "OK:", res.ok);
|
console.log("Response status:", res.status, "OK:", res.ok);
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
console.log("Response data:", data);
|
console.log("Response data:", data);
|
||||||
setAwsEnabled(Boolean(data.aws));
|
setAwsEnabled(Boolean(data.aws));
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to check AWS availability", err);
|
console.error("Failed to check AWS availability", err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
checkAws();
|
checkAws();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleFileUpload = async (e: React.FormEvent) => {
|
const handleFileUpload = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!selectedFile) return;
|
if (!selectedFile) return;
|
||||||
|
|
||||||
setFileUploadLoading(true);
|
setFileUploadLoading(true);
|
||||||
setUploadStatus("");
|
setUploadStatus("");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("file", selectedFile);
|
formData.append("file", selectedFile);
|
||||||
|
|
||||||
const response = await fetch("/api/router/upload_ingest", {
|
const response = await fetch("/api/router/upload_ingest", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
setUploadStatus(`File uploaded successfully! ID: ${result.id}`);
|
setUploadStatus(`File uploaded successfully! ID: ${result.id}`);
|
||||||
setSelectedFile(null);
|
setSelectedFile(null);
|
||||||
// Reset the file input
|
// Reset the file input
|
||||||
const fileInput = document.getElementById(
|
const fileInput = document.getElementById(
|
||||||
"file-input",
|
"file-input",
|
||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
if (fileInput) fileInput.value = "";
|
if (fileInput) fileInput.value = "";
|
||||||
} else {
|
} else {
|
||||||
setUploadStatus(`Error: ${result.error || "Upload failed"}`);
|
setUploadStatus(`Error: ${result.error || "Upload failed"}`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setUploadStatus(
|
setUploadStatus(
|
||||||
`Error: ${error instanceof Error ? error.message : "Upload failed"}`,
|
`Error: ${error instanceof Error ? error.message : "Upload failed"}`,
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setFileUploadLoading(false);
|
setFileUploadLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBucketUpload = async (e: React.FormEvent) => {
|
const handleBucketUpload = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!bucketUrl.trim()) return;
|
if (!bucketUrl.trim()) return;
|
||||||
|
|
||||||
setBucketUploadLoading(true);
|
setBucketUploadLoading(true);
|
||||||
setUploadStatus("");
|
setUploadStatus("");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/upload_bucket", {
|
const response = await fetch("/api/upload_bucket", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ s3_url: bucketUrl }),
|
body: JSON.stringify({ s3_url: bucketUrl }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
|
||||||
if (response.status === 201) {
|
if (response.status === 201) {
|
||||||
const taskId = result.task_id || result.id;
|
const taskId = result.task_id || result.id;
|
||||||
const totalFiles = result.total_files || 0;
|
const totalFiles = result.total_files || 0;
|
||||||
|
|
||||||
if (!taskId) {
|
if (!taskId) {
|
||||||
throw new Error("No task ID received from server");
|
throw new Error("No task ID received from server");
|
||||||
}
|
}
|
||||||
|
|
||||||
addTask(taskId);
|
addTask(taskId);
|
||||||
setUploadStatus(
|
setUploadStatus(
|
||||||
`🔄 Processing started for ${totalFiles} files. Check the task notification panel for real-time progress. (Task ID: ${taskId})`,
|
`🔄 Processing started for ${totalFiles} files. Check the task notification panel for real-time progress. (Task ID: ${taskId})`,
|
||||||
);
|
);
|
||||||
setBucketUrl("");
|
setBucketUrl("");
|
||||||
} else {
|
} else {
|
||||||
setUploadStatus(`Error: ${result.error || "Bucket processing failed"}`);
|
setUploadStatus(`Error: ${result.error || "Bucket processing failed"}`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setUploadStatus(
|
setUploadStatus(
|
||||||
`Error: ${error instanceof Error ? error.message : "Bucket processing failed"}`,
|
`Error: ${error instanceof Error ? error.message : "Bucket processing failed"}`,
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setBucketUploadLoading(false);
|
setBucketUploadLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePathUpload = async (e: React.FormEvent) => {
|
const handlePathUpload = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!folderPath.trim()) return;
|
if (!folderPath.trim()) return;
|
||||||
|
|
||||||
setPathUploadLoading(true);
|
setPathUploadLoading(true);
|
||||||
setUploadStatus("");
|
setUploadStatus("");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/upload_path", {
|
const response = await fetch("/api/upload_path", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ path: folderPath }),
|
body: JSON.stringify({ path: folderPath }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
|
||||||
if (response.status === 201) {
|
if (response.status === 201) {
|
||||||
// New flow: Got task ID, use centralized tracking
|
// New flow: Got task ID, use centralized tracking
|
||||||
const taskId = result.task_id || result.id;
|
const taskId = result.task_id || result.id;
|
||||||
const totalFiles = result.total_files || 0;
|
const totalFiles = result.total_files || 0;
|
||||||
|
|
||||||
if (!taskId) {
|
if (!taskId) {
|
||||||
throw new Error("No task ID received from server");
|
throw new Error("No task ID received from server");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add task to centralized tracking
|
// Add task to centralized tracking
|
||||||
addTask(taskId);
|
addTask(taskId);
|
||||||
|
|
||||||
setUploadStatus(
|
setUploadStatus(
|
||||||
`🔄 Processing started for ${totalFiles} files. Check the task notification panel for real-time progress. (Task ID: ${taskId})`,
|
`🔄 Processing started for ${totalFiles} files. Check the task notification panel for real-time progress. (Task ID: ${taskId})`,
|
||||||
);
|
);
|
||||||
setFolderPath("");
|
setFolderPath("");
|
||||||
setPathUploadLoading(false);
|
setPathUploadLoading(false);
|
||||||
} else if (response.ok) {
|
} else if (response.ok) {
|
||||||
// Original flow: Direct response with results
|
// Original flow: Direct response with results
|
||||||
const successful =
|
const successful =
|
||||||
result.results?.filter(
|
result.results?.filter(
|
||||||
(r: { status: string }) => r.status === "indexed",
|
(r: { status: string }) => r.status === "indexed",
|
||||||
).length || 0;
|
).length || 0;
|
||||||
const total = result.results?.length || 0;
|
const total = result.results?.length || 0;
|
||||||
setUploadStatus(
|
setUploadStatus(
|
||||||
`Path processed successfully! ${successful}/${total} files indexed.`,
|
`Path processed successfully! ${successful}/${total} files indexed.`,
|
||||||
);
|
);
|
||||||
setFolderPath("");
|
setFolderPath("");
|
||||||
setPathUploadLoading(false);
|
setPathUploadLoading(false);
|
||||||
} else {
|
} else {
|
||||||
setUploadStatus(`Error: ${result.error || "Path upload failed"}`);
|
setUploadStatus(`Error: ${result.error || "Path upload failed"}`);
|
||||||
setPathUploadLoading(false);
|
setPathUploadLoading(false);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setUploadStatus(
|
setUploadStatus(
|
||||||
`Error: ${error instanceof Error ? error.message : "Path upload failed"}`,
|
`Error: ${error instanceof Error ? error.message : "Path upload failed"}`,
|
||||||
);
|
);
|
||||||
setPathUploadLoading(false);
|
setPathUploadLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Remove the old pollPathTaskStatus function since we're using centralized system
|
// Remove the old pollPathTaskStatus function since we're using centralized system
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold">Ingest</h1>
|
<h1 className="text-3xl font-bold">Ingest</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Upload and manage documents in your database
|
Upload and manage documents in your database
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{uploadStatus && (
|
{uploadStatus && (
|
||||||
<Card
|
<Card
|
||||||
className={
|
className={
|
||||||
uploadStatus.includes("Error")
|
uploadStatus.includes("Error")
|
||||||
? "border-destructive"
|
? "border-destructive"
|
||||||
: "border-green-500"
|
: "border-green-500"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<p
|
<p
|
||||||
className={
|
className={
|
||||||
uploadStatus.includes("Error")
|
uploadStatus.includes("Error")
|
||||||
? "text-destructive"
|
? "text-destructive"
|
||||||
: "text-green-600"
|
: "text-green-600"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{uploadStatus}
|
{uploadStatus}
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid gap-6 md:grid-cols-3">
|
<div className="grid gap-6 md:grid-cols-3">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Upload className="h-5 w-5" />
|
<Upload className="h-5 w-5" />
|
||||||
Upload File
|
Upload File
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Upload a single document to be indexed and searchable
|
Upload a single document to be indexed and searchable
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form onSubmit={handleFileUpload} className="space-y-4">
|
<form onSubmit={handleFileUpload} className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="file-input">Select File</Label>
|
<Label htmlFor="file-input">Select File</Label>
|
||||||
<Input
|
<Input
|
||||||
id="file-input"
|
id="file-input"
|
||||||
type="file"
|
type="file"
|
||||||
onChange={(e) => setSelectedFile(e.target.files?.[0] || null)}
|
onChange={(e) => setSelectedFile(e.target.files?.[0] || null)}
|
||||||
accept=".pdf,.doc,.docx,.txt,.md"
|
accept=".pdf,.doc,.docx,.txt,.md"
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={!selectedFile || fileUploadLoading}
|
disabled={!selectedFile || fileUploadLoading}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
>
|
>
|
||||||
{fileUploadLoading ? (
|
{fileUploadLoading ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
Uploading...
|
Uploading...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Upload className="mr-2 h-4 w-4" />
|
<Upload className="mr-2 h-4 w-4" />
|
||||||
Upload File
|
Upload File
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<FolderOpen className="h-5 w-5" />
|
<FolderOpen className="h-5 w-5" />
|
||||||
Upload Folder
|
Upload Folder
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Process all documents in a folder path on the server
|
Process all documents in a folder path on the server
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form onSubmit={handlePathUpload} className="space-y-4">
|
<form onSubmit={handlePathUpload} className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="folder-path">Folder Path</Label>
|
<Label htmlFor="folder-path">Folder Path</Label>
|
||||||
<Input
|
<Input
|
||||||
id="folder-path"
|
id="folder-path"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="/path/to/documents"
|
placeholder="/path/to/documents"
|
||||||
value={folderPath}
|
value={folderPath}
|
||||||
onChange={(e) => setFolderPath(e.target.value)}
|
onChange={(e) => setFolderPath(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={!folderPath.trim() || pathUploadLoading}
|
disabled={!folderPath.trim() || pathUploadLoading}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
>
|
>
|
||||||
{pathUploadLoading ? (
|
{pathUploadLoading ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
Processing...
|
Processing...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<FolderOpen className="mr-2 h-4 w-4" />
|
<FolderOpen className="mr-2 h-4 w-4" />
|
||||||
Process Folder
|
Process Folder
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
{awsEnabled && (
|
{awsEnabled && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Cloud className="h-5 w-5" />
|
<Cloud className="h-5 w-5" />
|
||||||
Process Bucket
|
Process Bucket
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Process all documents from an S3 bucket. AWS credentials must be
|
Process all documents from an S3 bucket. AWS credentials must be
|
||||||
set as environment variables.
|
set as environment variables.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form onSubmit={handleBucketUpload} className="space-y-4">
|
<form onSubmit={handleBucketUpload} className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="bucket-url">S3 URL</Label>
|
<Label htmlFor="bucket-url">S3 URL</Label>
|
||||||
<Input
|
<Input
|
||||||
id="bucket-url"
|
id="bucket-url"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="s3://bucket/path"
|
placeholder="s3://bucket/path"
|
||||||
value={bucketUrl}
|
value={bucketUrl}
|
||||||
onChange={(e) => setBucketUrl(e.target.value)}
|
onChange={(e) => setBucketUrl(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={!bucketUrl.trim() || bucketUploadLoading}
|
disabled={!bucketUrl.trim() || bucketUploadLoading}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
>
|
>
|
||||||
{bucketUploadLoading ? (
|
{bucketUploadLoading ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
Processing...
|
Processing...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Cloud className="mr-2 h-4 w-4" />
|
<Cloud className="mr-2 h-4 w-4" />
|
||||||
Process Bucket
|
Process Bucket
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ProtectedAdminPage() {
|
export default function ProtectedAdminPage() {
|
||||||
return (
|
return (
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<AdminPage />
|
<AdminPage />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "openrag"
|
name = "openrag"
|
||||||
version = "0.1.42"
|
version = "0.1.47"
|
||||||
description = "Add your description here"
|
description = "Add your description here"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.13"
|
requires-python = ">=3.13"
|
||||||
|
|
|
||||||
|
|
@ -423,10 +423,7 @@ async def update_settings(request, session_manager):
|
||||||
# Also update the chat flow with the new system prompt
|
# Also update the chat flow with the new system prompt
|
||||||
try:
|
try:
|
||||||
flows_service = _get_flows_service()
|
flows_service = _get_flows_service()
|
||||||
await flows_service.update_chat_flow_system_prompt(
|
await _update_langflow_system_prompt(current_config, flows_service)
|
||||||
body["system_prompt"], current_config.agent.system_prompt
|
|
||||||
)
|
|
||||||
logger.info(f"Successfully updated chat flow system prompt")
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to update chat flow system prompt: {str(e)}")
|
logger.error(f"Failed to update chat flow system prompt: {str(e)}")
|
||||||
# Don't fail the entire settings update if flow update fails
|
# Don't fail the entire settings update if flow update fails
|
||||||
|
|
@ -449,13 +446,7 @@ async def update_settings(request, session_manager):
|
||||||
# Also update the flow with the new docling settings
|
# Also update the flow with the new docling settings
|
||||||
try:
|
try:
|
||||||
flows_service = _get_flows_service()
|
flows_service = _get_flows_service()
|
||||||
preset_config = get_docling_preset_configs(
|
await _update_langflow_docling_settings(current_config, flows_service)
|
||||||
table_structure=body["table_structure"],
|
|
||||||
ocr=current_config.knowledge.ocr,
|
|
||||||
picture_descriptions=current_config.knowledge.picture_descriptions,
|
|
||||||
)
|
|
||||||
await flows_service.update_flow_docling_preset("custom", preset_config)
|
|
||||||
logger.info(f"Successfully updated table_structure setting in flow")
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to update docling settings in flow: {str(e)}")
|
logger.error(f"Failed to update docling settings in flow: {str(e)}")
|
||||||
|
|
||||||
|
|
@ -466,13 +457,7 @@ async def update_settings(request, session_manager):
|
||||||
# Also update the flow with the new docling settings
|
# Also update the flow with the new docling settings
|
||||||
try:
|
try:
|
||||||
flows_service = _get_flows_service()
|
flows_service = _get_flows_service()
|
||||||
preset_config = get_docling_preset_configs(
|
await _update_langflow_docling_settings(current_config, flows_service)
|
||||||
table_structure=current_config.knowledge.table_structure,
|
|
||||||
ocr=body["ocr"],
|
|
||||||
picture_descriptions=current_config.knowledge.picture_descriptions,
|
|
||||||
)
|
|
||||||
await flows_service.update_flow_docling_preset("custom", preset_config)
|
|
||||||
logger.info(f"Successfully updated ocr setting in flow")
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to update docling settings in flow: {str(e)}")
|
logger.error(f"Failed to update docling settings in flow: {str(e)}")
|
||||||
|
|
||||||
|
|
@ -483,15 +468,7 @@ async def update_settings(request, session_manager):
|
||||||
# Also update the flow with the new docling settings
|
# Also update the flow with the new docling settings
|
||||||
try:
|
try:
|
||||||
flows_service = _get_flows_service()
|
flows_service = _get_flows_service()
|
||||||
preset_config = get_docling_preset_configs(
|
await _update_langflow_docling_settings(current_config, flows_service)
|
||||||
table_structure=current_config.knowledge.table_structure,
|
|
||||||
ocr=current_config.knowledge.ocr,
|
|
||||||
picture_descriptions=body["picture_descriptions"],
|
|
||||||
)
|
|
||||||
await flows_service.update_flow_docling_preset("custom", preset_config)
|
|
||||||
logger.info(
|
|
||||||
f"Successfully updated picture_descriptions setting in flow"
|
|
||||||
)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to update docling settings in flow: {str(e)}")
|
logger.error(f"Failed to update docling settings in flow: {str(e)}")
|
||||||
|
|
||||||
|
|
@ -571,7 +548,7 @@ async def update_settings(request, session_manager):
|
||||||
{"error": "Failed to save configuration"}, status_code=500
|
{"error": "Failed to save configuration"}, status_code=500
|
||||||
)
|
)
|
||||||
|
|
||||||
# Update Langflow global variables if provider settings changed
|
# Update Langflow global variables and model values if provider settings changed
|
||||||
provider_fields_to_check = [
|
provider_fields_to_check = [
|
||||||
"llm_provider", "embedding_provider",
|
"llm_provider", "embedding_provider",
|
||||||
"openai_api_key", "anthropic_api_key",
|
"openai_api_key", "anthropic_api_key",
|
||||||
|
|
@ -580,99 +557,17 @@ async def update_settings(request, session_manager):
|
||||||
]
|
]
|
||||||
if any(key in body for key in provider_fields_to_check):
|
if any(key in body for key in provider_fields_to_check):
|
||||||
try:
|
try:
|
||||||
# Update WatsonX global variables if changed
|
flows_service = _get_flows_service()
|
||||||
if "watsonx_api_key" in body:
|
|
||||||
await clients._create_langflow_global_variable(
|
|
||||||
"WATSONX_API_KEY", current_config.providers.watsonx.api_key, modify=True
|
|
||||||
)
|
|
||||||
logger.info("Set WATSONX_API_KEY global variable in Langflow")
|
|
||||||
|
|
||||||
if "watsonx_project_id" in body:
|
|
||||||
await clients._create_langflow_global_variable(
|
|
||||||
"WATSONX_PROJECT_ID", current_config.providers.watsonx.project_id, modify=True
|
|
||||||
)
|
|
||||||
logger.info("Set WATSONX_PROJECT_ID global variable in Langflow")
|
|
||||||
|
|
||||||
# Update OpenAI global variables if changed
|
|
||||||
if "openai_api_key" in body:
|
|
||||||
await clients._create_langflow_global_variable(
|
|
||||||
"OPENAI_API_KEY", current_config.providers.openai.api_key, modify=True
|
|
||||||
)
|
|
||||||
logger.info("Set OPENAI_API_KEY global variable in Langflow")
|
|
||||||
|
|
||||||
# Update Anthropic global variables if changed
|
|
||||||
if "anthropic_api_key" in body:
|
|
||||||
await clients._create_langflow_global_variable(
|
|
||||||
"ANTHROPIC_API_KEY", current_config.providers.anthropic.api_key, modify=True
|
|
||||||
)
|
|
||||||
logger.info("Set ANTHROPIC_API_KEY global variable in Langflow")
|
|
||||||
|
|
||||||
# Update Ollama global variables if changed
|
|
||||||
if "ollama_endpoint" in body:
|
|
||||||
endpoint = transform_localhost_url(current_config.providers.ollama.endpoint)
|
|
||||||
await clients._create_langflow_global_variable(
|
|
||||||
"OLLAMA_BASE_URL", endpoint, modify=True
|
|
||||||
)
|
|
||||||
logger.info("Set OLLAMA_BASE_URL global variable in Langflow")
|
|
||||||
|
|
||||||
# Update LLM model values across flows if provider or model changed
|
|
||||||
if "llm_provider" in body or "llm_model" in body:
|
|
||||||
flows_service = _get_flows_service()
|
|
||||||
llm_provider = current_config.agent.llm_provider.lower()
|
|
||||||
llm_provider_config = current_config.get_llm_provider_config()
|
|
||||||
llm_endpoint = getattr(llm_provider_config, "endpoint", None)
|
|
||||||
await flows_service.change_langflow_model_value(
|
|
||||||
llm_provider,
|
|
||||||
llm_model=current_config.agent.llm_model,
|
|
||||||
endpoint=llm_endpoint,
|
|
||||||
)
|
|
||||||
logger.info(
|
|
||||||
f"Successfully updated Langflow flows for LLM provider {llm_provider}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Update SELECTED_EMBEDDING_MODEL global variable (no flow updates needed)
|
|
||||||
if "embedding_provider" in body or "embedding_model" in body:
|
|
||||||
await clients._create_langflow_global_variable(
|
|
||||||
"SELECTED_EMBEDDING_MODEL", current_config.knowledge.embedding_model, modify=True
|
|
||||||
)
|
|
||||||
logger.info(
|
|
||||||
f"Set SELECTED_EMBEDDING_MODEL global variable to {current_config.knowledge.embedding_model}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Update MCP servers with provider credentials
|
# Update global variables
|
||||||
try:
|
await _update_langflow_global_variables(current_config)
|
||||||
from services.langflow_mcp_service import LangflowMCPService
|
|
||||||
from utils.langflow_headers import build_mcp_global_vars_from_config
|
if "embedding_provider" in body or "embedding_model" in body:
|
||||||
|
await _update_mcp_servers_with_provider_credentials(current_config)
|
||||||
mcp_service = LangflowMCPService()
|
|
||||||
|
# Update model values if provider or model changed
|
||||||
# Build global vars using utility function
|
if "llm_provider" in body or "llm_model" in body or "embedding_provider" in body or "embedding_model" in body:
|
||||||
mcp_global_vars = build_mcp_global_vars_from_config(current_config)
|
await _update_langflow_model_values(current_config, flows_service)
|
||||||
|
|
||||||
# In no-auth mode, add the anonymous JWT token and user details
|
|
||||||
if is_no_auth_mode() and session_manager:
|
|
||||||
from session_manager import AnonymousUser
|
|
||||||
|
|
||||||
# Create/get anonymous JWT for no-auth mode
|
|
||||||
anonymous_jwt = session_manager.get_effective_jwt_token(None, None)
|
|
||||||
if anonymous_jwt:
|
|
||||||
mcp_global_vars["JWT"] = anonymous_jwt
|
|
||||||
|
|
||||||
# Add anonymous user details
|
|
||||||
anonymous_user = AnonymousUser()
|
|
||||||
mcp_global_vars["OWNER"] = anonymous_user.user_id # "anonymous"
|
|
||||||
mcp_global_vars["OWNER_NAME"] = f'"{anonymous_user.name}"' # "Anonymous User" (quoted)
|
|
||||||
mcp_global_vars["OWNER_EMAIL"] = anonymous_user.email # "anonymous@localhost"
|
|
||||||
|
|
||||||
logger.debug("Added anonymous JWT and user details to MCP servers for no-auth mode")
|
|
||||||
|
|
||||||
if mcp_global_vars:
|
|
||||||
result = await mcp_service.update_mcp_servers_with_global_vars(mcp_global_vars)
|
|
||||||
logger.info("Updated MCP servers with provider credentials after settings change", **result)
|
|
||||||
|
|
||||||
except Exception as mcp_error:
|
|
||||||
logger.warning(f"Failed to update MCP servers after settings change: {str(mcp_error)}")
|
|
||||||
# Don't fail the entire settings update if MCP update fails
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to update Langflow settings: {str(e)}")
|
logger.error(f"Failed to update Langflow settings: {str(e)}")
|
||||||
|
|
@ -922,102 +817,32 @@ async def onboarding(request, flows_service, session_manager=None):
|
||||||
status_code=400,
|
status_code=400,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Set Langflow global variables based on provider configuration
|
# Set Langflow global variables and model values based on provider configuration
|
||||||
try:
|
try:
|
||||||
# Set WatsonX global variables
|
# Check if any provider-related fields were provided
|
||||||
if "watsonx_api_key" in body:
|
provider_fields_provided = any(key in body for key in [
|
||||||
await clients._create_langflow_global_variable(
|
"openai_api_key", "anthropic_api_key",
|
||||||
"WATSONX_API_KEY", current_config.providers.watsonx.api_key, modify=True
|
"watsonx_api_key", "watsonx_endpoint", "watsonx_project_id",
|
||||||
)
|
"ollama_endpoint"
|
||||||
logger.info("Set WATSONX_API_KEY global variable in Langflow")
|
])
|
||||||
|
|
||||||
if "watsonx_project_id" in body:
|
|
||||||
await clients._create_langflow_global_variable(
|
|
||||||
"WATSONX_PROJECT_ID", current_config.providers.watsonx.project_id, modify=True
|
|
||||||
)
|
|
||||||
logger.info("Set WATSONX_PROJECT_ID global variable in Langflow")
|
|
||||||
|
|
||||||
# Set OpenAI global variables
|
|
||||||
if "openai_api_key" in body or current_config.providers.openai.api_key != "":
|
|
||||||
await clients._create_langflow_global_variable(
|
|
||||||
"OPENAI_API_KEY", current_config.providers.openai.api_key, modify=True
|
|
||||||
)
|
|
||||||
logger.info("Set OPENAI_API_KEY global variable in Langflow")
|
|
||||||
|
|
||||||
# Set Anthropic global variables
|
|
||||||
if "anthropic_api_key" in body or current_config.providers.anthropic.api_key != "":
|
|
||||||
await clients._create_langflow_global_variable(
|
|
||||||
"ANTHROPIC_API_KEY", current_config.providers.anthropic.api_key, modify=True
|
|
||||||
)
|
|
||||||
logger.info("Set ANTHROPIC_API_KEY global variable in Langflow")
|
|
||||||
|
|
||||||
# Set Ollama global variables
|
|
||||||
if "ollama_endpoint" in body:
|
|
||||||
endpoint = transform_localhost_url(current_config.providers.ollama.endpoint)
|
|
||||||
await clients._create_langflow_global_variable(
|
|
||||||
"OLLAMA_BASE_URL", endpoint, modify=True
|
|
||||||
)
|
|
||||||
logger.info("Set OLLAMA_BASE_URL global variable in Langflow")
|
|
||||||
|
|
||||||
# Update flows with LLM model values
|
|
||||||
if "llm_provider" in body or "llm_model" in body:
|
|
||||||
llm_provider = current_config.agent.llm_provider.lower()
|
|
||||||
llm_provider_config = current_config.get_llm_provider_config()
|
|
||||||
llm_endpoint = getattr(llm_provider_config, "endpoint", None)
|
|
||||||
await flows_service.change_langflow_model_value(
|
|
||||||
provider=llm_provider,
|
|
||||||
llm_model=current_config.agent.llm_model,
|
|
||||||
endpoint=llm_endpoint,
|
|
||||||
)
|
|
||||||
logger.info(f"Updated Langflow flows for LLM provider {llm_provider}")
|
|
||||||
|
|
||||||
# Set SELECTED_EMBEDDING_MODEL global variable (no flow updates needed)
|
|
||||||
if "embedding_provider" in body or "embedding_model" in body:
|
|
||||||
await clients._create_langflow_global_variable(
|
|
||||||
"SELECTED_EMBEDDING_MODEL", current_config.knowledge.embedding_model, modify=True
|
|
||||||
)
|
|
||||||
logger.info(
|
|
||||||
f"Set SELECTED_EMBEDDING_MODEL global variable to {current_config.knowledge.embedding_model}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Update MCP servers with provider credentials during onboarding
|
# Update global variables if any provider fields were provided
|
||||||
try:
|
# or if existing config has values (for OpenAI/Anthropic that might already be set)
|
||||||
from services.langflow_mcp_service import LangflowMCPService
|
if (provider_fields_provided or
|
||||||
from utils.langflow_headers import build_mcp_global_vars_from_config
|
current_config.providers.openai.api_key != "" or
|
||||||
|
current_config.providers.anthropic.api_key != ""):
|
||||||
mcp_service = LangflowMCPService()
|
await _update_langflow_global_variables(current_config)
|
||||||
|
|
||||||
# Build global vars using utility function
|
if "embedding_provider" in body or "embedding_model" in body:
|
||||||
mcp_global_vars = build_mcp_global_vars_from_config(current_config)
|
await _update_mcp_servers_with_provider_credentials(current_config, session_manager)
|
||||||
|
|
||||||
# In no-auth mode, add the anonymous JWT token and user details
|
# Update model values if provider or model fields were provided
|
||||||
if is_no_auth_mode() and session_manager:
|
if "llm_provider" in body or "llm_model" in body or "embedding_provider" in body or "embedding_model" in body:
|
||||||
from session_manager import AnonymousUser
|
await _update_langflow_model_values(current_config, flows_service)
|
||||||
|
|
||||||
# Create/get anonymous JWT for no-auth mode
|
|
||||||
anonymous_jwt = session_manager.get_effective_jwt_token(None, None)
|
|
||||||
if anonymous_jwt:
|
|
||||||
mcp_global_vars["JWT"] = anonymous_jwt
|
|
||||||
|
|
||||||
# Add anonymous user details
|
|
||||||
anonymous_user = AnonymousUser()
|
|
||||||
mcp_global_vars["OWNER"] = anonymous_user.user_id # "anonymous"
|
|
||||||
mcp_global_vars["OWNER_NAME"] = f'"{anonymous_user.name}"' # "Anonymous User" (quoted)
|
|
||||||
mcp_global_vars["OWNER_EMAIL"] = anonymous_user.email # "anonymous@localhost"
|
|
||||||
|
|
||||||
logger.debug("Added anonymous JWT and user details to MCP servers for no-auth mode during onboarding")
|
|
||||||
|
|
||||||
if mcp_global_vars:
|
|
||||||
result = await mcp_service.update_mcp_servers_with_global_vars(mcp_global_vars)
|
|
||||||
logger.info("Updated MCP servers with provider credentials during onboarding", **result)
|
|
||||||
|
|
||||||
except Exception as mcp_error:
|
|
||||||
logger.warning(f"Failed to update MCP servers during onboarding: {str(mcp_error)}")
|
|
||||||
# Don't fail onboarding if MCP update fails
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(
|
logger.error(
|
||||||
"Failed to set Langflow global variables",
|
"Failed to set Langflow global variables and model values",
|
||||||
error=str(e),
|
error=str(e),
|
||||||
)
|
)
|
||||||
raise
|
raise
|
||||||
|
|
@ -1117,6 +942,221 @@ def _get_flows_service():
|
||||||
return FlowsService()
|
return FlowsService()
|
||||||
|
|
||||||
|
|
||||||
|
async def _update_langflow_global_variables(config):
|
||||||
|
"""Update Langflow global variables for all configured providers"""
|
||||||
|
try:
|
||||||
|
# WatsonX global variables
|
||||||
|
if config.providers.watsonx.api_key:
|
||||||
|
await clients._create_langflow_global_variable(
|
||||||
|
"WATSONX_API_KEY", config.providers.watsonx.api_key, modify=True
|
||||||
|
)
|
||||||
|
logger.info("Set WATSONX_API_KEY global variable in Langflow")
|
||||||
|
|
||||||
|
if config.providers.watsonx.project_id:
|
||||||
|
await clients._create_langflow_global_variable(
|
||||||
|
"WATSONX_PROJECT_ID", config.providers.watsonx.project_id, modify=True
|
||||||
|
)
|
||||||
|
logger.info("Set WATSONX_PROJECT_ID global variable in Langflow")
|
||||||
|
|
||||||
|
# OpenAI global variables
|
||||||
|
if config.providers.openai.api_key:
|
||||||
|
await clients._create_langflow_global_variable(
|
||||||
|
"OPENAI_API_KEY", config.providers.openai.api_key, modify=True
|
||||||
|
)
|
||||||
|
logger.info("Set OPENAI_API_KEY global variable in Langflow")
|
||||||
|
|
||||||
|
# Anthropic global variables
|
||||||
|
if config.providers.anthropic.api_key:
|
||||||
|
await clients._create_langflow_global_variable(
|
||||||
|
"ANTHROPIC_API_KEY", config.providers.anthropic.api_key, modify=True
|
||||||
|
)
|
||||||
|
logger.info("Set ANTHROPIC_API_KEY global variable in Langflow")
|
||||||
|
|
||||||
|
# Ollama global variables
|
||||||
|
if config.providers.ollama.endpoint:
|
||||||
|
endpoint = transform_localhost_url(config.providers.ollama.endpoint)
|
||||||
|
await clients._create_langflow_global_variable(
|
||||||
|
"OLLAMA_BASE_URL", endpoint, modify=True
|
||||||
|
)
|
||||||
|
logger.info("Set OLLAMA_BASE_URL global variable in Langflow")
|
||||||
|
|
||||||
|
if config.knowledge.embedding_model:
|
||||||
|
await clients._create_langflow_global_variable(
|
||||||
|
"SELECTED_EMBEDDING_MODEL", config.knowledge.embedding_model, modify=True
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
f"Set SELECTED_EMBEDDING_MODEL global variable to {config.knowledge.embedding_model}"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to update Langflow global variables: {str(e)}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def _update_mcp_servers_with_provider_credentials(config, session_manager = None):
|
||||||
|
# Update MCP servers with provider credentials
|
||||||
|
try:
|
||||||
|
from services.langflow_mcp_service import LangflowMCPService
|
||||||
|
from utils.langflow_headers import build_mcp_global_vars_from_config
|
||||||
|
|
||||||
|
mcp_service = LangflowMCPService()
|
||||||
|
|
||||||
|
# Build global vars using utility function
|
||||||
|
mcp_global_vars = build_mcp_global_vars_from_config(config)
|
||||||
|
|
||||||
|
# In no-auth mode, add the anonymous JWT token and user details
|
||||||
|
if is_no_auth_mode() and session_manager:
|
||||||
|
from session_manager import AnonymousUser
|
||||||
|
|
||||||
|
# Create/get anonymous JWT for no-auth mode
|
||||||
|
anonymous_jwt = session_manager.get_effective_jwt_token(None, None)
|
||||||
|
if anonymous_jwt:
|
||||||
|
mcp_global_vars["JWT"] = anonymous_jwt
|
||||||
|
|
||||||
|
# Add anonymous user details
|
||||||
|
anonymous_user = AnonymousUser()
|
||||||
|
mcp_global_vars["OWNER"] = anonymous_user.user_id # "anonymous"
|
||||||
|
mcp_global_vars["OWNER_NAME"] = f'"{anonymous_user.name}"' # "Anonymous User" (quoted)
|
||||||
|
mcp_global_vars["OWNER_EMAIL"] = anonymous_user.email # "anonymous@localhost"
|
||||||
|
|
||||||
|
logger.debug("Added anonymous JWT and user details to MCP servers for no-auth mode")
|
||||||
|
|
||||||
|
if mcp_global_vars:
|
||||||
|
result = await mcp_service.update_mcp_servers_with_global_vars(mcp_global_vars)
|
||||||
|
logger.info("Updated MCP servers with provider credentials after settings change", **result)
|
||||||
|
|
||||||
|
except Exception as mcp_error:
|
||||||
|
logger.warning(f"Failed to update MCP servers after settings change: {str(mcp_error)}")
|
||||||
|
# Don't fail the entire settings update if MCP update fails
|
||||||
|
|
||||||
|
|
||||||
|
async def _update_langflow_model_values(config, flows_service):
|
||||||
|
"""Update model values across Langflow flows"""
|
||||||
|
try:
|
||||||
|
# Update LLM model values
|
||||||
|
llm_provider = config.agent.llm_provider.lower()
|
||||||
|
llm_provider_config = config.get_llm_provider_config()
|
||||||
|
llm_endpoint = getattr(llm_provider_config, "endpoint", None)
|
||||||
|
|
||||||
|
await flows_service.change_langflow_model_value(
|
||||||
|
llm_provider,
|
||||||
|
llm_model=config.agent.llm_model,
|
||||||
|
endpoint=llm_endpoint,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
f"Successfully updated Langflow flows for LLM provider {llm_provider}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update embedding model values
|
||||||
|
embedding_provider = config.knowledge.embedding_provider.lower()
|
||||||
|
embedding_provider_config = config.get_embedding_provider_config()
|
||||||
|
embedding_endpoint = getattr(embedding_provider_config, "endpoint", None)
|
||||||
|
|
||||||
|
await flows_service.change_langflow_model_value(
|
||||||
|
embedding_provider,
|
||||||
|
embedding_model=config.knowledge.embedding_model,
|
||||||
|
endpoint=embedding_endpoint,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
f"Successfully updated Langflow flows for embedding provider {embedding_provider}"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to update Langflow model values: {str(e)}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def _update_langflow_system_prompt(config, flows_service):
|
||||||
|
"""Update system prompt in chat flow"""
|
||||||
|
try:
|
||||||
|
llm_provider = config.agent.llm_provider.lower()
|
||||||
|
await flows_service.update_chat_flow_system_prompt(
|
||||||
|
config.agent.system_prompt, llm_provider
|
||||||
|
)
|
||||||
|
logger.info("Successfully updated chat flow system prompt")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to update chat flow system prompt: {str(e)}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def _update_langflow_docling_settings(config, flows_service):
|
||||||
|
"""Update docling settings in ingest flow"""
|
||||||
|
try:
|
||||||
|
preset_config = get_docling_preset_configs(
|
||||||
|
table_structure=config.knowledge.table_structure,
|
||||||
|
ocr=config.knowledge.ocr,
|
||||||
|
picture_descriptions=config.knowledge.picture_descriptions,
|
||||||
|
)
|
||||||
|
await flows_service.update_flow_docling_preset("custom", preset_config)
|
||||||
|
logger.info("Successfully updated docling settings in ingest flow")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to update docling settings: {str(e)}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def _update_langflow_chunk_settings(config, flows_service):
|
||||||
|
"""Update chunk size and overlap in ingest flow"""
|
||||||
|
try:
|
||||||
|
await flows_service.update_ingest_flow_chunk_size(config.knowledge.chunk_size)
|
||||||
|
logger.info(f"Successfully updated ingest flow chunk size to {config.knowledge.chunk_size}")
|
||||||
|
|
||||||
|
await flows_service.update_ingest_flow_chunk_overlap(config.knowledge.chunk_overlap)
|
||||||
|
logger.info(f"Successfully updated ingest flow chunk overlap to {config.knowledge.chunk_overlap}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to update chunk settings: {str(e)}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def reapply_all_settings(session_manager = None):
|
||||||
|
"""
|
||||||
|
Reapply all current configuration settings to Langflow flows and global variables.
|
||||||
|
This is called when flows are detected to have been reset.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
config = get_openrag_config()
|
||||||
|
flows_service = _get_flows_service()
|
||||||
|
|
||||||
|
logger.info("Reapplying all settings to Langflow flows and global variables")
|
||||||
|
|
||||||
|
if config.knowledge.embedding_model or config.knowledge.embedding_provider:
|
||||||
|
await _update_mcp_servers_with_provider_credentials(config, session_manager)
|
||||||
|
else:
|
||||||
|
logger.info("No embedding model or provider configured, skipping MCP server update")
|
||||||
|
|
||||||
|
# Update all Langflow settings using helper functions
|
||||||
|
try:
|
||||||
|
await _update_langflow_global_variables(config)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to update Langflow global variables: {str(e)}")
|
||||||
|
# Continue with other updates even if global variables fail
|
||||||
|
|
||||||
|
try:
|
||||||
|
await _update_langflow_model_values(config, flows_service)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to update Langflow model values: {str(e)}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
await _update_langflow_system_prompt(config, flows_service)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to update Langflow system prompt: {str(e)}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
await _update_langflow_docling_settings(config, flows_service)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to update Langflow docling settings: {str(e)}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
await _update_langflow_chunk_settings(config, flows_service)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to update Langflow chunk settings: {str(e)}")
|
||||||
|
|
||||||
|
logger.info("Successfully reapplied all settings to Langflow flows")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to reapply settings: {str(e)}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
async def update_docling_preset(request, session_manager):
|
async def update_docling_preset(request, session_manager):
|
||||||
"""Update docling settings in the ingest flow - deprecated endpoint, use /settings instead"""
|
"""Update docling settings in the ingest flow - deprecated endpoint, use /settings instead"""
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
66
src/main.py
66
src/main.py
|
|
@ -304,11 +304,11 @@ async def init_index_when_ready():
|
||||||
|
|
||||||
def _get_documents_dir():
|
def _get_documents_dir():
|
||||||
"""Get the documents directory path, handling both Docker and local environments."""
|
"""Get the documents directory path, handling both Docker and local environments."""
|
||||||
# In Docker, the volume is mounted at /app/documents
|
# In Docker, the volume is mounted at /app/openrag-documents
|
||||||
# Locally, we use openrag-documents
|
# Locally, we use openrag-documents
|
||||||
container_env = detect_container_environment()
|
container_env = detect_container_environment()
|
||||||
if container_env:
|
if container_env:
|
||||||
path = os.path.abspath("/app/documents")
|
path = os.path.abspath("/app/openrag-documents")
|
||||||
logger.debug(f"Running in {container_env}, using container path: {path}")
|
logger.debug(f"Running in {container_env}, using container path: {path}")
|
||||||
return path
|
return path
|
||||||
else:
|
else:
|
||||||
|
|
@ -515,6 +515,29 @@ async def startup_tasks(services):
|
||||||
# Update MCP servers with provider credentials (especially important for no-auth mode)
|
# Update MCP servers with provider credentials (especially important for no-auth mode)
|
||||||
await _update_mcp_servers_with_provider_credentials(services)
|
await _update_mcp_servers_with_provider_credentials(services)
|
||||||
|
|
||||||
|
# Check if flows were reset and reapply settings if config is edited
|
||||||
|
try:
|
||||||
|
config = get_openrag_config()
|
||||||
|
if config.edited:
|
||||||
|
logger.info("Checking if Langflow flows were reset")
|
||||||
|
flows_service = services["flows_service"]
|
||||||
|
reset_flows = await flows_service.check_flows_reset()
|
||||||
|
|
||||||
|
if reset_flows:
|
||||||
|
logger.info(
|
||||||
|
f"Detected reset flows: {', '.join(reset_flows)}. Reapplying all settings."
|
||||||
|
)
|
||||||
|
from api.settings import reapply_all_settings
|
||||||
|
await reapply_all_settings(session_manager=services["session_manager"])
|
||||||
|
logger.info("Successfully reapplied settings after detecting flow resets")
|
||||||
|
else:
|
||||||
|
logger.info("No flows detected as reset, skipping settings reapplication")
|
||||||
|
else:
|
||||||
|
logger.debug("Configuration not yet edited, skipping flow reset check")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to check flows reset or reapply settings: {str(e)}")
|
||||||
|
# Don't fail startup if this check fails
|
||||||
|
|
||||||
|
|
||||||
async def initialize_services():
|
async def initialize_services():
|
||||||
"""Initialize all services and their dependencies"""
|
"""Initialize all services and their dependencies"""
|
||||||
|
|
@ -1205,6 +1228,45 @@ async def create_app():
|
||||||
app.state.background_tasks.add(t1)
|
app.state.background_tasks.add(t1)
|
||||||
t1.add_done_callback(app.state.background_tasks.discard)
|
t1.add_done_callback(app.state.background_tasks.discard)
|
||||||
|
|
||||||
|
# Start periodic flow backup task (every 5 minutes)
|
||||||
|
async def periodic_backup():
|
||||||
|
"""Periodic backup task that runs every 15 minutes"""
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(5 * 60) # Wait 5 minutes
|
||||||
|
|
||||||
|
# Check if onboarding has been completed
|
||||||
|
config = get_openrag_config()
|
||||||
|
if not config.edited:
|
||||||
|
logger.debug("Onboarding not completed yet, skipping periodic backup")
|
||||||
|
continue
|
||||||
|
|
||||||
|
flows_service = services.get("flows_service")
|
||||||
|
if flows_service:
|
||||||
|
logger.info("Running periodic flow backup")
|
||||||
|
backup_results = await flows_service.backup_all_flows(only_if_changed=True)
|
||||||
|
if backup_results["backed_up"]:
|
||||||
|
logger.info(
|
||||||
|
"Periodic backup completed",
|
||||||
|
backed_up=len(backup_results["backed_up"]),
|
||||||
|
skipped=len(backup_results["skipped"]),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.debug(
|
||||||
|
"Periodic backup: no flows changed",
|
||||||
|
skipped=len(backup_results["skipped"]),
|
||||||
|
)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
logger.info("Periodic backup task cancelled")
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in periodic backup task: {str(e)}")
|
||||||
|
# Continue running even if one backup fails
|
||||||
|
|
||||||
|
backup_task = asyncio.create_task(periodic_backup())
|
||||||
|
app.state.background_tasks.add(backup_task)
|
||||||
|
backup_task.add_done_callback(app.state.background_tasks.discard)
|
||||||
|
|
||||||
# Add shutdown event handler
|
# Add shutdown event handler
|
||||||
@app.on_event("shutdown")
|
@app.on_event("shutdown")
|
||||||
async def shutdown_event():
|
async def shutdown_event():
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,10 @@ from config.settings import (
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import copy
|
||||||
|
from datetime import datetime
|
||||||
from utils.logging_config import get_logger
|
from utils.logging_config import get_logger
|
||||||
|
from utils.container_utils import transform_localhost_url
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
@ -41,6 +44,241 @@ class FlowsService:
|
||||||
project_root = os.path.dirname(src_dir) # project root
|
project_root = os.path.dirname(src_dir) # project root
|
||||||
return os.path.join(project_root, "flows")
|
return os.path.join(project_root, "flows")
|
||||||
|
|
||||||
|
def _get_backup_directory(self):
|
||||||
|
"""Get the backup directory path"""
|
||||||
|
flows_dir = self._get_flows_directory()
|
||||||
|
backup_dir = os.path.join(flows_dir, "backup")
|
||||||
|
os.makedirs(backup_dir, exist_ok=True)
|
||||||
|
return backup_dir
|
||||||
|
|
||||||
|
def _get_latest_backup_path(self, flow_id: str, flow_type: str):
|
||||||
|
"""
|
||||||
|
Get the path to the latest backup file for a flow.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
flow_id: The flow ID
|
||||||
|
flow_type: The flow type name
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Path to latest backup file, or None if no backup exists
|
||||||
|
"""
|
||||||
|
backup_dir = self._get_backup_directory()
|
||||||
|
|
||||||
|
if not os.path.exists(backup_dir):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Find all backup files for this flow
|
||||||
|
backup_files = []
|
||||||
|
prefix = f"{flow_type}_"
|
||||||
|
|
||||||
|
try:
|
||||||
|
for filename in os.listdir(backup_dir):
|
||||||
|
if filename.startswith(prefix) and filename.endswith(".json"):
|
||||||
|
file_path = os.path.join(backup_dir, filename)
|
||||||
|
# Get modification time for sorting
|
||||||
|
mtime = os.path.getmtime(file_path)
|
||||||
|
backup_files.append((mtime, file_path))
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error reading backup directory: {str(e)}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not backup_files:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Return the most recent backup (highest mtime)
|
||||||
|
backup_files.sort(key=lambda x: x[0], reverse=True)
|
||||||
|
return backup_files[0][1]
|
||||||
|
|
||||||
|
def _compare_flows(self, flow1: dict, flow2: dict):
|
||||||
|
"""
|
||||||
|
Compare two flow structures to see if they're different.
|
||||||
|
Normalizes both flows before comparison.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
flow1: First flow data
|
||||||
|
flow2: Second flow data
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if flows are different, False if they're the same
|
||||||
|
"""
|
||||||
|
normalized1 = self._normalize_flow_structure(flow1)
|
||||||
|
normalized2 = self._normalize_flow_structure(flow2)
|
||||||
|
|
||||||
|
# Compare normalized structures
|
||||||
|
return normalized1 != normalized2
|
||||||
|
|
||||||
|
async def backup_all_flows(self, only_if_changed=True):
|
||||||
|
"""
|
||||||
|
Backup all flows from Langflow to the backup folder.
|
||||||
|
Only backs up flows that have changed since the last backup.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
only_if_changed: If True, only backup flows that differ from latest backup
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Summary of backup operations with success/failure status
|
||||||
|
"""
|
||||||
|
backup_results = {
|
||||||
|
"success": True,
|
||||||
|
"backed_up": [],
|
||||||
|
"skipped": [],
|
||||||
|
"failed": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
flow_configs = [
|
||||||
|
("nudges", NUDGES_FLOW_ID),
|
||||||
|
("retrieval", LANGFLOW_CHAT_FLOW_ID),
|
||||||
|
("ingest", LANGFLOW_INGEST_FLOW_ID),
|
||||||
|
("url_ingest", LANGFLOW_URL_INGEST_FLOW_ID),
|
||||||
|
]
|
||||||
|
|
||||||
|
logger.info("Starting periodic backup of Langflow flows")
|
||||||
|
|
||||||
|
for flow_type, flow_id in flow_configs:
|
||||||
|
if not flow_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get current flow from Langflow
|
||||||
|
response = await clients.langflow_request("GET", f"/api/v1/flows/{flow_id}")
|
||||||
|
if response.status_code != 200:
|
||||||
|
logger.warning(
|
||||||
|
f"Failed to get flow {flow_id} for backup: HTTP {response.status_code}"
|
||||||
|
)
|
||||||
|
backup_results["failed"].append({
|
||||||
|
"flow_type": flow_type,
|
||||||
|
"flow_id": flow_id,
|
||||||
|
"error": f"HTTP {response.status_code}",
|
||||||
|
})
|
||||||
|
backup_results["success"] = False
|
||||||
|
continue
|
||||||
|
|
||||||
|
current_flow = response.json()
|
||||||
|
|
||||||
|
# Check if flow is locked and if we should skip backup
|
||||||
|
flow_locked = current_flow.get("locked", False)
|
||||||
|
latest_backup_path = self._get_latest_backup_path(flow_id, flow_type)
|
||||||
|
has_backups = latest_backup_path is not None
|
||||||
|
|
||||||
|
# If flow is locked and no backups exist, skip backup
|
||||||
|
if flow_locked and not has_backups:
|
||||||
|
logger.debug(
|
||||||
|
f"Flow {flow_type} (ID: {flow_id}) is locked and has no backups, skipping backup"
|
||||||
|
)
|
||||||
|
backup_results["skipped"].append({
|
||||||
|
"flow_type": flow_type,
|
||||||
|
"flow_id": flow_id,
|
||||||
|
"reason": "locked_without_backups",
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check if we need to backup (only if changed)
|
||||||
|
if only_if_changed and has_backups:
|
||||||
|
try:
|
||||||
|
with open(latest_backup_path, "r") as f:
|
||||||
|
latest_backup = json.load(f)
|
||||||
|
|
||||||
|
# Compare flows
|
||||||
|
if not self._compare_flows(current_flow, latest_backup):
|
||||||
|
logger.debug(
|
||||||
|
f"Flow {flow_type} (ID: {flow_id}) unchanged, skipping backup"
|
||||||
|
)
|
||||||
|
backup_results["skipped"].append({
|
||||||
|
"flow_type": flow_type,
|
||||||
|
"flow_id": flow_id,
|
||||||
|
"reason": "unchanged",
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
f"Failed to read latest backup for {flow_type} (ID: {flow_id}): {str(e)}"
|
||||||
|
)
|
||||||
|
# Continue with backup if we can't read the latest backup
|
||||||
|
|
||||||
|
# Backup the flow
|
||||||
|
backup_path = await self._backup_flow(flow_id, flow_type, current_flow)
|
||||||
|
if backup_path:
|
||||||
|
backup_results["backed_up"].append({
|
||||||
|
"flow_type": flow_type,
|
||||||
|
"flow_id": flow_id,
|
||||||
|
"backup_path": backup_path,
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
backup_results["failed"].append({
|
||||||
|
"flow_type": flow_type,
|
||||||
|
"flow_id": flow_id,
|
||||||
|
"error": "Backup returned None",
|
||||||
|
})
|
||||||
|
backup_results["success"] = False
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"Failed to backup {flow_type} flow (ID: {flow_id}): {str(e)}"
|
||||||
|
)
|
||||||
|
backup_results["failed"].append({
|
||||||
|
"flow_type": flow_type,
|
||||||
|
"flow_id": flow_id,
|
||||||
|
"error": str(e),
|
||||||
|
})
|
||||||
|
backup_results["success"] = False
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Completed periodic backup of flows",
|
||||||
|
backed_up_count=len(backup_results["backed_up"]),
|
||||||
|
skipped_count=len(backup_results["skipped"]),
|
||||||
|
failed_count=len(backup_results["failed"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
return backup_results
|
||||||
|
|
||||||
|
async def _backup_flow(self, flow_id: str, flow_type: str, flow_data: dict = None):
|
||||||
|
"""
|
||||||
|
Backup a single flow to the backup folder.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
flow_id: The flow ID to backup
|
||||||
|
flow_type: The flow type name (nudges, retrieval, ingest, url_ingest)
|
||||||
|
flow_data: The flow data to backup (if None, fetches from API)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Path to the backup file, or None if backup failed
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Get flow data if not provided
|
||||||
|
if flow_data is None:
|
||||||
|
response = await clients.langflow_request("GET", f"/api/v1/flows/{flow_id}")
|
||||||
|
if response.status_code != 200:
|
||||||
|
logger.warning(
|
||||||
|
f"Failed to get flow {flow_id} for backup: HTTP {response.status_code}"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
flow_data = response.json()
|
||||||
|
|
||||||
|
# Create backup directory if it doesn't exist
|
||||||
|
backup_dir = self._get_backup_directory()
|
||||||
|
|
||||||
|
# Generate backup filename with timestamp
|
||||||
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
backup_filename = f"{flow_type}_{timestamp}.json"
|
||||||
|
backup_path = os.path.join(backup_dir, backup_filename)
|
||||||
|
|
||||||
|
# Save flow to backup file
|
||||||
|
with open(backup_path, "w") as f:
|
||||||
|
json.dump(flow_data, f, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Backed up {flow_type} flow (ID: {flow_id}) to {backup_filename}",
|
||||||
|
backup_path=backup_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
return backup_path
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"Failed to backup flow {flow_id} ({flow_type}): {str(e)}",
|
||||||
|
error=str(e),
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
def _find_flow_file_by_id(self, flow_id: str):
|
def _find_flow_file_by_id(self, flow_id: str):
|
||||||
"""
|
"""
|
||||||
Scan the flows directory and find the JSON file that contains the specified flow ID.
|
Scan the flows directory and find the JSON file that contains the specified flow ID.
|
||||||
|
|
@ -674,6 +912,135 @@ class FlowsService:
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def _normalize_flow_structure(self, flow_data):
|
||||||
|
"""
|
||||||
|
Normalize flow structure for comparison by removing dynamic fields.
|
||||||
|
Keeps structural elements: nodes (types, display names, templates), edges (connections).
|
||||||
|
Removes: IDs, timestamps, positions, etc. but keeps template structure.
|
||||||
|
"""
|
||||||
|
normalized = {
|
||||||
|
"data": {
|
||||||
|
"nodes": [],
|
||||||
|
"edges": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Normalize nodes - keep structural info including templates
|
||||||
|
nodes = flow_data.get("data", {}).get("nodes", [])
|
||||||
|
for node in nodes:
|
||||||
|
node_data = node.get("data", {})
|
||||||
|
node_template = node_data.get("node", {})
|
||||||
|
|
||||||
|
normalized_node = {
|
||||||
|
"id": node.get("id"), # Keep ID for edge matching
|
||||||
|
"type": node.get("type"),
|
||||||
|
"data": {
|
||||||
|
"node": {
|
||||||
|
"display_name": node_template.get("display_name"),
|
||||||
|
"name": node_template.get("name"),
|
||||||
|
"base_classes": node_template.get("base_classes", []),
|
||||||
|
"template": node_template.get("template", {}), # Include template structure
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
normalized["data"]["nodes"].append(normalized_node)
|
||||||
|
|
||||||
|
# Normalize edges - keep only connections
|
||||||
|
edges = flow_data.get("data", {}).get("edges", [])
|
||||||
|
for edge in edges:
|
||||||
|
normalized_edge = {
|
||||||
|
"source": edge.get("source"),
|
||||||
|
"target": edge.get("target"),
|
||||||
|
"sourceHandle": edge.get("sourceHandle"),
|
||||||
|
"targetHandle": edge.get("targetHandle"),
|
||||||
|
}
|
||||||
|
normalized["data"]["edges"].append(normalized_edge)
|
||||||
|
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
async def _compare_flow_with_file(self, flow_id: str):
|
||||||
|
"""
|
||||||
|
Compare a Langflow flow with its JSON file.
|
||||||
|
Returns True if flows match (indicating a reset), False otherwise.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Get flow from Langflow API
|
||||||
|
response = await clients.langflow_request("GET", f"/api/v1/flows/{flow_id}")
|
||||||
|
if response.status_code != 200:
|
||||||
|
logger.warning(f"Failed to get flow {flow_id} from Langflow: HTTP {response.status_code}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
langflow_flow = response.json()
|
||||||
|
|
||||||
|
# Find and load the corresponding JSON file
|
||||||
|
flow_path = self._find_flow_file_by_id(flow_id)
|
||||||
|
if not flow_path:
|
||||||
|
logger.warning(f"Flow file not found for flow ID: {flow_id}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
with open(flow_path, "r") as f:
|
||||||
|
file_flow = json.load(f)
|
||||||
|
|
||||||
|
# Normalize both flows for comparison
|
||||||
|
normalized_langflow = self._normalize_flow_structure(langflow_flow)
|
||||||
|
normalized_file = self._normalize_flow_structure(file_flow)
|
||||||
|
|
||||||
|
# Compare entire normalized structures exactly
|
||||||
|
# Sort nodes and edges for consistent comparison
|
||||||
|
normalized_langflow["data"]["nodes"] = sorted(
|
||||||
|
normalized_langflow["data"]["nodes"],
|
||||||
|
key=lambda x: (x.get("id", ""), x.get("type", ""))
|
||||||
|
)
|
||||||
|
normalized_file["data"]["nodes"] = sorted(
|
||||||
|
normalized_file["data"]["nodes"],
|
||||||
|
key=lambda x: (x.get("id", ""), x.get("type", ""))
|
||||||
|
)
|
||||||
|
|
||||||
|
normalized_langflow["data"]["edges"] = sorted(
|
||||||
|
normalized_langflow["data"]["edges"],
|
||||||
|
key=lambda x: (x.get("source", ""), x.get("target", ""), x.get("sourceHandle", ""), x.get("targetHandle", ""))
|
||||||
|
)
|
||||||
|
normalized_file["data"]["edges"] = sorted(
|
||||||
|
normalized_file["data"]["edges"],
|
||||||
|
key=lambda x: (x.get("source", ""), x.get("target", ""), x.get("sourceHandle", ""), x.get("targetHandle", ""))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Compare entire normalized structures
|
||||||
|
return normalized_langflow == normalized_file
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error comparing flow {flow_id} with file: {str(e)}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def check_flows_reset(self):
|
||||||
|
"""
|
||||||
|
Check if any flows have been reset by comparing with JSON files.
|
||||||
|
Returns list of flow types that were reset.
|
||||||
|
"""
|
||||||
|
reset_flows = []
|
||||||
|
|
||||||
|
flow_configs = [
|
||||||
|
("nudges", NUDGES_FLOW_ID),
|
||||||
|
("retrieval", LANGFLOW_CHAT_FLOW_ID),
|
||||||
|
("ingest", LANGFLOW_INGEST_FLOW_ID),
|
||||||
|
("url_ingest", LANGFLOW_URL_INGEST_FLOW_ID),
|
||||||
|
]
|
||||||
|
|
||||||
|
for flow_type, flow_id in flow_configs:
|
||||||
|
if not flow_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
logger.info(f"Checking if {flow_type} flow (ID: {flow_id}) was reset")
|
||||||
|
is_reset = await self._compare_flow_with_file(flow_id)
|
||||||
|
|
||||||
|
if is_reset:
|
||||||
|
logger.info(f"Flow {flow_type} (ID: {flow_id}) appears to have been reset")
|
||||||
|
reset_flows.append(flow_type)
|
||||||
|
else:
|
||||||
|
logger.info(f"Flow {flow_type} (ID: {flow_id}) does not match reset state")
|
||||||
|
|
||||||
|
return reset_flows
|
||||||
|
|
||||||
async def change_langflow_model_value(
|
async def change_langflow_model_value(
|
||||||
self,
|
self,
|
||||||
provider: str,
|
provider: str,
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
../../../../documents/docling.pdf
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
../../../../documents/ibm_anthropic.pdf
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
../../../../documents/openrag-documentation.pdf
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
../../../../documents/warmup_ocr.pdf
|
|
||||||
1
src/tui/_assets/openrag-documents/docling.pdf
Symbolic link
1
src/tui/_assets/openrag-documents/docling.pdf
Symbolic link
|
|
@ -0,0 +1 @@
|
||||||
|
../../../../openrag-documents/docling.pdf
|
||||||
1
src/tui/_assets/openrag-documents/ibm_anthropic.pdf
Symbolic link
1
src/tui/_assets/openrag-documents/ibm_anthropic.pdf
Symbolic link
|
|
@ -0,0 +1 @@
|
||||||
|
../../../../openrag-documents/ibm_anthropic.pdf
|
||||||
1
src/tui/_assets/openrag-documents/openrag-documentation.pdf
Symbolic link
1
src/tui/_assets/openrag-documents/openrag-documentation.pdf
Symbolic link
|
|
@ -0,0 +1 @@
|
||||||
|
../../../../openrag-documents/openrag-documentation.pdf
|
||||||
1
src/tui/_assets/openrag-documents/warmup_ocr.pdf
Symbolic link
1
src/tui/_assets/openrag-documents/warmup_ocr.pdf
Symbolic link
|
|
@ -0,0 +1 @@
|
||||||
|
../../../../openrag-documents/warmup_ocr.pdf
|
||||||
|
|
@ -458,7 +458,7 @@ def copy_sample_documents(*, force: bool = False) -> None:
|
||||||
documents_dir = Path("openrag-documents")
|
documents_dir = Path("openrag-documents")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
assets_files = files("tui._assets.documents")
|
assets_files = files("tui._assets.openrag-documents")
|
||||||
_copy_assets(assets_files, documents_dir, allowed_suffixes=(".pdf",), force=force)
|
_copy_assets(assets_files, documents_dir, allowed_suffixes=(".pdf",), force=force)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"Could not copy sample documents: {e}")
|
logger.debug(f"Could not copy sample documents: {e}")
|
||||||
|
|
|
||||||
|
|
@ -521,15 +521,15 @@ class EnvManager:
|
||||||
)
|
)
|
||||||
|
|
||||||
if not is_valid:
|
if not is_valid:
|
||||||
return ["./openrag-documents:/app/documents:Z"] # fallback
|
return ["./openrag-documents:/app/openrag-documents:Z"] # fallback
|
||||||
|
|
||||||
volume_mounts = []
|
volume_mounts = []
|
||||||
for i, path in enumerate(validated_paths):
|
for i, path in enumerate(validated_paths):
|
||||||
if i == 0:
|
if i == 0:
|
||||||
# First path maps to the default /app/documents
|
# First path maps to the default /app/openrag-documents
|
||||||
volume_mounts.append(f"{path}:/app/documents:Z")
|
volume_mounts.append(f"{path}:/app/openrag-documents:Z")
|
||||||
else:
|
else:
|
||||||
# Additional paths map to numbered directories
|
# Additional paths map to numbered directories
|
||||||
volume_mounts.append(f"{path}:/app/documents{i + 1}:Z")
|
volume_mounts.append(f"{path}:/app/openrag-documents{i + 1}:Z")
|
||||||
|
|
||||||
return volume_mounts
|
return volume_mounts
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ from ..managers.container_manager import ContainerManager, ServiceStatus, Servic
|
||||||
from ..managers.docling_manager import DoclingManager
|
from ..managers.docling_manager import DoclingManager
|
||||||
from ..utils.platform import RuntimeType
|
from ..utils.platform import RuntimeType
|
||||||
from ..widgets.command_modal import CommandOutputModal
|
from ..widgets.command_modal import CommandOutputModal
|
||||||
|
from ..widgets.flow_backup_warning_modal import FlowBackupWarningModal
|
||||||
from ..widgets.diagnostics_notification import notify_with_diagnostics
|
from ..widgets.diagnostics_notification import notify_with_diagnostics
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -393,6 +394,16 @@ class MonitorScreen(Screen):
|
||||||
"""Upgrade services with progress updates."""
|
"""Upgrade services with progress updates."""
|
||||||
self.operation_in_progress = True
|
self.operation_in_progress = True
|
||||||
try:
|
try:
|
||||||
|
# Check for flow backups before upgrading
|
||||||
|
if self._check_flow_backups():
|
||||||
|
# Show warning modal and wait for user decision
|
||||||
|
should_continue = await self.app.push_screen_wait(
|
||||||
|
FlowBackupWarningModal(operation="upgrade")
|
||||||
|
)
|
||||||
|
if not should_continue:
|
||||||
|
self.notify("Upgrade cancelled", severity="information")
|
||||||
|
return
|
||||||
|
|
||||||
# Show command output in modal dialog
|
# Show command output in modal dialog
|
||||||
command_generator = self.container_manager.upgrade_services()
|
command_generator = self.container_manager.upgrade_services()
|
||||||
modal = CommandOutputModal(
|
modal = CommandOutputModal(
|
||||||
|
|
@ -408,6 +419,16 @@ class MonitorScreen(Screen):
|
||||||
"""Reset services with progress updates."""
|
"""Reset services with progress updates."""
|
||||||
self.operation_in_progress = True
|
self.operation_in_progress = True
|
||||||
try:
|
try:
|
||||||
|
# Check for flow backups before resetting
|
||||||
|
if self._check_flow_backups():
|
||||||
|
# Show warning modal and wait for user decision
|
||||||
|
should_continue = await self.app.push_screen_wait(
|
||||||
|
FlowBackupWarningModal(operation="reset")
|
||||||
|
)
|
||||||
|
if not should_continue:
|
||||||
|
self.notify("Reset cancelled", severity="information")
|
||||||
|
return
|
||||||
|
|
||||||
# Show command output in modal dialog
|
# Show command output in modal dialog
|
||||||
command_generator = self.container_manager.reset_services()
|
command_generator = self.container_manager.reset_services()
|
||||||
modal = CommandOutputModal(
|
modal = CommandOutputModal(
|
||||||
|
|
@ -419,6 +440,20 @@ class MonitorScreen(Screen):
|
||||||
finally:
|
finally:
|
||||||
self.operation_in_progress = False
|
self.operation_in_progress = False
|
||||||
|
|
||||||
|
def _check_flow_backups(self) -> bool:
|
||||||
|
"""Check if there are any flow backups in ./flows/backup directory."""
|
||||||
|
from pathlib import Path
|
||||||
|
backup_dir = Path("flows/backup")
|
||||||
|
if not backup_dir.exists():
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Check if there are any .json files in the backup directory
|
||||||
|
backup_files = list(backup_dir.glob("*.json"))
|
||||||
|
return len(backup_files) > 0
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
async def _start_docling_serve(self) -> None:
|
async def _start_docling_serve(self) -> None:
|
||||||
"""Start docling serve."""
|
"""Start docling serve."""
|
||||||
self.operation_in_progress = True
|
self.operation_in_progress = True
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ class WelcomeScreen(Screen):
|
||||||
self.has_oauth_config = False
|
self.has_oauth_config = False
|
||||||
self.default_button_id = "basic-setup-btn"
|
self.default_button_id = "basic-setup-btn"
|
||||||
self._state_checked = False
|
self._state_checked = False
|
||||||
|
self.has_flow_backups = False
|
||||||
|
|
||||||
# Check if .env file exists
|
# Check if .env file exists
|
||||||
self.has_env_file = self.env_manager.env_file.exists()
|
self.has_env_file = self.env_manager.env_file.exists()
|
||||||
|
|
@ -45,6 +46,9 @@ class WelcomeScreen(Screen):
|
||||||
self.has_oauth_config = bool(os.getenv("GOOGLE_OAUTH_CLIENT_ID")) or bool(
|
self.has_oauth_config = bool(os.getenv("GOOGLE_OAUTH_CLIENT_ID")) or bool(
|
||||||
os.getenv("MICROSOFT_GRAPH_OAUTH_CLIENT_ID")
|
os.getenv("MICROSOFT_GRAPH_OAUTH_CLIENT_ID")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Check for flow backups
|
||||||
|
self.has_flow_backups = self._check_flow_backups()
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
"""Create the welcome screen layout."""
|
"""Create the welcome screen layout."""
|
||||||
|
|
@ -61,6 +65,19 @@ class WelcomeScreen(Screen):
|
||||||
)
|
)
|
||||||
yield Footer()
|
yield Footer()
|
||||||
|
|
||||||
|
def _check_flow_backups(self) -> bool:
|
||||||
|
"""Check if there are any flow backups in ./flows/backup directory."""
|
||||||
|
backup_dir = Path("flows/backup")
|
||||||
|
if not backup_dir.exists():
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Check if there are any .json files in the backup directory
|
||||||
|
backup_files = list(backup_dir.glob("*.json"))
|
||||||
|
return len(backup_files) > 0
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
def _detect_services_sync(self) -> None:
|
def _detect_services_sync(self) -> None:
|
||||||
"""Synchronously detect if services are running."""
|
"""Synchronously detect if services are running."""
|
||||||
if not self.container_manager.is_available():
|
if not self.container_manager.is_available():
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,7 @@
|
||||||
"""Widgets for OpenRAG TUI."""
|
"""Widgets for OpenRAG TUI."""
|
||||||
|
|
||||||
# Made with Bob
|
from .flow_backup_warning_modal import FlowBackupWarningModal
|
||||||
|
|
||||||
|
__all__ = ["FlowBackupWarningModal"]
|
||||||
|
|
||||||
|
# Made with Bob
|
||||||
109
src/tui/widgets/flow_backup_warning_modal.py
Normal file
109
src/tui/widgets/flow_backup_warning_modal.py
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
"""Flow backup warning modal for OpenRAG TUI."""
|
||||||
|
|
||||||
|
from textual.app import ComposeResult
|
||||||
|
from textual.containers import Container, Horizontal
|
||||||
|
from textual.screen import ModalScreen
|
||||||
|
from textual.widgets import Button, Static, Label
|
||||||
|
|
||||||
|
|
||||||
|
class FlowBackupWarningModal(ModalScreen[bool]):
|
||||||
|
"""Modal dialog to warn about flow backups before upgrade/reset."""
|
||||||
|
|
||||||
|
DEFAULT_CSS = """
|
||||||
|
FlowBackupWarningModal {
|
||||||
|
align: center middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
#dialog {
|
||||||
|
width: 70;
|
||||||
|
height: auto;
|
||||||
|
border: solid #3f3f46;
|
||||||
|
background: #27272a;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#title {
|
||||||
|
background: #3f3f46;
|
||||||
|
color: #fafafa;
|
||||||
|
padding: 1 2;
|
||||||
|
text-align: center;
|
||||||
|
width: 100%;
|
||||||
|
text-style: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
#message {
|
||||||
|
padding: 2;
|
||||||
|
color: #fafafa;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#button-row {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
align: center middle;
|
||||||
|
padding: 1;
|
||||||
|
margin-top: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#button-row Button {
|
||||||
|
margin: 0 1;
|
||||||
|
min-width: 16;
|
||||||
|
background: #27272a;
|
||||||
|
color: #fafafa;
|
||||||
|
border: round #52525b;
|
||||||
|
text-style: none;
|
||||||
|
tint: transparent 0%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#button-row Button:hover {
|
||||||
|
background: #27272a !important;
|
||||||
|
color: #fafafa !important;
|
||||||
|
border: round #52525b;
|
||||||
|
tint: transparent 0%;
|
||||||
|
text-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#button-row Button:focus {
|
||||||
|
background: #27272a !important;
|
||||||
|
color: #fafafa !important;
|
||||||
|
border: round #ec4899;
|
||||||
|
tint: transparent 0%;
|
||||||
|
text-style: none;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, operation: str = "upgrade"):
|
||||||
|
"""Initialize the warning modal.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
operation: The operation being performed ("upgrade" or "reset")
|
||||||
|
"""
|
||||||
|
super().__init__()
|
||||||
|
self.operation = operation
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
"""Create the modal dialog layout."""
|
||||||
|
with Container(id="dialog"):
|
||||||
|
yield Label("⚠ Flow Backups Detected", id="title")
|
||||||
|
yield Static(
|
||||||
|
f"Flow backups found in ./flows/backup\n\n"
|
||||||
|
f"Proceeding with {self.operation} will reset custom flows to defaults.\n"
|
||||||
|
f"Your customizations are backed up and will need to be\n"
|
||||||
|
f"manually imported and upgraded to work with the latest version.\n\n"
|
||||||
|
f"Do you want to continue?",
|
||||||
|
id="message"
|
||||||
|
)
|
||||||
|
with Horizontal(id="button-row"):
|
||||||
|
yield Button("Cancel", id="cancel-btn")
|
||||||
|
yield Button(f"Continue {self.operation.title()}", id="continue-btn")
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
"""Focus the cancel button by default for safety."""
|
||||||
|
self.query_one("#cancel-btn", Button).focus()
|
||||||
|
|
||||||
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||||
|
"""Handle button presses."""
|
||||||
|
if event.button.id == "continue-btn":
|
||||||
|
self.dismiss(True) # User wants to continue
|
||||||
|
else:
|
||||||
|
self.dismiss(False) # User cancelled
|
||||||
Loading…
Add table
Reference in a new issue