From cec5fde0982f0225f8c8cab92ca1db41094dd0e9 Mon Sep 17 00:00:00 2001 From: qixinbo Date: Mon, 16 Mar 2026 16:12:35 +0800 Subject: [PATCH] feat: add project --- backend/app/api/datasources.py | 63 ++--- backend/app/api/projects.py | 92 +++++++ backend/app/api/skills.py | 43 +++- backend/app/core/security.py | 32 ++- backend/app/models/datasource.py | 6 +- backend/app/models/project.py | 16 ++ backend/app/models/user.py | 3 + backend/app/schemas/datasource.py | 1 + backend/app/schemas/project.py | 23 ++ backend/dataclaw.db | Bin 28672 -> 36864 bytes backend/main.py | 4 +- frontend/src/App.tsx | 19 +- frontend/src/components/ChatInterface.tsx | 123 ++++++--- .../components/InlineVisualizationCard.tsx | 7 +- frontend/src/components/ProjectSwitcher.tsx | 123 +++++++++ frontend/src/components/Sidebar.tsx | 35 ++- .../src/components/VisualizationPanel.tsx | 7 +- frontend/src/pages/Dashboard.tsx | 48 ++-- frontend/src/pages/DataSources.tsx | 20 +- frontend/src/pages/Projects.tsx | 238 ++++++++++++++++++ frontend/src/pages/Skills.tsx | 112 ++++++--- frontend/src/store/dashboardStore.ts | 48 ++-- frontend/src/store/projectStore.ts | 106 ++++++++ 23 files changed, 990 insertions(+), 179 deletions(-) create mode 100644 backend/app/api/projects.py create mode 100644 backend/app/models/project.py create mode 100644 backend/app/schemas/project.py create mode 100644 frontend/src/components/ProjectSwitcher.tsx create mode 100644 frontend/src/pages/Projects.tsx create mode 100644 frontend/src/store/projectStore.ts diff --git a/backend/app/api/datasources.py b/backend/app/api/datasources.py index 4d0ee37..1f8215d 100644 --- a/backend/app/api/datasources.py +++ b/backend/app/api/datasources.py @@ -1,57 +1,35 @@ -from typing import List, Dict, Any +from typing import List, Dict, Any, Optional from fastapi import APIRouter, HTTPException, Depends, status -from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer -from jose import jwt, JWTError from sqlalchemy.orm import Session from app.database import get_db from app.models.datasource import DataSource from app.schemas.datasource import DataSourceCreate, DataSourceUpdate, DataSource as DataSourceSchema, DataSourceTestRequest -from app.core.security import SECRET_KEY, ALGORITHM +from app.core.security import get_current_user, get_admin_user, CurrentUser from app.connectors.factory import get_connector_from_config from pydantic import BaseModel router = APIRouter() -security = HTTPBearer() - -class CurrentUser(BaseModel): - id: int - username: str - is_admin: bool = False - -def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> CurrentUser: - unauthorized = HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid authentication credentials", - ) - try: - payload = jwt.decode(credentials.credentials, SECRET_KEY, algorithms=[ALGORITHM]) - except JWTError: - raise unauthorized - user_id = payload.get("id") - username = payload.get("sub") - is_admin = bool(payload.get("is_admin", False)) - if user_id is None or username is None: - raise unauthorized - return CurrentUser(id=user_id, username=username, is_admin=is_admin) - -def get_admin_user(current_user: CurrentUser = Depends(get_current_user)) -> CurrentUser: - if not current_user.is_admin: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin permission required") - return current_user @router.get("/datasources", response_model=List[DataSourceSchema]) def list_datasources( + project_id: Optional[int] = None, skip: int = 0, limit: int = 100, db: Session = Depends(get_db), current_user: CurrentUser = Depends(get_current_user) ): - # Admin can see all, regular user might only see allowed ones? - # For now, let's assume only admin can manage, but maybe regular users can see them to use? - # The requirement says "Add data source config in Admin User Center", implying management is admin-only. - # But usage in chat should be available to users. - # Let's allow read for all authenticated users for now. - datasources = db.query(DataSource).offset(skip).limit(limit).all() + query = db.query(DataSource) + if project_id: + query = query.filter(DataSource.project_id == project_id) + + # If not admin, check if user has access to the project + if not current_user.is_admin and project_id: + from app.models.project import Project + project = db.query(Project).filter(Project.id == project_id).first() + if not project or project.owner_id != current_user.id: + raise HTTPException(status_code=403, detail="Not enough permissions for this project") + + datasources = query.offset(skip).limit(limit).all() # Hide sensitive info for non-admins if necessary, but config usually contains secrets. # Maybe we should return a sanitized version for regular users? @@ -75,8 +53,17 @@ def list_datasources( def create_datasource( datasource: DataSourceCreate, db: Session = Depends(get_db), - _: CurrentUser = Depends(get_admin_user) + current_user: CurrentUser = Depends(get_current_user) ): + # Check if project exists and user has access + from app.models.project import Project + project = db.query(Project).filter(Project.id == datasource.project_id).first() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + if not current_user.is_admin and project.owner_id != current_user.id: + raise HTTPException(status_code=403, detail="Not enough permissions for this project") + db_datasource = DataSource(**datasource.dict()) db.add(db_datasource) db.commit() diff --git a/backend/app/api/projects.py b/backend/app/api/projects.py new file mode 100644 index 0000000..f8b2d22 --- /dev/null +++ b/backend/app/api/projects.py @@ -0,0 +1,92 @@ +from typing import List +from fastapi import APIRouter, HTTPException, Depends, status +from sqlalchemy.orm import Session +from app.database import get_db +from app.models.project import Project +from app.schemas.project import ProjectCreate, ProjectUpdate, Project as ProjectSchema +from app.core.security import get_current_user, CurrentUser + +router = APIRouter() + +@router.get("/projects", response_model=List[ProjectSchema]) +def list_projects( + skip: int = 0, + limit: int = 100, + db: Session = Depends(get_db), + current_user: CurrentUser = Depends(get_current_user) +): + # Users can only see their own projects, unless they are admin (who can see all?) + # For simplicity, let's allow users to see their own projects. + query = db.query(Project) + if not current_user.is_admin: + query = query.filter(Project.owner_id == current_user.id) + + projects = query.offset(skip).limit(limit).all() + return projects + +@router.post("/projects", response_model=ProjectSchema) +def create_project( + project: ProjectCreate, + db: Session = Depends(get_db), + current_user: CurrentUser = Depends(get_current_user) +): + db_project = Project(**project.dict(), owner_id=current_user.id) + db.add(db_project) + db.commit() + db.refresh(db_project) + return db_project + +@router.get("/projects/{project_id}", response_model=ProjectSchema) +def read_project( + project_id: int, + db: Session = Depends(get_db), + current_user: CurrentUser = Depends(get_current_user) +): + db_project = db.query(Project).filter(Project.id == project_id).first() + if db_project is None: + raise HTTPException(status_code=404, detail="Project not found") + + if not current_user.is_admin and db_project.owner_id != current_user.id: + raise HTTPException(status_code=403, detail="Not enough permissions") + + return db_project + +@router.put("/projects/{project_id}", response_model=ProjectSchema) +def update_project( + project_id: int, + project: ProjectUpdate, + db: Session = Depends(get_db), + current_user: CurrentUser = Depends(get_current_user) +): + db_project = db.query(Project).filter(Project.id == project_id).first() + if db_project is None: + raise HTTPException(status_code=404, detail="Project not found") + + if not current_user.is_admin and db_project.owner_id != current_user.id: + raise HTTPException(status_code=403, detail="Not enough permissions") + + project_data = project.dict(exclude_unset=True) + for key, value in project_data.items(): + setattr(db_project, key, value) + + db.add(db_project) + db.commit() + db.refresh(db_project) + return db_project + +@router.delete("/projects/{project_id}") +def delete_project( + project_id: int, + db: Session = Depends(get_db), + current_user: CurrentUser = Depends(get_current_user) +): + db_project = db.query(Project).filter(Project.id == project_id).first() + if db_project is None: + raise HTTPException(status_code=404, detail="Project not found") + + if not current_user.is_admin and db_project.owner_id != current_user.id: + raise HTTPException(status_code=403, detail="Not enough permissions") + + db.delete(db_project) + db.commit() + return {"status": "success"} diff --git a/backend/app/api/skills.py b/backend/app/api/skills.py index e0f5f62..7c984a4 100644 --- a/backend/app/api/skills.py +++ b/backend/app/api/skills.py @@ -14,6 +14,7 @@ class Skill(BaseModel): description: Optional[str] = Field(None, description="Description of what the skill does") content: str = Field(..., description="The content/prompt/logic of the skill") type: str = Field("python", description="Type of the skill (python, sql, api)") + project_id: Optional[int] = Field(None, description="The ID of the project this skill belongs to") class SkillCreate(BaseModel): id: str @@ -21,12 +22,14 @@ class SkillCreate(BaseModel): description: Optional[str] = None content: str type: str = "python" + project_id: Optional[int] = None class SkillUpdate(BaseModel): name: Optional[str] = None description: Optional[str] = None content: Optional[str] = None type: Optional[str] = None + project_id: Optional[int] = None def _load_data() -> List[Dict[str, Any]]: if not os.path.exists(DATA_FILE): @@ -42,19 +45,24 @@ def _save_data(data: List[Dict[str, Any]]): with open(DATA_FILE, "w") as f: json.dump(data, f, indent=2) -def load_skills() -> List[Dict[str, Any]]: - return _load_data() +def load_skills(project_id: Optional[int] = None) -> List[Dict[str, Any]]: + data = _load_data() + if project_id is not None: + return [item for item in data if item.get("project_id") == project_id] + return data @router.get("/skills", response_model=List[Skill]) -def list_skills(): - data = load_skills() +def list_skills(project_id: Optional[int] = None): + data = load_skills(project_id) return [Skill(**item) for item in data] @router.get("/skills/{skill_id}", response_model=Skill) -def get_skill(skill_id: str): +def get_skill(skill_id: str, project_id: Optional[int] = None): data = _load_data() for item in data: if item["id"] == skill_id: + if project_id is not None and item.get("project_id") != project_id: + continue return Skill(**item) raise HTTPException(status_code=404, detail="Skill not found") @@ -70,10 +78,12 @@ def create_skill(skill: SkillCreate): return Skill(**new_skill) @router.put("/skills/{skill_id}", response_model=Skill) -def update_skill(skill_id: str, skill: SkillUpdate): +def update_skill(skill_id: str, skill: SkillUpdate, project_id: Optional[int] = None): data = _load_data() for i, item in enumerate(data): if item["id"] == skill_id: + if project_id is not None and item.get("project_id") != project_id: + continue updated_item = item.copy() update_data = skill.dict(exclude_unset=True) updated_item.update(update_data) @@ -83,11 +93,24 @@ def update_skill(skill_id: str, skill: SkillUpdate): raise HTTPException(status_code=404, detail="Skill not found") @router.delete("/skills/{skill_id}") -def delete_skill(skill_id: str): +def delete_skill(skill_id: str, project_id: Optional[int] = None): data = _load_data() initial_len = len(data) - data = [item for item in data if item["id"] != skill_id] - if len(data) == initial_len: + + # If project_id is provided, we only delete if it matches + new_data = [] + found = False + for item in data: + if item["id"] == skill_id: + if project_id is not None and item.get("project_id") != project_id: + new_data.append(item) + continue + found = True + else: + new_data.append(item) + + if not found: raise HTTPException(status_code=404, detail="Skill not found") - _save_data(data) + + _save_data(new_data) return {"message": "Skill deleted successfully"} diff --git a/backend/app/core/security.py b/backend/app/core/security.py index 4e075c2..c98b861 100644 --- a/backend/app/core/security.py +++ b/backend/app/core/security.py @@ -1,13 +1,22 @@ from datetime import datetime, timedelta from typing import Optional -from jose import jwt +from jose import jwt, JWTError from passlib.context import CryptContext +from fastapi import HTTPException, Depends, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from pydantic import BaseModel SECRET_KEY = "your-super-secret-key-for-dataclaw" # In production, use env variable ALGORITHM = "HS256" ACCESS_TOKEN_EXPIRE_MINUTES = 30 * 24 * 60 # 30 days pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto") +security = HTTPBearer() + +class CurrentUser(BaseModel): + id: int + username: str + is_admin: bool = False def verify_password(plain_password, hashed_password): return pwd_context.verify(plain_password, hashed_password) @@ -24,3 +33,24 @@ def create_access_token(data: dict, expires_delta: Optional[timedelta] = None): to_encode.update({"exp": expire}) encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) return encoded_jwt + +def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> CurrentUser: + unauthorized = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid authentication credentials", + ) + try: + payload = jwt.decode(credentials.credentials, SECRET_KEY, algorithms=[ALGORITHM]) + except JWTError: + raise unauthorized + user_id = payload.get("id") + username = payload.get("sub") + is_admin = bool(payload.get("is_admin", False)) + if user_id is None or username is None: + raise unauthorized + return CurrentUser(id=user_id, username=username, is_admin=is_admin) + +def get_admin_user(current_user: CurrentUser = Depends(get_current_user)) -> CurrentUser: + if not current_user.is_admin: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin permission required") + return current_user diff --git a/backend/app/models/datasource.py b/backend/app/models/datasource.py index ed0f6a3..17f9d86 100644 --- a/backend/app/models/datasource.py +++ b/backend/app/models/datasource.py @@ -1,4 +1,5 @@ -from sqlalchemy import Column, Integer, String, JSON, DateTime, func +from sqlalchemy import Column, Integer, String, JSON, DateTime, ForeignKey, func +from sqlalchemy.orm import relationship from app.database import Base class DataSource(Base): @@ -8,5 +9,8 @@ class DataSource(Base): name = Column(String, nullable=False) type = Column(String, nullable=False) config = Column(JSON, nullable=False) + project_id = Column(Integer, ForeignKey("projects.id"), nullable=False) created_at = Column(DateTime, default=func.now()) updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) + + project = relationship("Project", back_populates="data_sources") diff --git a/backend/app/models/project.py b/backend/app/models/project.py new file mode 100644 index 0000000..a2df9c7 --- /dev/null +++ b/backend/app/models/project.py @@ -0,0 +1,16 @@ +from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, func +from sqlalchemy.orm import relationship +from app.database import Base + +class Project(Base): + __tablename__ = "projects" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String, nullable=False) + description = Column(String, nullable=True) + owner_id = Column(Integer, ForeignKey("users.id"), nullable=False) + created_at = Column(DateTime, default=func.now()) + updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) + + owner = relationship("User", back_populates="projects") + data_sources = relationship("DataSource", back_populates="project", cascade="all, delete-orphan") diff --git a/backend/app/models/user.py b/backend/app/models/user.py index b49cee6..ffd3dfd 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -1,4 +1,5 @@ from sqlalchemy import Column, Integer, String, Boolean, DateTime +from sqlalchemy.orm import relationship from sqlalchemy.sql import func from app.database import Base @@ -12,3 +13,5 @@ class User(Base): is_active = Column(Boolean, default=True) is_admin = Column(Boolean, default=False) created_at = Column(DateTime(timezone=True), server_default=func.now()) + + projects = relationship("Project", back_populates="owner") diff --git a/backend/app/schemas/datasource.py b/backend/app/schemas/datasource.py index fb432d8..7dc6648 100644 --- a/backend/app/schemas/datasource.py +++ b/backend/app/schemas/datasource.py @@ -6,6 +6,7 @@ class DataSourceBase(BaseModel): name: str type: str # sqlite, postgres, clickhouse, supabase, parquet config: Dict[str, Any] + project_id: int class DataSourceCreate(DataSourceBase): pass diff --git a/backend/app/schemas/project.py b/backend/app/schemas/project.py new file mode 100644 index 0000000..c7639b0 --- /dev/null +++ b/backend/app/schemas/project.py @@ -0,0 +1,23 @@ +from pydantic import BaseModel +from typing import Optional, List +from datetime import datetime + +class ProjectBase(BaseModel): + name: str + description: Optional[str] = None + +class ProjectCreate(ProjectBase): + pass + +class ProjectUpdate(BaseModel): + name: Optional[str] = None + description: Optional[str] = None + +class Project(ProjectBase): + id: int + owner_id: int + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True diff --git a/backend/dataclaw.db b/backend/dataclaw.db index 2af59774e1b759fc8458f21d2eab2d97c5bcf8be..d2c31991babe76e18dd40e6a4db7fe3502b521f3 100644 GIT binary patch delta 466 zcmZp8z}T>WX@az%5Ca1PClJE`$3z`tSs@0!vQA#05C?ZO1K%gUseFOFe|g1uwsWuM zj^5aKpR3-FgH2phmN7LmFD11ivm(BrC_gJTxuiHgGX=)vbPjTL3~^QP^mB2IP(W3q z;P0mZQ>UPjnWEXm$<8h=E6doNT9TNQlM0iA(Hu~-LL8lZT!A`S!M077<(HXU%qyv$ zl3JWxlvz-cnV+W+<{0Gc;TWW&z{Qzgo|jq#bP>=AA+GMOL6bM|dK-cy-28)FJ>C5j zyj>$TV2U&qf?VBPgIxWbU4s=$i&Kk=!4Bin+`OH~Gnoez8ch5%82Hcd&)6)e(8FJE z#Kg`Z%D`xBoSs@-!fa$Lkljj g&EFXK|L}i<#S)O-ER%4bfAR-=7B(g(W@b)K0OhQCNj?U>vQA#05F5{S2EI>xQ~3gU|MH6SY~L&> MV9&F8JCA2F0ED9rsQ>@~ diff --git a/backend/main.py b/backend/main.py index 3afe38b..c8c4a66 100644 --- a/backend/main.py +++ b/backend/main.py @@ -7,7 +7,7 @@ import asyncio import json from datetime import datetime -from app.api import upload, llm, skills, users, datasources +from app.api import upload, llm, skills, users, datasources, projects from app.connectors.postgres import postgres_connector from app.connectors.clickhouse import clickhouse_connector from app.core.nanobot import nanobot_service @@ -16,6 +16,7 @@ from app.agent.nl2sql import process_nl2sql, NL2SQLRequest, NL2SQLResponse from app.database import engine, Base # Import all models to ensure they are registered from app.models.user import User +from app.models.project import Project from app.models.datasource import DataSource app = FastAPI() @@ -35,6 +36,7 @@ app.include_router(upload.router, prefix="/api/v1") app.include_router(llm.router, prefix="/api/v1") app.include_router(skills.router, prefix="/api/v1") app.include_router(users.router, prefix="/api/v1") +app.include_router(projects.router, prefix="/api/v1") app.include_router(datasources.router, prefix="/api/v1") @app.on_event("startup") diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1847587..6aa0987 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,10 +1,12 @@ import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom"; import { Sidebar } from "./components/Sidebar"; +import { ProjectSwitcher } from "./components/ProjectSwitcher"; import { ChatInterface } from "./components/ChatInterface"; import { Dashboard } from "./pages/Dashboard"; import { Skills } from "./pages/Skills"; import { Settings } from "./pages/Settings"; import { Users } from "./pages/Users"; +import { Projects } from "./pages/Projects"; import { Login } from "./pages/Login"; import { ModelConfigs } from "./pages/ModelConfigs"; import { DataSources } from "./pages/DataSources"; @@ -29,8 +31,13 @@ function MainLayout({ children }: { children: React.ReactNode }) { return (
-
- {children} +
+
+ +
+
+ {children} +
); @@ -77,6 +84,14 @@ function App() { } /> + + + + + + } /> + diff --git a/frontend/src/components/ChatInterface.tsx b/frontend/src/components/ChatInterface.tsx index c822658..c14801d 100644 --- a/frontend/src/components/ChatInterface.tsx +++ b/frontend/src/components/ChatInterface.tsx @@ -13,6 +13,7 @@ import remarkGfm from 'remark-gfm'; import rehypeRaw from 'rehype-raw'; import { useLocation } from "react-router-dom"; import { InlineVisualizationCard } from "./InlineVisualizationCard"; +import { useProjectStore } from "@/store/projectStore"; interface Message { id: string; @@ -76,6 +77,7 @@ export function ChatInterface() { const [isLoading, setIsLoading] = useState(false); const scrollRef = useRef(null); const location = useLocation(); + const { currentProject } = useProjectStore(); // Model selection state const [models, setModels] = useState([]); @@ -83,10 +85,7 @@ export function ChatInterface() { const [modelOpen, setModelOpen] = useState(false); // Data Source selection state - const [availableDataSources, setAvailableDataSources] = useState<{id: string, name: string}[]>([ - { id: "postgres-main", name: "PostgreSQL" }, - { id: "clickhouse-main", name: "ClickHouse" } - ]); + const [availableDataSources, setAvailableDataSources] = useState<{id: string, name: string}[]>([]); // Try to parse active session from URL query const queryParams = new URLSearchParams(location.search); @@ -101,16 +100,29 @@ export function ChatInterface() { useEffect(() => { fetchModels(); - fetchDataSources(); }, []); + useEffect(() => { + if (currentProject) { + fetchDataSources(); + } + }, [currentProject]); + const fetchDataSources = async () => { + if (!currentProject) return; try { - const data = await api.get>("/api/v1/datasources"); - setAvailableDataSources(prev => [ - ...prev.filter(d => !d.id.startsWith("ds:")), - ...data.map(d => ({ id: `ds:${d.id}`, name: d.name })) - ]); + const data = await api.get>(`/api/v1/datasources?project_id=${currentProject.id}`); + const projectSources = data.map(d => ({ id: `ds:${d.id}`, name: d.name })); + setAvailableDataSources(projectSources); + + // Default select the first one if current selection is not in the list + if (projectSources.length > 0) { + if (!selectedDataSource.startsWith("ds:") || !projectSources.find(ds => ds.id === selectedDataSource)) { + setSelectedDataSource(projectSources[0].id); + } + } else { + setSelectedDataSource("upload"); // Default to upload if no data sources + } } catch (e) { console.error("Failed to fetch data sources", e); } @@ -282,14 +294,18 @@ export function ChatInterface() { useEffect(() => { const fetchSkills = async () => { try { - const skills = await api.get("/api/v1/skills"); + let url = "/api/v1/skills"; + if (currentProject) { + url += `?project_id=${currentProject.id}`; + } + const skills = await api.get(url); setAvailableSkills(skills); } catch (err) { console.error("Failed to fetch skills:", err); } }; fetchSkills(); - }, []); + }, [currentProject]); useEffect(() => { if (scrollRef.current) { @@ -340,12 +356,21 @@ export function ChatInterface() { const token = localStorage.getItem("token"); const effectiveModelId = selectedModelId || currentModel?.id || ""; - const selectedSource = selectedDataSource.split('-')[0]; + + // Correctly parse source from selectedDataSource (could be 'ds:ID', 'upload', or legacy 'postgres-main') + let source = selectedDataSource; + if (selectedDataSource.includes("-")) { + source = selectedDataSource.split("-")[0]; + } + const useUploadSource = Boolean( currentAttachedFile?.url?.startsWith("local://") || - (selectedSource === "upload" && activeDataFile?.url?.startsWith("local://")) + (source === "upload" && activeDataFile?.url?.startsWith("local://")) ); - const source = useUploadSource ? "upload" : selectedSource; + if (useUploadSource) { + source = "upload"; + } + const fileUrl = useUploadSource ? (currentAttachedFile?.url || activeDataFile?.url) : undefined; const preferSqlChart = chartIntentPattern.test(messagePayload); const response = await fetch("/nanobot/chat/stream", { @@ -570,19 +595,11 @@ export function ChatInterface() { 数据源
- {[ - { id: 'postgres-main', label: 'Postgres (Main)', icon: Database }, - { id: 'clickhouse-main', label: 'Clickhouse', icon: Database }, - { id: 'upload', label: '本地文件上传', icon: FileIcon }, - ].map((ds) => ( + {availableDataSources.map((ds) => ( ))} + +
@@ -776,19 +813,11 @@ export function ChatInterface() { 数据源
- {[ - { id: 'postgres-main', label: 'Postgres (Main)', icon: Database }, - { id: 'clickhouse-main', label: 'Clickhouse', icon: Database }, - { id: 'upload', label: '本地文件上传', icon: FileIcon }, - ].map((ds) => ( + {availableDataSources.map((ds) => ( ))} + +
diff --git a/frontend/src/components/InlineVisualizationCard.tsx b/frontend/src/components/InlineVisualizationCard.tsx index 01f7c83..023ba22 100644 --- a/frontend/src/components/InlineVisualizationCard.tsx +++ b/frontend/src/components/InlineVisualizationCard.tsx @@ -6,6 +6,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@ import { Code, Table as TableIcon, BarChart as ChartIcon, LayoutDashboard } from "lucide-react"; import { ScrollArea } from "@/components/ui/scroll-area"; import { useDashboardStore, type ChartConfig } from "@/store/dashboardStore"; +import { useProjectStore } from "@/store/projectStore"; import type { ChartSpec } from "@/store/visualizationStore"; import { VegaChart } from "./VegaChart"; @@ -25,6 +26,7 @@ export function InlineVisualizationCard({ viz }: InlineVisualizationCardProps) { const [confirmOpen, setConfirmOpen] = useState(false); const [pendingChart, setPendingChart] = useState | null>(null); const { addChart } = useDashboardStore(); + const { currentProject } = useProjectStore(); const objectRows = viz.rows.filter((row) => row && typeof row === "object" && !Array.isArray(row)) as Record[]; const columns = objectRows.length > 0 ? Object.keys(objectRows[0]) : []; @@ -43,14 +45,15 @@ export function InlineVisualizationCard({ viz }: InlineVisualizationCardProps) { }; const handleAddToDashboard = () => { + if (!currentProject) return; const chart = buildPendingChart(); setPendingChart(chart); setConfirmOpen(true); }; const handleConfirmAdd = () => { - if (!pendingChart) return; - addChart(pendingChart); + if (!pendingChart || !currentProject) return; + addChart(pendingChart, currentProject.id); setConfirmOpen(false); setPendingChart(null); }; diff --git a/frontend/src/components/ProjectSwitcher.tsx b/frontend/src/components/ProjectSwitcher.tsx new file mode 100644 index 0000000..3fbae33 --- /dev/null +++ b/frontend/src/components/ProjectSwitcher.tsx @@ -0,0 +1,123 @@ +import React, { useEffect, useState } from 'react'; +import { ChevronDown, Plus, Folder } from 'lucide-react'; +import { useProjectStore, type Project } from '@/store/projectStore'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, + DropdownMenuGroup, +} from '@/components/ui/dropdown-menu'; +import { Button } from '@/components/ui/button'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; + +export function ProjectSwitcher() { + const { projects, currentProject, fetchProjects, setCurrentProject, addProject } = useProjectStore(); + const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); + const [newProjectName, setNewProjectName] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + + useEffect(() => { + fetchProjects(); + }, [fetchProjects]); + + const handleCreateProject = async () => { + if (!newProjectName.trim()) return; + setIsSubmitting(true); + try { + await addProject(newProjectName); + setNewProjectName(''); + setIsCreateDialogOpen(false); + } catch (error) { + console.error('Failed to create project:', error); + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+
+ DataClaw + / +
+ + + + + {currentProject?.name || 'Select Project'} + + + + + + PROJECTS + + + + +
+ {projects.map((project) => ( + { + setCurrentProject(project); + }} + className={currentProject?.id === project.id ? 'bg-accent' : ''} + > + + {project.name} + + ))} + {projects.length === 0 && ( +
+ No projects found +
+ )} +
+
+
+ + + + + Create New Project + +
+
+ + setNewProjectName(e.target.value)} + placeholder="Enter project name" + autoFocus + /> +
+
+ + + + +
+
+
+ ); +} diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 3d825c0..1cfe980 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -1,7 +1,7 @@ import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"; import { Button } from "@/components/ui/button"; import { ScrollArea } from "@/components/ui/scroll-area"; -import { Menu, LayoutDashboard, Plus, MoreVertical, User, Search, Wrench, Settings, Brain, Trash2, Pencil, Pin, Archive, Database, CheckSquare, Square, ListChecks, RotateCcw, Wand2 } from "lucide-react"; +import { Menu, LayoutDashboard, Plus, MoreVertical, User, Search, Wrench, Settings, Brain, Trash2, Pencil, Pin, Archive, Database, CheckSquare, Square, ListChecks, RotateCcw, Wand2, Folder } from "lucide-react"; import { useState, useRef, useEffect } from "react"; import { Link, useNavigate, useLocation } from "react-router-dom"; import { useAuthStore } from "@/store/authStore"; @@ -561,6 +561,28 @@ function SidebarBody() {

{user?.email}

+ + + + - - diff --git a/frontend/src/pages/DataSources.tsx b/frontend/src/pages/DataSources.tsx index 57ad425..6e9e13b 100644 --- a/frontend/src/pages/DataSources.tsx +++ b/frontend/src/pages/DataSources.tsx @@ -2,9 +2,10 @@ import { useState, useEffect } from "react"; import { api } from "@/lib/api"; import { DataSourceForm, type DataSourceConfig } from "@/components/DataSourceForm"; import { Button } from "@/components/ui/button"; -import { Plus, Database, Pencil, Trash2, Loader2 } from "lucide-react"; +import { Plus, Database, Pencil, Trash2, Loader2, FolderOpen } from "lucide-react"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { useAuthStore } from "@/store/authStore"; +import { useProjectStore } from "@/store/projectStore"; import { useNavigate } from "react-router-dom"; export function DataSources() { @@ -13,20 +14,20 @@ export function DataSources() { const [isOpen, setIsOpen] = useState(false); const [editingDs, setEditingDs] = useState(null); const { user } = useAuthStore(); + const { currentProject } = useProjectStore(); const navigate = useNavigate(); useEffect(() => { - if (!user?.is_admin) { - navigate("/"); - return; + if (currentProject) { + fetchDataSources(); } - fetchDataSources(); - }, [user]); + }, [currentProject]); const fetchDataSources = async () => { + if (!currentProject) return; setIsLoading(true); try { - const data = await api.get("/api/v1/datasources"); + const data = await api.get(`/api/v1/datasources?project_id=${currentProject.id}`); setDatasources(data); } catch (e) { console.error("Failed to fetch data sources", e); @@ -56,11 +57,12 @@ export function DataSources() { }; const handleSubmit = async (data: Omit) => { + if (!currentProject) return; try { if (editingDs?.id) { - await api.put(`/api/v1/datasources/${editingDs.id}`, data); + await api.put(`/api/v1/datasources/${editingDs.id}`, { ...data, project_id: currentProject.id }); } else { - await api.post("/api/v1/datasources", data); + await api.post("/api/v1/datasources", { ...data, project_id: currentProject.id }); } setIsOpen(false); fetchDataSources(); diff --git a/frontend/src/pages/Projects.tsx b/frontend/src/pages/Projects.tsx new file mode 100644 index 0000000..e21f012 --- /dev/null +++ b/frontend/src/pages/Projects.tsx @@ -0,0 +1,238 @@ +import React, { useEffect, useState } from 'react'; +import { Plus, Folder, Pencil, Trash2, Loader2, Database } from 'lucide-react'; +import { useProjectStore, type Project } from '@/store/projectStore'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { useNavigate } from 'react-router-dom'; + +export function Projects() { + const { projects, loading, fetchProjects, addProject, updateProject, deleteProject, setCurrentProject } = useProjectStore(); + const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); + const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); + const [editingProject, setEditingProject] = useState(null); + const [formData, setFormData] = useState({ name: '', description: '' }); + const [isSubmitting, setIsSubmitting] = useState(false); + const navigate = useNavigate(); + + useEffect(() => { + fetchProjects(); + }, [fetchProjects]); + + const handleCreate = async () => { + if (!formData.name.trim()) return; + setIsSubmitting(true); + try { + await addProject(formData.name, formData.description); + setFormData({ name: '', description: '' }); + setIsCreateDialogOpen(false); + } catch (error) { + console.error('Failed to create project:', error); + } finally { + setIsSubmitting(false); + } + }; + + const handleUpdate = async () => { + if (!editingProject || !formData.name.trim()) return; + setIsSubmitting(true); + try { + await updateProject(editingProject.id, formData.name, formData.description); + setEditingProject(null); + setFormData({ name: '', description: '' }); + setIsEditDialogOpen(false); + } catch (error) { + console.error('Failed to update project:', error); + } finally { + setIsSubmitting(false); + } + }; + + const handleDelete = async (id: number) => { + if (!window.confirm('Are you sure you want to delete this project? All associated data sources will be deleted.')) return; + try { + await deleteProject(id); + } catch (error) { + console.error('Failed to delete project:', error); + } + }; + + const openEditDialog = (project: Project) => { + setEditingProject(project); + setFormData({ name: project.name, description: project.description || '' }); + setIsEditDialogOpen(true); + }; + + const goToDataSources = (project: Project) => { + setCurrentProject(project); + navigate('/datasources'); + }; + + return ( +
+
+
+ + 项目管理 +
+ +
+ +
+
+ + + 项目列表 + 管理您的项目,不同项目拥有独立的数据源 + + + {loading && projects.length === 0 ? ( +
+ +

加载中...

+
+ ) : projects.length === 0 ? ( +
+ +

暂无项目,请先创建一个

+
+ ) : ( + + + + 名称 + 描述 + 创建时间 + 操作 + + + + {projects.map((project) => ( + + {project.name} + + {project.description || '-'} + + + {new Date(project.created_at).toLocaleDateString()} + + +
+ + + +
+
+
+ ))} +
+
+ )} +
+
+
+
+ + {/* Create Dialog */} + + + + 新建项目 + +
+
+ + setFormData({ ...formData, name: e.target.value })} + placeholder="输入项目名称" + /> +
+
+ +