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 2af5977..d2c3199 100644
Binary files a/backend/dataclaw.db and b/backend/dataclaw.db differ
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
+
+ )}
+
+
+
+
+
+
+ );
+}
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 */}
+
+
+ {/* Edit Dialog */}
+
+
+ );
+}
diff --git a/frontend/src/pages/Skills.tsx b/frontend/src/pages/Skills.tsx
index d49003e..6b91570 100644
--- a/frontend/src/pages/Skills.tsx
+++ b/frontend/src/pages/Skills.tsx
@@ -2,13 +2,14 @@ import { useState, useEffect } from 'react';
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
-import { Trash2, Edit2, Plus, Terminal, Loader2 } from "lucide-react";
+import { Trash2, Edit2, Plus, Terminal, Loader2, FolderOpen } from "lucide-react";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogFooter } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { api } from "@/lib/api";
+import { useProjectStore } from "@/store/projectStore";
interface Skill {
id: string;
@@ -16,22 +17,28 @@ interface Skill {
description: string;
content: string;
type: 'python' | 'sql' | 'api';
+ project_id?: number;
}
export function Skills() {
const [skills, setSkills] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const [isDialogOpen, setIsDialogOpen] = useState(false);
+ const [editingSkill, setEditingSkill] = useState(null);
const [newSkill, setNewSkill] = useState>({ type: 'python', content: '' });
+ const { currentProject } = useProjectStore();
useEffect(() => {
- fetchSkills();
- }, []);
+ if (currentProject) {
+ fetchSkills();
+ }
+ }, [currentProject]);
const fetchSkills = async () => {
+ if (!currentProject) return;
setIsLoading(true);
try {
- const data = await api.get('/api/v1/skills');
+ const data = await api.get(`/api/v1/skills?project_id=${currentProject.id}`);
setSkills(data);
} catch (error) {
console.error("Failed to fetch skills", error);
@@ -41,52 +48,90 @@ export function Skills() {
};
const handleAddSkill = async () => {
+ if (!currentProject) return;
if (newSkill.name && newSkill.description && newSkill.content) {
try {
- const skillToCreate = {
- ...newSkill,
- id: Date.now().toString(),
- };
- const createdSkill = await api.post('/api/v1/skills', skillToCreate);
- setSkills([...skills, createdSkill]);
+ if (editingSkill) {
+ const updatedSkill = await api.put(`/api/v1/skills/${editingSkill.id}?project_id=${currentProject.id}`, {
+ ...newSkill,
+ project_id: currentProject.id
+ });
+ setSkills(skills.map(s => s.id === editingSkill.id ? updatedSkill : s));
+ } else {
+ const skillToCreate = {
+ ...newSkill,
+ id: Date.now().toString(),
+ project_id: currentProject.id
+ };
+ const createdSkill = await api.post('/api/v1/skills', skillToCreate);
+ setSkills([...skills, createdSkill]);
+ }
setNewSkill({ type: 'python', content: '' });
+ setEditingSkill(null);
setIsDialogOpen(false);
} catch (error) {
- console.error("Failed to create skill", error);
+ console.error("Failed to save skill", error);
}
}
};
+ const handleEditSkill = (skill: Skill) => {
+ setEditingSkill(skill);
+ setNewSkill(skill);
+ setIsDialogOpen(true);
+ };
+
const handleDeleteSkill = async (id: string) => {
+ if (!currentProject) return;
+ if (!window.confirm("确定要删除这个技能吗?")) return;
try {
- await api.delete(`/api/v1/skills/${id}`);
+ await api.delete(`/api/v1/skills/${id}?project_id=${currentProject.id}`);
setSkills(skills.filter(s => s.id !== id));
} catch (error) {
console.error("Failed to delete skill", error);
}
};
+ if (!currentProject) {
+ return (
+
+ );
+ }
+
return (
-
Skills
-
Manage AI capabilities and tools
+
技能管理 - {currentProject.name}
+
管理该项目的 AI 技能和工具
-
-
)}
diff --git a/frontend/src/store/dashboardStore.ts b/frontend/src/store/dashboardStore.ts
index 84ccf17..7ba4278 100644
--- a/frontend/src/store/dashboardStore.ts
+++ b/frontend/src/store/dashboardStore.ts
@@ -16,17 +16,22 @@ export interface ChartConfig {
interface DashboardState {
charts: ChartConfig[];
- addChart: (chart: Omit
) => void;
- removeChart: (id: string) => void;
- updateLayout: (layouts: GridLayout[]) => void;
+ addChart: (chart: Omit, projectId: number) => void;
+ removeChart: (id: string, projectId: number) => void;
+ updateLayout: (layouts: GridLayout[], projectId: number) => void;
+ loadCharts: (projectId: number) => void;
}
-const DASHBOARD_STORAGE_KEY = 'dashboard_charts_v1';
+const DASHBOARD_STORAGE_KEY_PREFIX = 'dashboard_charts_v1_project_';
-function loadChartsFromStorage(): ChartConfig[] {
+function getStorageKey(projectId: number) {
+ return `${DASHBOARD_STORAGE_KEY_PREFIX}${projectId}`;
+}
+
+function loadChartsFromStorage(projectId: number): ChartConfig[] {
if (typeof window === 'undefined') return [];
try {
- const raw = window.localStorage.getItem(DASHBOARD_STORAGE_KEY);
+ const raw = window.localStorage.getItem(getStorageKey(projectId));
if (!raw) return [];
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
@@ -47,14 +52,17 @@ function loadChartsFromStorage(): ChartConfig[] {
}
}
-function saveChartsToStorage(charts: ChartConfig[]) {
+function saveChartsToStorage(charts: ChartConfig[], projectId: number) {
if (typeof window === 'undefined') return;
- window.localStorage.setItem(DASHBOARD_STORAGE_KEY, JSON.stringify(charts));
+ window.localStorage.setItem(getStorageKey(projectId), JSON.stringify(charts));
}
-export const useDashboardStore = create((set) => ({
- charts: loadChartsFromStorage(),
- addChart: (chart) => set((state) => {
+export const useDashboardStore = create((set, get) => ({
+ charts: [],
+ loadCharts: (projectId) => {
+ set({ charts: loadChartsFromStorage(projectId) });
+ },
+ addChart: (chart, projectId) => set((state) => {
const colSize = 4;
const cols = 12 / colSize;
const index = state.charts.length;
@@ -66,22 +74,20 @@ export const useDashboardStore = create((set) => ({
h: 4,
};
const nextCharts = [...state.charts, { ...chart, layout: newLayout }];
- saveChartsToStorage(nextCharts);
+ saveChartsToStorage(nextCharts, projectId);
return { charts: nextCharts };
}),
- removeChart: (id) => set((state) => ({
- charts: (() => {
- const nextCharts = state.charts.filter((c) => c.id !== id);
- saveChartsToStorage(nextCharts);
- return nextCharts;
- })(),
- })),
- updateLayout: (layouts) => set((state) => {
+ removeChart: (id, projectId) => set((state) => {
+ const nextCharts = state.charts.filter((c) => c.id !== id);
+ saveChartsToStorage(nextCharts, projectId);
+ return { charts: nextCharts };
+ }),
+ updateLayout: (layouts, projectId) => set((state) => {
const nextCharts = state.charts.map((chart) => {
const layout = layouts.find((l) => l.i === chart.id);
return layout ? { ...chart, layout } : chart;
});
- saveChartsToStorage(nextCharts);
+ saveChartsToStorage(nextCharts, projectId);
return { charts: nextCharts };
}),
}));
diff --git a/frontend/src/store/projectStore.ts b/frontend/src/store/projectStore.ts
new file mode 100644
index 0000000..da9c58a
--- /dev/null
+++ b/frontend/src/store/projectStore.ts
@@ -0,0 +1,106 @@
+import { create } from 'zustand';
+import { api } from '@/lib/api';
+
+export interface Project {
+ id: number;
+ name: string;
+ description?: string;
+ owner_id: number;
+ created_at: string;
+ updated_at: string;
+}
+
+interface ProjectState {
+ projects: Project[];
+ currentProject: Project | null;
+ loading: boolean;
+ error: string | null;
+ fetchProjects: () => Promise;
+ setCurrentProject: (project: Project) => void;
+ addProject: (name: string, description?: string) => Promise;
+ updateProject: (id: number, name: string, description?: string) => Promise;
+ deleteProject: (id: number) => Promise;
+}
+
+export const useProjectStore = create((set, get) => ({
+ projects: [],
+ currentProject: JSON.parse(localStorage.getItem('currentProject') || 'null'),
+ loading: false,
+ error: null,
+
+ fetchProjects: async () => {
+ set({ loading: true, error: null });
+ try {
+ const projects = await api.get('/api/v1/projects');
+ set({ projects, loading: false });
+
+ // Set current project if not set or not in list
+ const current = get().currentProject;
+ if (projects.length > 0) {
+ if (!current || !projects.find((p: Project) => p.id === current.id)) {
+ get().setCurrentProject(projects[0]);
+ }
+ } else {
+ set({ currentProject: null });
+ localStorage.removeItem('currentProject');
+ }
+ } catch (error: any) {
+ set({ error: error.message, loading: false });
+ }
+ },
+
+ setCurrentProject: (project: Project) => {
+ localStorage.setItem('currentProject', JSON.stringify(project));
+ set({ currentProject: project });
+ },
+
+ addProject: async (name: string, description?: string) => {
+ try {
+ const newProject = await api.post('/api/v1/projects', { name, description });
+ set((state) => ({ projects: [...state.projects, newProject] }));
+ if (!get().currentProject) {
+ get().setCurrentProject(newProject);
+ }
+ return newProject;
+ } catch (error: any) {
+ throw new Error(error.message || 'Failed to create project');
+ }
+ },
+
+ updateProject: async (id: number, name: string, description?: string) => {
+ try {
+ const updatedProject = await api.put(`/api/v1/projects/${id}`, { name, description });
+ set((state) => ({
+ projects: state.projects.map((p) => (p.id === id ? updatedProject : p)),
+ currentProject: state.currentProject?.id === id ? updatedProject : state.currentProject,
+ }));
+ if (get().currentProject?.id === id) {
+ localStorage.setItem('currentProject', JSON.stringify(updatedProject));
+ }
+ return updatedProject;
+ } catch (error: any) {
+ throw new Error(error.message || 'Failed to update project');
+ }
+ },
+
+ deleteProject: async (id: number) => {
+ try {
+ await api.delete(`/api/v1/projects/${id}`);
+ set((state) => {
+ const projects = state.projects.filter((p) => p.id !== id);
+ let currentProject = state.currentProject;
+ if (currentProject?.id === id) {
+ currentProject = projects.length > 0 ? projects[0] : null;
+ if (currentProject) {
+ localStorage.setItem('currentProject', JSON.stringify(currentProject));
+ } else {
+ localStorage.removeItem('currentProject');
+ }
+ }
+ return { projects, currentProject };
+ });
+ } catch (error: any) {
+ throw new Error(error.message || 'Failed to delete project');
+ }
+ },
+}));