add data source

This commit is contained in:
qixinbo
2026-03-15 20:48:40 +08:00
parent f1db709aae
commit a7f1c77787
10 changed files with 119 additions and 29 deletions
Vendored
BIN
View File
Binary file not shown.
+5 -9
View File
@@ -23,6 +23,7 @@ from app.schemas.chart import ChartGenerationResponse
from app.agent.chart import generate_chart from app.agent.chart import generate_chart
from app.database import SessionLocal from app.database import SessionLocal
from app.models.datasource import DataSource from app.models.datasource import DataSource
from app.core.files import resolve_upload_file_path
SCHEMA_CACHE_TTL_SECONDS = 300 SCHEMA_CACHE_TTL_SECONDS = 300
CONNECTION_CACHE_TTL_SECONDS = 30 CONNECTION_CACHE_TTL_SECONDS = 30
@@ -100,15 +101,10 @@ The final answer must be a ANSI SQL query in JSON format:
""" """
def _resolve_upload_file_path(file_url: Optional[str]) -> Path: def _resolve_upload_file_path(file_url: Optional[str]) -> Path:
if not file_url or not file_url.startswith("local://"): try:
raise ValueError("Invalid uploaded file URL") return resolve_upload_file_path(file_url)
raw_name = file_url.replace("local://", "", 1) except ValueError as e:
safe_name = os.path.basename(raw_name) raise ValueError(f"Invalid uploaded file URL: {e}")
upload_dir = Path(__file__).resolve().parents[2] / "data" / "uploads"
file_path = upload_dir / safe_name
if not file_path.exists():
raise ValueError(f"Uploaded file not found: {safe_name}")
return file_path
def _load_upload_dataframe_from_path(file_path: Path) -> pd.DataFrame: def _load_upload_dataframe_from_path(file_path: Path) -> pd.DataFrame:
suffix = file_path.suffix.lower() suffix = file_path.suffix.lower()
+19 -5
View File
@@ -11,9 +11,10 @@ upload_dir.mkdir(parents=True, exist_ok=True)
@router.post("/upload/file") @router.post("/upload/file")
async def upload_file(file: UploadFile = File(...)): async def upload_file(file: UploadFile = File(...)):
allowed_extensions = ('.csv', '.xls', '.xlsx') allowed_extensions = ('.csv', '.xls', '.xlsx', '.parquet', '.db', '.sqlite', '.sqlite3')
if not file.filename.lower().endswith(allowed_extensions): filename_lower = file.filename.lower()
raise HTTPException(status_code=400, detail="Invalid file type. Only CSV and Excel files allowed.") if not filename_lower.endswith(allowed_extensions):
raise HTTPException(status_code=400, detail="Invalid file type. Allowed: CSV, Excel, Parquet, SQLite.")
try: try:
content = await file.read() content = await file.read()
@@ -29,11 +30,24 @@ async def upload_file(file: UploadFile = File(...)):
file_obj.seek(0) file_obj.seek(0)
try: try:
if file.filename.lower().endswith('.csv'): if filename_lower.endswith('.csv'):
df = pd.read_csv(file_obj) df = pd.read_csv(file_obj)
else: elif filename_lower.endswith(('.xls', '.xlsx')):
df = pd.read_excel(file_obj) df = pd.read_excel(file_obj)
elif filename_lower.endswith('.parquet'):
df = pd.read_parquet(file_obj)
elif filename_lower.endswith(('.db', '.sqlite', '.sqlite3')):
# For SQLite, we don't load into DF immediately for analysis here
# Just return success
return {
"filename": unique_filename,
"url": file_url,
"rows": 0,
"columns": [],
"summary": "SQLite database uploaded"
}
# For DF supported types
duckdb_conn = duckdb.connect(database=':memory:') duckdb_conn = duckdb.connect(database=':memory:')
duckdb_conn.register('uploaded_file', df) duckdb_conn.register('uploaded_file', df)
summary = duckdb_conn.execute("DESCRIBE uploaded_file").fetchall() summary = duckdb_conn.execute("DESCRIBE uploaded_file").fetchall()
+5 -2
View File
@@ -5,6 +5,7 @@ from app.connectors.postgres import PostgresConnector
from app.connectors.clickhouse import ClickHouseConnector from app.connectors.clickhouse import ClickHouseConnector
from app.connectors.parquet import ParquetConnector from app.connectors.parquet import ParquetConnector
from app.models.datasource import DataSource from app.models.datasource import DataSource
from app.core.files import resolve_upload_file_path
@functools.lru_cache(maxsize=32) @functools.lru_cache(maxsize=32)
def _get_cached_connector(ds_type: str, config_json: str): def _get_cached_connector(ds_type: str, config_json: str):
@@ -20,7 +21,8 @@ def _get_cached_connector(ds_type: str, config_json: str):
# SQLite uses connection string usually file path # SQLite uses connection string usually file path
db_url = config.get("connection_string") db_url = config.get("connection_string")
if not db_url and config.get("file_path"): if not db_url and config.get("file_path"):
db_url = f"sqlite:///{config.get('file_path')}" file_path = str(resolve_upload_file_path(config.get("file_path")))
db_url = f"sqlite:///{file_path}"
return PostgresConnector(db_url=db_url) return PostgresConnector(db_url=db_url)
elif ds_type == "clickhouse": elif ds_type == "clickhouse":
@@ -33,7 +35,8 @@ def _get_cached_connector(ds_type: str, config_json: str):
) )
elif ds_type == "parquet": elif ds_type == "parquet":
return ParquetConnector(file_path=config.get("file_path")) file_path = str(resolve_upload_file_path(config.get("file_path")))
return ParquetConnector(file_path=file_path)
else: else:
raise ValueError(f"Unsupported data source type: {ds_type}") raise ValueError(f"Unsupported data source type: {ds_type}")
+18
View File
@@ -0,0 +1,18 @@
import os
from pathlib import Path
from typing import Optional
def resolve_upload_file_path(file_url: Optional[str]) -> Path:
if not file_url:
raise ValueError("File URL is empty")
if file_url.startswith("local://"):
raw_name = file_url.replace("local://", "", 1)
safe_name = os.path.basename(raw_name)
# Assuming we are in backend/app/core, go up to backend/data/uploads
upload_dir = Path(__file__).resolve().parents[2] / "data" / "uploads"
file_path = upload_dir / safe_name
return file_path
# If it's already an absolute path (or relative path not starting with local://)
return Path(file_url)
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
+61 -2
View File
@@ -1,7 +1,8 @@
import { useState } from "react"; import { useState, useRef } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Loader2, Check, AlertTriangle } from "lucide-react"; import { Loader2, Check, AlertTriangle, Upload } from "lucide-react";
import { api } from "@/lib/api";
export interface DataSourceConfig { export interface DataSourceConfig {
id?: number; id?: number;
@@ -24,11 +25,43 @@ export function DataSourceForm({ initialData, onSubmit, onTest, onCancel }: Data
const [isTesting, setIsTesting] = useState(false); const [isTesting, setIsTesting] = useState(false);
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null); const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleConfigChange = (key: string, value: any) => { const handleConfigChange = (key: string, value: any) => {
setConfig(prev => ({ ...prev, [key]: value })); setConfig(prev => ({ ...prev, [key]: value }));
}; };
const handleFileSelect = () => {
fileInputRef.current?.click();
};
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setIsUploading(true);
const formData = new FormData();
formData.append("file", file);
try {
// @ts-ignore
const res = await api.post("/api/v1/upload/file", formData);
if (res && (res as any).url) {
handleConfigChange("file_path", (res as any).url);
}
} catch (error) {
console.error("Upload failed", error);
alert("上传失败");
} finally {
setIsUploading(false);
// Clear input value so same file can be selected again
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
}
};
const handleTest = async () => { const handleTest = async () => {
setIsTesting(true); setIsTesting(true);
setTestResult(null); setTestResult(null);
@@ -175,11 +208,24 @@ export function DataSourceForm({ initialData, onSubmit, onTest, onCancel }: Data
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium">File Path (Server Side)</label> <label className="text-sm font-medium">File Path (Server Side)</label>
<div className="flex gap-2">
<Input <Input
value={config.file_path || ""} value={config.file_path || ""}
onChange={e => handleConfigChange("file_path", e.target.value)} onChange={e => handleConfigChange("file_path", e.target.value)}
placeholder="/path/to/database.db" placeholder="/path/to/database.db"
/> />
<Button type="button" variant="outline" onClick={handleFileSelect} disabled={isUploading}>
{isUploading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
</Button>
<input
key="sqlite-input"
type="file"
ref={fileInputRef}
className="hidden"
accept=".db,.sqlite,.sqlite3"
onChange={handleFileUpload}
/>
</div>
</div> </div>
</div> </div>
); );
@@ -188,11 +234,24 @@ export function DataSourceForm({ initialData, onSubmit, onTest, onCancel }: Data
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium">File Path (Server Side)</label> <label className="text-sm font-medium">File Path (Server Side)</label>
<div className="flex gap-2">
<Input <Input
value={config.file_path || ""} value={config.file_path || ""}
onChange={e => handleConfigChange("file_path", e.target.value)} onChange={e => handleConfigChange("file_path", e.target.value)}
placeholder="/path/to/data.parquet" placeholder="/path/to/data.parquet"
/> />
<Button type="button" variant="outline" onClick={handleFileSelect} disabled={isUploading}>
{isUploading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
</Button>
<input
key="parquet-input"
type="file"
ref={fileInputRef}
className="hidden"
accept=".parquet"
onChange={handleFileUpload}
/>
</div>
</div> </div>
</div> </div>
); );