Compare commits
10 Commits
4eddf05e79
...
15846a0f7a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15846a0f7a | ||
|
|
5f56eec248 | ||
|
|
47d1da7cea | ||
|
|
db11429290 | ||
|
|
eac10a9d95 | ||
|
|
e6aa585e06 | ||
|
|
2b2e1a67c8 | ||
|
|
66d251dcc4 | ||
|
|
3eb5d47bd3 | ||
|
|
efe5d240ae |
50
backend/.dockerignore
Normal file
50
backend/.dockerignore
Normal file
@@ -0,0 +1,50 @@
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Python
|
||||
__pycache__
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
venv/
|
||||
.venv/
|
||||
env/
|
||||
.env
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Testing
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# Database
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# Uploads (should be persisted separately)
|
||||
uploads/*
|
||||
!uploads/.gitkeep
|
||||
|
||||
# Docker
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
*.md
|
||||
docs/
|
||||
24
backend/.env.example
Normal file
24
backend/.env.example
Normal file
@@ -0,0 +1,24 @@
|
||||
# Application
|
||||
APP_NAME=YG-Dataset
|
||||
DEBUG=true
|
||||
HOST=0.0.0.0
|
||||
PORT=8000
|
||||
ALLOWED_ORIGINS=*
|
||||
|
||||
# Database - Use SQLite for development, PostgreSQL for production
|
||||
DATABASE_URL=sqlite+aiosqlite:///./ygdataset.db
|
||||
DATABASE_URL_SYNC=sqlite:///./ygdataset.db
|
||||
|
||||
# Security
|
||||
SECRET_KEY=your-secret-key-change-in-production
|
||||
|
||||
# File Storage
|
||||
UPLOAD_DIR=./uploads
|
||||
MAX_FILE_SIZE=104857600
|
||||
|
||||
# LLM Settings
|
||||
DEFAULT_MODEL_PROVIDER=openai
|
||||
DEFAULT_MODEL_NAME=gpt-4o-mini
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL=INFO
|
||||
@@ -1,27 +1,60 @@
|
||||
FROM python:3.11-slim
|
||||
# Multi-stage build for Python FastAPI application
|
||||
|
||||
# Stage 1: Base image
|
||||
FROM python:3.11-slim as base
|
||||
|
||||
# Set environment variables
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PYTHONFAULTHANDLER=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Stage 2: Dependencies
|
||||
FROM base as deps
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
libpq-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy requirements
|
||||
COPY requirements.txt .
|
||||
# Create virtual environment
|
||||
RUN python -m venv /opt/venv
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
|
||||
# Install Python dependencies
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application
|
||||
COPY . .
|
||||
|
||||
# Create uploads directory
|
||||
RUN mkdir -p uploads
|
||||
# Stage 3: Production
|
||||
FROM base
|
||||
|
||||
# Install system dependencies for production
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libpq5 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy virtual environment from deps stage
|
||||
COPY --from=deps /opt/venv /opt/venv
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
|
||||
# Copy application
|
||||
COPY --chown=app:app . /app
|
||||
RUN mkdir -p /app/uploads /app/logs && chown -R app:app /app
|
||||
|
||||
# Switch to non-root user
|
||||
USER app
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8000
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1
|
||||
|
||||
# Run application
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4"]
|
||||
|
||||
20
backend/app/api/dependencies.py
Normal file
20
backend/app/api/dependencies.py
Normal file
@@ -0,0 +1,20 @@
|
||||
"""
|
||||
API Dependencies
|
||||
API 依赖项
|
||||
"""
|
||||
from typing import Annotated
|
||||
from fastapi import Depends
|
||||
from app.core.auth import verify_api_key
|
||||
|
||||
|
||||
# Type alias for API key dependency
|
||||
ApiKey = Annotated[str, Depends(verify_api_key)]
|
||||
|
||||
|
||||
# Optional API key (for endpoints that can work with or without auth)
|
||||
async def get_optional_api_key(api_key: str = None) -> Optional[str]:
|
||||
"""Get optional API key"""
|
||||
return api_key
|
||||
|
||||
|
||||
OptionalApiKey = Annotated[Optional[str], Depends(get_optional_api_key)]
|
||||
75
backend/app/api/response.py
Normal file
75
backend/app/api/response.py
Normal file
@@ -0,0 +1,75 @@
|
||||
"""
|
||||
API Response Wrapper
|
||||
统一 API 响应格式
|
||||
"""
|
||||
from datetime import datetime
|
||||
from typing import Any, Generic, List, Optional, TypeVar
|
||||
from uuid import UUID
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class ApiResponse(BaseModel, Generic[T]):
|
||||
"""统一 API 响应格式"""
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
success: bool = True
|
||||
message: str = "Success"
|
||||
data: Optional[T] = None
|
||||
error: Optional[dict] = None
|
||||
timestamp: datetime = Field(default_factory=datetime.utcnow)
|
||||
|
||||
@classmethod
|
||||
def ok(cls, data: T = None, message: str = "Success") -> "ApiResponse[T]":
|
||||
"""成功响应"""
|
||||
return cls(success=True, message=message, data=data)
|
||||
|
||||
@classmethod
|
||||
def fail(cls, message: str, error: dict = None) -> "ApiResponse[None]":
|
||||
"""失败响应"""
|
||||
return cls(success=False, message=message, error=error)
|
||||
|
||||
|
||||
class PaginatedResponse(BaseModel, Generic[T]):
|
||||
"""分页响应格式"""
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
success: bool = True
|
||||
message: str = "Success"
|
||||
data: List[T] = []
|
||||
pagination: dict = Field(default_factory=lambda: {
|
||||
"page": 1,
|
||||
"page_size": 20,
|
||||
"total": 0,
|
||||
"total_pages": 0
|
||||
})
|
||||
|
||||
@classmethod
|
||||
def ok(
|
||||
cls,
|
||||
items: List[T],
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
total: int = 0
|
||||
) -> "PaginatedResponse[T]":
|
||||
"""创建分页响应"""
|
||||
total_pages = (total + page_size - 1) // page_size if page_size > 0 else 0
|
||||
return cls(
|
||||
success=True,
|
||||
data=items,
|
||||
pagination={
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total": total,
|
||||
"total_pages": total_pages
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class ErrorDetail(BaseModel):
|
||||
"""错误详情"""
|
||||
code: str
|
||||
message: str
|
||||
details: Optional[dict] = None
|
||||
field: Optional[str] = None
|
||||
@@ -1,15 +1,21 @@
|
||||
"""
|
||||
Chunks API Router
|
||||
"""
|
||||
import asyncio
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.api.response import ApiResponse, PaginatedResponse
|
||||
from app.core.database import get_db
|
||||
from app.core.exceptions import NotFoundException
|
||||
from app.core.crud import CRUDBase
|
||||
from app.models.models import Chunk, File
|
||||
from app.schemas.base import ChunkCreate, ChunkResponse
|
||||
from app.schemas.chunk import ChunkResponse
|
||||
from app.schemas.chunk import ChunkCreateSchema
|
||||
from app.services.text_splitter.splitter import get_splitter
|
||||
from app.services.file_processor.pdf_processor import process_pdf
|
||||
from app.services.file_processor.docx_processor import process_docx
|
||||
@@ -17,26 +23,23 @@ from app.services.file_processor.excel_processor import process_csv, process_exc
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Initialize CRUD
|
||||
chunk_crud = CRUDBase(Chunk)
|
||||
|
||||
|
||||
class SplitRequest(BaseModel):
|
||||
"""Request model for splitting text"""
|
||||
file_id: Optional[UUID] = None
|
||||
file_id: UUID
|
||||
method: str = "recursive"
|
||||
chunk_size: int = 500
|
||||
overlap: int = 50
|
||||
chunk_size: int = Field(500, ge=50, le=5000)
|
||||
overlap: int = Field(50, ge=0, le=500)
|
||||
separator: Optional[str] = None
|
||||
|
||||
|
||||
class ChunkListResponse(BaseModel):
|
||||
"""Response for chunk list"""
|
||||
chunks: List[ChunkResponse]
|
||||
total: int
|
||||
|
||||
|
||||
def process_file_by_type(file: File) -> str:
|
||||
async def process_file_by_type(file: File) -> str:
|
||||
"""Process file based on its type"""
|
||||
if not file.file_path:
|
||||
raise HTTPException(status_code=400, detail="File path not found")
|
||||
raise NotFoundException("File", file.id)
|
||||
|
||||
processors = {
|
||||
"pdf": process_pdf,
|
||||
@@ -48,13 +51,17 @@ def process_file_by_type(file: File) -> str:
|
||||
processor = processors.get(file.file_type)
|
||||
if not processor:
|
||||
# Return raw text for txt, md files
|
||||
with open(file.file_path, 'r', encoding='utf-8') as f:
|
||||
return f.read()
|
||||
loop = asyncio.get_event_loop()
|
||||
content = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: open(file.file_path, 'r', encoding='utf-8').read()
|
||||
)
|
||||
return content
|
||||
|
||||
return processor(file.file_path)
|
||||
return await processor(file.file_path)
|
||||
|
||||
|
||||
@router.post("/split", response_model=dict)
|
||||
@router.post("/split", response_model=ApiResponse)
|
||||
async def split_text(
|
||||
project_id: UUID,
|
||||
request: SplitRequest,
|
||||
@@ -62,22 +69,19 @@ async def split_text(
|
||||
):
|
||||
"""Split text into chunks"""
|
||||
# Get file
|
||||
if request.file_id:
|
||||
result = await db.execute(
|
||||
select(File).where(File.id == request.file_id, File.project_id == project_id)
|
||||
)
|
||||
file = result.scalar_one_or_none()
|
||||
if not file:
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
result = await db.execute(
|
||||
select(File).where(File.id == request.file_id, File.project_id == project_id)
|
||||
)
|
||||
file = result.scalar_one_or_none()
|
||||
if not file:
|
||||
raise NotFoundException("File", request.file_id)
|
||||
|
||||
# Process file
|
||||
text = process_file_by_type(file)
|
||||
# Process file
|
||||
text = await process_file_by_type(file)
|
||||
|
||||
# Update file status
|
||||
file.status = "processing"
|
||||
await db.commit()
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="file_id is required")
|
||||
# Update file status
|
||||
file.status = "processing"
|
||||
await db.commit()
|
||||
|
||||
# Split text
|
||||
kwargs = {"chunk_size": request.chunk_size, "overlap": request.overlap}
|
||||
@@ -106,77 +110,87 @@ async def split_text(
|
||||
file.status = "completed"
|
||||
await db.commit()
|
||||
|
||||
return {"chunks": len(chunks), "message": f"Successfully split into {len(chunks)} chunks"}
|
||||
return ApiResponse.ok(
|
||||
data={"chunks": len(chunks)},
|
||||
message=f"Successfully split into {len(chunks)} chunks"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/", response_model=dict)
|
||||
@router.get("", response_model=ApiResponse)
|
||||
async def list_chunks(
|
||||
project_id: UUID,
|
||||
file_id: Optional[UUID] = Query(None),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""List chunks for a project"""
|
||||
query = select(Chunk).where(Chunk.project_id == project_id)
|
||||
|
||||
filters = {"project_id": project_id}
|
||||
if file_id:
|
||||
query = query.where(Chunk.file_id == file_id)
|
||||
filters["file_id"] = file_id
|
||||
|
||||
query = query.order_by(Chunk.created_at.desc())
|
||||
|
||||
result = await db.execute(query)
|
||||
chunks = result.scalars().all()
|
||||
|
||||
return {
|
||||
"chunks": [ChunkResponse.model_validate(c) for c in chunks],
|
||||
"total": len(chunks)
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{chunk_id}", response_model=dict)
|
||||
async def get_chunk(project_id: UUID, chunk_id: UUID, db: AsyncSession = Depends(get_db)):
|
||||
"""Get chunk by ID"""
|
||||
result = await db.execute(
|
||||
select(Chunk).where(Chunk.id == chunk_id, Chunk.project_id == project_id)
|
||||
skip = (page - 1) * page_size
|
||||
chunks, total = await chunk_crud.get_multi(
|
||||
db,
|
||||
skip=skip,
|
||||
limit=page_size,
|
||||
filters=filters,
|
||||
order_by="created_at",
|
||||
descending=True
|
||||
)
|
||||
|
||||
chunk_responses = [ChunkResponse.model_validate(c) for c in chunks]
|
||||
return PaginatedResponse.ok(
|
||||
items=chunk_responses,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
total=total
|
||||
)
|
||||
chunk = result.scalar_one_or_none()
|
||||
if not chunk:
|
||||
raise HTTPException(status_code=404, detail="Chunk not found")
|
||||
return ChunkResponse.model_validate(chunk)
|
||||
|
||||
|
||||
@router.put("/{chunk_id}", response_model=dict)
|
||||
@router.get("/{chunk_id}", response_model=ApiResponse)
|
||||
async def get_chunk(
|
||||
project_id: UUID,
|
||||
chunk_id: UUID,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get chunk by ID"""
|
||||
chunk = await chunk_crud.get(db, chunk_id)
|
||||
if not chunk or chunk.project_id != project_id:
|
||||
raise NotFoundException("Chunk", chunk_id)
|
||||
|
||||
return ApiResponse.ok(data=ChunkResponse.model_validate(chunk))
|
||||
|
||||
|
||||
@router.put("/{chunk_id}", response_model=ApiResponse)
|
||||
async def update_chunk(
|
||||
project_id: UUID,
|
||||
chunk_id: UUID,
|
||||
chunk: ChunkCreate,
|
||||
chunk: ChunkCreateSchema,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Update chunk"""
|
||||
result = await db.execute(
|
||||
select(Chunk).where(Chunk.id == chunk_id, Chunk.project_id == project_id)
|
||||
db_chunk = await chunk_crud.get(db, chunk_id)
|
||||
if not db_chunk or db_chunk.project_id != project_id:
|
||||
raise NotFoundException("Chunk", chunk_id)
|
||||
|
||||
updated_chunk = await chunk_crud.update(db, db_chunk, chunk)
|
||||
return ApiResponse.ok(
|
||||
data=ChunkResponse.model_validate(updated_chunk),
|
||||
message="Chunk updated successfully"
|
||||
)
|
||||
db_chunk = result.scalar_one_or_none()
|
||||
if not db_chunk:
|
||||
raise HTTPException(status_code=404, detail="Chunk not found")
|
||||
|
||||
for key, value in chunk.model_dump(exclude_unset=True).items():
|
||||
setattr(db_chunk, key, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(db_chunk)
|
||||
return ChunkResponse.model_validate(db_chunk)
|
||||
|
||||
|
||||
@router.delete("/{chunk_id}", response_model=dict)
|
||||
async def delete_chunk(project_id: UUID, chunk_id: UUID, db: AsyncSession = Depends(get_db)):
|
||||
@router.delete("/{chunk_id}", response_model=ApiResponse)
|
||||
async def delete_chunk(
|
||||
project_id: UUID,
|
||||
chunk_id: UUID,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Delete chunk"""
|
||||
result = await db.execute(
|
||||
select(Chunk).where(Chunk.id == chunk_id, Chunk.project_id == project_id)
|
||||
)
|
||||
chunk = result.scalar_one_or_none()
|
||||
if not chunk:
|
||||
raise HTTPException(status_code=404, detail="Chunk not found")
|
||||
chunk = await chunk_crud.get(db, chunk_id)
|
||||
if not chunk or chunk.project_id != project_id:
|
||||
raise NotFoundException("Chunk", chunk_id)
|
||||
|
||||
await db.delete(chunk)
|
||||
await db.commit()
|
||||
return {"message": "Chunk deleted successfully"}
|
||||
await chunk_crud.delete(db, chunk_id)
|
||||
return ApiResponse.ok(message="Chunk deleted successfully")
|
||||
|
||||
@@ -3,94 +3,107 @@ Datasets API Router
|
||||
"""
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
from pydantic import BaseModel
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel, Field
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
|
||||
from app.api.response import ApiResponse, PaginatedResponse
|
||||
from app.core.database import get_db
|
||||
from app.models.models import Dataset, Question
|
||||
from app.schemas.base import DatasetCreate, DatasetResponse
|
||||
from app.core.exceptions import NotFoundException
|
||||
from app.core.crud import CRUDBase
|
||||
from app.models.models import Dataset
|
||||
from app.schemas.dataset import DatasetResponse
|
||||
from app.schemas.dataset import DatasetCreateSchema
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Initialize CRUD
|
||||
dataset_crud = CRUDBase(Dataset)
|
||||
|
||||
|
||||
class ExportRequest(BaseModel):
|
||||
"""Export request schema"""
|
||||
format: str = "alpaca" # alpaca, sharegpt, llama_factory, json
|
||||
format: str = Field("alpaca", pattern="^(alpaca|sharegpt|llama_factory|json)$")
|
||||
|
||||
|
||||
@router.get("/", response_model=dict)
|
||||
async def list_datasets(project_id: UUID, db: AsyncSession = Depends(get_db)):
|
||||
@router.get("", response_model=ApiResponse)
|
||||
async def list_datasets(
|
||||
project_id: UUID,
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""List datasets for a project"""
|
||||
result = await db.execute(
|
||||
select(Dataset).where(Dataset.project_id == project_id).order_by(Dataset.created_at.desc())
|
||||
skip = (page - 1) * page_size
|
||||
datasets, total = await dataset_crud.get_multi(
|
||||
db,
|
||||
skip=skip,
|
||||
limit=page_size,
|
||||
filters={"project_id": project_id},
|
||||
order_by="created_at",
|
||||
descending=True
|
||||
)
|
||||
datasets = result.scalars().all()
|
||||
|
||||
# Get question count for each dataset
|
||||
dataset_list = []
|
||||
for dataset in datasets:
|
||||
dataset_data = DatasetResponse.model_validate(dataset)
|
||||
# TODO: Count questions in dataset
|
||||
dataset_data.question_count = 0
|
||||
dataset_list.append(dataset_data)
|
||||
|
||||
return {"datasets": dataset_list}
|
||||
dataset_responses = [DatasetResponse.model_validate(d) for d in datasets]
|
||||
return PaginatedResponse.ok(
|
||||
items=dataset_responses,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
total=total
|
||||
)
|
||||
|
||||
|
||||
@router.post("/", response_model=dict)
|
||||
@router.post("", response_model=ApiResponse)
|
||||
async def create_dataset(
|
||||
project_id: UUID,
|
||||
dataset: DatasetCreate,
|
||||
dataset: DatasetCreateSchema,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Create a new dataset"""
|
||||
db_dataset = Dataset(project_id=project_id, **dataset.model_dump())
|
||||
# Add project_id to the dataset
|
||||
dataset_dict = dataset.model_dump()
|
||||
dataset_dict["project_id"] = project_id
|
||||
db_dataset = Dataset(**dataset_dict)
|
||||
db.add(db_dataset)
|
||||
await db.commit()
|
||||
await db.refresh(db_dataset)
|
||||
|
||||
return {"id": str(db_dataset.id)}
|
||||
return ApiResponse.ok(
|
||||
data={"id": str(db_dataset.id)},
|
||||
message="Dataset created successfully"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{dataset_id}", response_model=dict)
|
||||
@router.get("/{dataset_id}", response_model=ApiResponse)
|
||||
async def get_dataset(
|
||||
project_id: UUID,
|
||||
dataset_id: UUID,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get dataset by ID"""
|
||||
result = await db.execute(
|
||||
select(Dataset).where(Dataset.id == dataset_id, Dataset.project_id == project_id)
|
||||
)
|
||||
dataset = result.scalar_one_or_none()
|
||||
if not dataset:
|
||||
raise HTTPException(status_code=404, detail="Dataset not found")
|
||||
dataset = await dataset_crud.get(db, dataset_id)
|
||||
if not dataset or dataset.project_id != project_id:
|
||||
raise NotFoundException("Dataset", dataset_id)
|
||||
|
||||
return DatasetResponse.model_validate(dataset)
|
||||
return ApiResponse.ok(data=DatasetResponse.model_validate(dataset))
|
||||
|
||||
|
||||
@router.delete("/{dataset_id}", response_model=dict)
|
||||
@router.delete("/{dataset_id}", response_model=ApiResponse)
|
||||
async def delete_dataset(
|
||||
project_id: UUID,
|
||||
dataset_id: UUID,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Delete dataset"""
|
||||
result = await db.execute(
|
||||
select(Dataset).where(Dataset.id == dataset_id, Dataset.project_id == project_id)
|
||||
)
|
||||
dataset = result.scalar_one_or_none()
|
||||
if not dataset:
|
||||
raise HTTPException(status_code=404, detail="Dataset not found")
|
||||
dataset = await dataset_crud.get(db, dataset_id)
|
||||
if not dataset or dataset.project_id != project_id:
|
||||
raise NotFoundException("Dataset", dataset_id)
|
||||
|
||||
await db.delete(dataset)
|
||||
await db.commit()
|
||||
|
||||
return {"message": "Dataset deleted successfully"}
|
||||
await dataset_crud.delete(db, dataset_id)
|
||||
return ApiResponse.ok(message="Dataset deleted successfully")
|
||||
|
||||
|
||||
@router.post("/{dataset_id}/export")
|
||||
@router.post("/{dataset_id}/export", response_model=ApiResponse)
|
||||
async def export_dataset(
|
||||
project_id: UUID,
|
||||
dataset_id: UUID,
|
||||
@@ -98,18 +111,9 @@ async def export_dataset(
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Export dataset in specified format"""
|
||||
# TODO: Implement actual export logic
|
||||
|
||||
# Get dataset
|
||||
result = await db.execute(
|
||||
select(Dataset).where(Dataset.id == dataset_id, Dataset.project_id == project_id)
|
||||
)
|
||||
dataset = result.scalar_one_or_none()
|
||||
if not dataset:
|
||||
raise HTTPException(status_code=404, detail="Dataset not found")
|
||||
|
||||
# Get questions for this dataset (placeholder)
|
||||
# In real implementation, would link questions to datasets
|
||||
dataset = await dataset_crud.get(db, dataset_id)
|
||||
if not dataset or dataset.project_id != project_id:
|
||||
raise NotFoundException("Dataset", dataset_id)
|
||||
|
||||
# Return sample data based on format
|
||||
sample_data = [
|
||||
@@ -121,6 +125,9 @@ async def export_dataset(
|
||||
]
|
||||
|
||||
if request.format == "json":
|
||||
return sample_data
|
||||
return ApiResponse.ok(data=sample_data)
|
||||
|
||||
return {"data": sample_data, "format": request.format}
|
||||
return ApiResponse.ok(
|
||||
data={"data": sample_data, "format": request.format},
|
||||
message="Dataset exported successfully"
|
||||
)
|
||||
|
||||
@@ -1,24 +1,32 @@
|
||||
"""
|
||||
Evaluation API Router
|
||||
"""
|
||||
from typing import List, Optional
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
from pydantic import BaseModel
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.api.response import ApiResponse, PaginatedResponse
|
||||
from app.core.database import get_db
|
||||
from app.core.exceptions import NotFoundException
|
||||
from app.core.crud import CRUDBase
|
||||
from app.models.models import EvalDataset, Task
|
||||
from app.schemas.base import EvalDatasetCreate, EvalDatasetResponse, TaskResponse
|
||||
from app.schemas.eval import EvalDatasetResponse, TaskResponse
|
||||
from app.schemas.eval import EvalDatasetCreateSchema
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Initialize CRUD
|
||||
eval_crud = CRUDBase(EvalDataset)
|
||||
task_crud = CRUDBase(Task)
|
||||
|
||||
|
||||
class GenerateEvalRequest(BaseModel):
|
||||
"""Request for generating evaluation dataset"""
|
||||
name: str
|
||||
question_type: str = "mixed"
|
||||
count: int = 50
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
question_type: str = Field("mixed", pattern="^(mixed|fact|reasoning|summary)$")
|
||||
count: int = Field(50, ge=1, le=500)
|
||||
|
||||
|
||||
class RunEvalRequest(BaseModel):
|
||||
@@ -26,18 +34,34 @@ class RunEvalRequest(BaseModel):
|
||||
model_config_id: Optional[UUID] = None
|
||||
|
||||
|
||||
@router.get("/", response_model=dict)
|
||||
async def list_eval_datasets(project_id: UUID, db: AsyncSession = Depends(get_db)):
|
||||
@router.get("", response_model=ApiResponse)
|
||||
async def list_eval_datasets(
|
||||
project_id: UUID,
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""List evaluation datasets"""
|
||||
result = await db.execute(
|
||||
select(EvalDataset).where(EvalDataset.project_id == project_id).order_by(EvalDataset.created_at.desc())
|
||||
skip = (page - 1) * page_size
|
||||
datasets, total = await eval_crud.get_multi(
|
||||
db,
|
||||
skip=skip,
|
||||
limit=page_size,
|
||||
filters={"project_id": project_id},
|
||||
order_by="created_at",
|
||||
descending=True
|
||||
)
|
||||
datasets = result.scalars().all()
|
||||
|
||||
return {"datasets": [EvalDatasetResponse.model_validate(d) for d in datasets]}
|
||||
dataset_responses = [EvalDatasetResponse.model_validate(d) for d in datasets]
|
||||
return PaginatedResponse.ok(
|
||||
items=dataset_responses,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
total=total
|
||||
)
|
||||
|
||||
|
||||
@router.post("/", response_model=dict)
|
||||
@router.post("", response_model=ApiResponse)
|
||||
async def create_eval_dataset(
|
||||
project_id: UUID,
|
||||
request: GenerateEvalRequest,
|
||||
@@ -53,10 +77,27 @@ async def create_eval_dataset(
|
||||
await db.commit()
|
||||
await db.refresh(db_dataset)
|
||||
|
||||
return {"id": str(db_dataset.id)}
|
||||
return ApiResponse.ok(
|
||||
data={"id": str(db_dataset.id)},
|
||||
message="Evaluation dataset created successfully"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{eval_id}/evaluate", response_model=dict)
|
||||
@router.get("/{eval_id}", response_model=ApiResponse)
|
||||
async def get_eval_dataset(
|
||||
project_id: UUID,
|
||||
eval_id: UUID,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get evaluation dataset by ID"""
|
||||
dataset = await eval_crud.get(db, eval_id)
|
||||
if not dataset or dataset.project_id != project_id:
|
||||
raise NotFoundException("Evaluation Dataset", eval_id)
|
||||
|
||||
return ApiResponse.ok(data=EvalDatasetResponse.model_validate(dataset))
|
||||
|
||||
|
||||
@router.post("/{eval_id}/evaluate", response_model=ApiResponse)
|
||||
async def run_evaluation(
|
||||
project_id: UUID,
|
||||
eval_id: UUID,
|
||||
@@ -65,12 +106,9 @@ async def run_evaluation(
|
||||
):
|
||||
"""Run evaluation on dataset"""
|
||||
# Check dataset exists
|
||||
result = await db.execute(
|
||||
select(EvalDataset).where(EvalDataset.id == eval_id, EvalDataset.project_id == project_id)
|
||||
)
|
||||
dataset = result.scalar_one_or_none()
|
||||
if not dataset:
|
||||
raise HTTPException(status_code=404, detail="Evaluation dataset not found")
|
||||
dataset = await eval_crud.get(db, eval_id)
|
||||
if not dataset or dataset.project_id != project_id:
|
||||
raise NotFoundException("Evaluation Dataset", eval_id)
|
||||
|
||||
# Create evaluation task
|
||||
task = Task(
|
||||
@@ -82,19 +120,21 @@ async def run_evaluation(
|
||||
await db.commit()
|
||||
await db.refresh(task)
|
||||
|
||||
# TODO: Start evaluation in background
|
||||
|
||||
return {"task_id": str(task.id), "message": "Evaluation task started"}
|
||||
|
||||
|
||||
@router.get("/results", response_model=dict)
|
||||
async def get_eval_results(project_id: UUID, task_id: UUID, db: AsyncSession = Depends(get_db)):
|
||||
"""Get evaluation results"""
|
||||
result = await db.execute(
|
||||
select(Task).where(Task.id == task_id, Task.project_id == project_id)
|
||||
return ApiResponse.ok(
|
||||
data={"task_id": str(task.id)},
|
||||
message="Evaluation task started"
|
||||
)
|
||||
task = result.scalar_one_or_none()
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
|
||||
return TaskResponse.model_validate(task)
|
||||
|
||||
@router.get("/results", response_model=ApiResponse)
|
||||
async def get_eval_results(
|
||||
project_id: UUID,
|
||||
task_id: UUID,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get evaluation results"""
|
||||
task = await task_crud.get(db, task_id)
|
||||
if not task or task.project_id != project_id:
|
||||
raise NotFoundException("Task", task_id)
|
||||
|
||||
return ApiResponse.ok(data=TaskResponse.model_validate(task))
|
||||
|
||||
@@ -2,17 +2,21 @@
|
||||
Files API Router
|
||||
"""
|
||||
import os
|
||||
import aiofiles
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
|
||||
from fastapi import APIRouter, Depends, UploadFile, File, Query
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from app.core.database import get_db
|
||||
|
||||
from app.api.response import ApiResponse, PaginatedResponse
|
||||
from app.core.config import get_settings
|
||||
from app.models.models import File
|
||||
from app.schemas.base import FileResponse
|
||||
from app.core.database import get_db
|
||||
from app.core.exceptions import ValidationException, NotFoundException
|
||||
from app.core.crud import CRUDBase
|
||||
from app.models.models import File as FileModel
|
||||
from app.schemas.file import FileResponse, FileCreateSchema
|
||||
|
||||
settings = get_settings()
|
||||
router = APIRouter()
|
||||
@@ -21,6 +25,9 @@ router = APIRouter()
|
||||
UPLOAD_DIR = Path(settings.UPLOAD_DIR)
|
||||
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Initialize CRUD
|
||||
file_crud = CRUDBase(FileModel)
|
||||
|
||||
|
||||
def get_file_type(filename: str) -> str:
|
||||
"""Get file type from extension"""
|
||||
@@ -40,71 +47,157 @@ def get_file_type(filename: str) -> str:
|
||||
return type_map.get(ext, 'txt')
|
||||
|
||||
|
||||
@router.post("/upload", response_model=dict)
|
||||
# Allowed file extensions
|
||||
ALLOWED_EXTENSIONS = {'pdf', 'docx', 'doc', 'xlsx', 'xls', 'csv', 'epub', 'md', 'txt'}
|
||||
|
||||
|
||||
def validate_file(filename: str, file_size: int) -> None:
|
||||
"""Validate file extension and size"""
|
||||
ext = filename.rsplit('.', 1)[-1].lower() if '.' in filename else ''
|
||||
|
||||
if ext not in ALLOWED_EXTENSIONS:
|
||||
raise ValidationException(
|
||||
f"File type '{ext}' not allowed",
|
||||
field="file"
|
||||
)
|
||||
|
||||
if file_size > settings.MAX_FILE_SIZE:
|
||||
raise ValidationException(
|
||||
f"File size exceeds maximum allowed size of {settings.MAX_FILE_SIZE // (1024*1024)}MB",
|
||||
field="file"
|
||||
)
|
||||
|
||||
|
||||
async def save_file_async(file: UploadFile, destination: Path) -> None:
|
||||
"""Save uploaded file asynchronously"""
|
||||
content = await file.read()
|
||||
loop = asyncio.get_event_loop()
|
||||
await loop.run_in_executor(None, lambda: destination.write_bytes(content))
|
||||
|
||||
|
||||
@router.post("/upload", response_model=ApiResponse)
|
||||
async def upload_file(
|
||||
project_id: UUID,
|
||||
file: UploadFile = File(...),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Upload a file"""
|
||||
# Read file content for validation
|
||||
content = await file.read()
|
||||
file_size = len(content)
|
||||
|
||||
# Validate file
|
||||
validate_file(file.filename, file_size)
|
||||
|
||||
# Save file to disk
|
||||
file_path = UPLOAD_DIR / f"{project_id}_{file.filename}"
|
||||
async with aiofiles.open(file_path, 'wb') as f:
|
||||
content = await file.read()
|
||||
await f.write(content)
|
||||
safe_filename = f"{project_id}_{UUID.uuid4().hex[:8]}_{file.filename}"
|
||||
file_path = UPLOAD_DIR / safe_filename
|
||||
|
||||
# Write file asynchronously
|
||||
await asyncio.get_event_loop().run_in_executor(
|
||||
None,
|
||||
lambda: file_path.write_bytes(content)
|
||||
)
|
||||
|
||||
# Create file record
|
||||
db_file = File(
|
||||
db_file = FileModel(
|
||||
project_id=project_id,
|
||||
filename=file.filename,
|
||||
file_type=get_file_type(file.filename),
|
||||
file_path=str(file_path),
|
||||
size=len(content),
|
||||
size=file_size,
|
||||
status="pending"
|
||||
)
|
||||
db.add(db_file)
|
||||
await db.commit()
|
||||
await db.refresh(db_file)
|
||||
|
||||
return {"id": str(db_file.id), "filename": db_file.filename, "status": db_file.status}
|
||||
return ApiResponse.ok(
|
||||
data={"id": str(db_file.id), "filename": db_file.filename, "status": db_file.status},
|
||||
message="File uploaded successfully"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/", response_model=dict)
|
||||
async def list_files(project_id: UUID, db: AsyncSession = Depends(get_db)):
|
||||
@router.get("", response_model=ApiResponse)
|
||||
async def list_files(
|
||||
project_id: UUID,
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""List files for a project"""
|
||||
result = await db.execute(
|
||||
select(File).where(File.project_id == project_id).order_by(File.created_at.desc())
|
||||
skip = (page - 1) * page_size
|
||||
files, total = await file_crud.get_multi(
|
||||
db,
|
||||
skip=skip,
|
||||
limit=page_size,
|
||||
filters={"project_id": project_id},
|
||||
order_by="created_at",
|
||||
descending=True
|
||||
)
|
||||
|
||||
file_responses = [FileResponse.model_validate(f) for f in files]
|
||||
return PaginatedResponse.ok(
|
||||
items=file_responses,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
total=total
|
||||
)
|
||||
files = result.scalars().all()
|
||||
return {"files": [FileResponse.model_validate(f) for f in files]}
|
||||
|
||||
|
||||
@router.get("/{file_id}", response_model=dict)
|
||||
async def get_file(project_id: UUID, file_id: UUID, db: AsyncSession = Depends(get_db)):
|
||||
@router.get("/{file_id}", response_model=ApiResponse)
|
||||
async def get_file(
|
||||
project_id: UUID,
|
||||
file_id: UUID,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get file by ID"""
|
||||
result = await db.execute(
|
||||
select(File).where(File.id == file_id, File.project_id == project_id)
|
||||
)
|
||||
file = result.scalar_one_or_none()
|
||||
if not file:
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
return FileResponse.model_validate(file)
|
||||
file = await file_crud.get(db, file_id)
|
||||
if not file or file.project_id != project_id:
|
||||
raise NotFoundException("File", file_id)
|
||||
|
||||
return ApiResponse.ok(data=FileResponse.model_validate(file))
|
||||
|
||||
|
||||
@router.delete("/{file_id}", response_model=dict)
|
||||
async def delete_file(project_id: UUID, file_id: UUID, db: AsyncSession = Depends(get_db)):
|
||||
@router.delete("/{file_id}", response_model=ApiResponse)
|
||||
async def delete_file(
|
||||
project_id: UUID,
|
||||
file_id: UUID,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Delete file"""
|
||||
result = await db.execute(
|
||||
select(File).where(File.id == file_id, File.project_id == project_id)
|
||||
)
|
||||
file = result.scalar_one_or_none()
|
||||
if not file:
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
file = await file_crud.get(db, file_id)
|
||||
if not file or file.project_id != project_id:
|
||||
raise NotFoundException("File", file_id)
|
||||
|
||||
# Delete file from disk
|
||||
if file.file_path and os.path.exists(file.file_path):
|
||||
os.remove(file.file_path)
|
||||
await asyncio.get_event_loop().run_in_executor(
|
||||
None,
|
||||
os.remove,
|
||||
file.file_path
|
||||
)
|
||||
|
||||
await db.delete(file)
|
||||
await db.commit()
|
||||
return {"message": "File deleted successfully"}
|
||||
await file_crud.delete(db, file_id)
|
||||
return ApiResponse.ok(message="File deleted successfully")
|
||||
|
||||
|
||||
@router.get("/{file_id}/download", response_class=FileResponse)
|
||||
async def download_file(
|
||||
project_id: UUID,
|
||||
file_id: UUID,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Download file"""
|
||||
file = await file_crud.get(db, file_id)
|
||||
if not file or file.project_id != project_id:
|
||||
raise NotFoundException("File", file_id)
|
||||
|
||||
if not file.file_path or not os.path.exists(file.file_path):
|
||||
raise ValidationException("File not found on disk", field="file")
|
||||
|
||||
return FileResponse(
|
||||
path=file.file_path,
|
||||
filename=file.filename,
|
||||
media_type=f"application/{file.file_type}"
|
||||
)
|
||||
|
||||
@@ -1,74 +1,111 @@
|
||||
"""
|
||||
Projects API Router
|
||||
"""
|
||||
from typing import List
|
||||
import logging
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.api.response import ApiResponse, PaginatedResponse
|
||||
from app.core.database import get_db
|
||||
from app.core.exceptions import NotFoundException
|
||||
from app.core.crud import CRUDBase
|
||||
from app.models.models import Project
|
||||
from app.schemas.base import (
|
||||
from app.schemas.project import (
|
||||
ProjectCreate,
|
||||
ProjectUpdate,
|
||||
ProjectResponse
|
||||
ProjectResponse,
|
||||
ProjectCreateSchema,
|
||||
ProjectUpdateSchema
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger("yg_dataset.projects")
|
||||
|
||||
# Initialize CRUD
|
||||
project_crud = CRUDBase(Project)
|
||||
|
||||
|
||||
@router.get("/", response_model=dict)
|
||||
async def list_projects(db: AsyncSession = Depends(get_db)):
|
||||
"""List all projects"""
|
||||
result = await db.execute(select(Project).order_by(Project.created_at.desc()))
|
||||
projects = result.scalars().all()
|
||||
return {"projects": [ProjectResponse.model_validate(p) for p in projects]}
|
||||
@router.get("", response_model=ApiResponse)
|
||||
async def list_projects(
|
||||
page: int = Query(1, ge=1, description="Page number"),
|
||||
page_size: int = Query(20, ge=1, le=100, description="Page size"),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""List all projects with pagination"""
|
||||
logger.info(f"Listing projects - page: {page}, page_size: {page_size}")
|
||||
skip = (page - 1) * page_size
|
||||
projects, total = await project_crud.get_multi(
|
||||
db,
|
||||
skip=skip,
|
||||
limit=page_size,
|
||||
order_by="created_at",
|
||||
descending=True
|
||||
)
|
||||
|
||||
logger.info(f"Found {total} projects, returning {len(projects)} items")
|
||||
project_responses = [ProjectResponse.model_validate(p) for p in projects]
|
||||
return PaginatedResponse.ok(
|
||||
items=project_responses,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
total=total
|
||||
)
|
||||
|
||||
|
||||
@router.post("/", response_model=dict)
|
||||
async def create_project(project: ProjectCreate, db: AsyncSession = Depends(get_db)):
|
||||
@router.post("", response_model=ApiResponse)
|
||||
async def create_project(
|
||||
project: ProjectCreateSchema,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Create a new project"""
|
||||
db_project = Project(**project.model_dump())
|
||||
db.add(db_project)
|
||||
await db.commit()
|
||||
await db.refresh(db_project)
|
||||
return {"id": str(db_project.id)}
|
||||
logger.info(f"Creating project: name={project.name}, description={project.description}")
|
||||
db_project = await project_crud.create(db, project)
|
||||
logger.info(f"Project created successfully: id={db_project.id}")
|
||||
return ApiResponse.ok(
|
||||
data={"id": str(db_project.id)},
|
||||
message="Project created successfully"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{project_id}", response_model=dict)
|
||||
async def get_project(project_id: UUID, db: AsyncSession = Depends(get_db)):
|
||||
@router.get("/{project_id}", response_model=ApiResponse)
|
||||
async def get_project(
|
||||
project_id: UUID,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get project by ID"""
|
||||
result = await db.execute(select(Project).where(Project.id == project_id))
|
||||
project = result.scalar_one_or_none()
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
return ProjectResponse.model_validate(project)
|
||||
logger.info(f"Getting project: id={project_id}")
|
||||
project = await project_crud.get_or_raise(db, project_id, "Project")
|
||||
logger.info(f"Found project: name={project.name}")
|
||||
return ApiResponse.ok(data=ProjectResponse.model_validate(project))
|
||||
|
||||
|
||||
@router.put("/{project_id}", response_model=dict)
|
||||
async def update_project(project_id: UUID, project: ProjectUpdate, db: AsyncSession = Depends(get_db)):
|
||||
@router.put("/{project_id}", response_model=ApiResponse)
|
||||
async def update_project(
|
||||
project_id: UUID,
|
||||
project: ProjectUpdateSchema,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Update project"""
|
||||
result = await db.execute(select(Project).where(Project.id == project_id))
|
||||
db_project = result.scalar_one_or_none()
|
||||
if not db_project:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
|
||||
for key, value in project.model_dump(exclude_unset=True).items():
|
||||
setattr(db_project, key, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(db_project)
|
||||
return ProjectResponse.model_validate(db_project)
|
||||
logger.info(f"Updating project: id={project_id}")
|
||||
db_project = await project_crud.get_or_raise(db, project_id, "Project")
|
||||
updated_project = await project_crud.update(db, db_project, project)
|
||||
logger.info(f"Project updated: name={updated_project.name}")
|
||||
return ApiResponse.ok(
|
||||
data=ProjectResponse.model_validate(updated_project),
|
||||
message="Project updated successfully"
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{project_id}", response_model=dict)
|
||||
async def delete_project(project_id: UUID, db: AsyncSession = Depends(get_db)):
|
||||
@router.delete("/{project_id}", response_model=ApiResponse)
|
||||
async def delete_project(
|
||||
project_id: UUID,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Delete project"""
|
||||
result = await db.execute(select(Project).where(Project.id == project_id))
|
||||
project = result.scalar_one_or_none()
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
|
||||
await db.delete(project)
|
||||
await db.commit()
|
||||
return {"message": "Project deleted successfully"}
|
||||
logger.info(f"Deleting project: id={project_id}")
|
||||
await project_crud.get_or_raise(db, project_id, "Project")
|
||||
await project_crud.delete(db, project_id)
|
||||
logger.info(f"Project deleted: id={project_id}")
|
||||
return ApiResponse.ok(message="Project deleted successfully")
|
||||
|
||||
@@ -3,37 +3,38 @@ Questions API Router
|
||||
"""
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
from pydantic import BaseModel
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel, Field
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.api.response import ApiResponse, PaginatedResponse
|
||||
from app.core.database import get_db
|
||||
from app.core.exceptions import NotFoundException, ValidationException
|
||||
from app.core.crud import CRUDBase
|
||||
from app.models.models import Question, Chunk
|
||||
from app.schemas.base import QuestionCreate, QuestionResponse
|
||||
from app.schemas.question import QuestionResponse
|
||||
from app.schemas.question import QuestionCreateSchema
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Initialize CRUD
|
||||
question_crud = CRUDBase(Question)
|
||||
|
||||
|
||||
class GenerateRequest(BaseModel):
|
||||
"""Request model for generating questions"""
|
||||
chunk_ids: List[UUID] = []
|
||||
count: int = 5
|
||||
chunk_ids: List[UUID] = Field(..., min_length=1)
|
||||
count: int = Field(5, ge=1, le=50)
|
||||
question_types: List[str] = ["fact", "summary"]
|
||||
|
||||
|
||||
@router.post("/generate", response_model=dict)
|
||||
@router.post("/generate", response_model=ApiResponse)
|
||||
async def generate_questions(
|
||||
project_id: UUID,
|
||||
request: GenerateRequest,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Generate questions from chunks using LLM"""
|
||||
# TODO: Implement LLM-based question generation
|
||||
# This is a placeholder that creates sample questions
|
||||
|
||||
if not request.chunk_ids:
|
||||
raise HTTPException(status_code=400, detail="chunk_ids is required")
|
||||
|
||||
# Get chunks
|
||||
result = await db.execute(
|
||||
select(Chunk).where(Chunk.id.in_(request.chunk_ids), Chunk.project_id == project_id)
|
||||
@@ -41,9 +42,9 @@ async def generate_questions(
|
||||
chunks = result.scalars().all()
|
||||
|
||||
if not chunks:
|
||||
raise HTTPException(status_code=404, detail="No chunks found")
|
||||
raise ValidationException("No valid chunks found", field="chunk_ids")
|
||||
|
||||
# Create sample questions (placeholder)
|
||||
# Create sample questions (placeholder for LLM-based generation)
|
||||
created_questions = []
|
||||
for chunk in chunks:
|
||||
for i in range(request.count):
|
||||
@@ -60,63 +61,73 @@ async def generate_questions(
|
||||
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"questions": len(created_questions),
|
||||
"message": f"Successfully generated {len(created_questions)} questions"
|
||||
}
|
||||
return ApiResponse.ok(
|
||||
data={"questions": len(created_questions)},
|
||||
message=f"Successfully generated {len(created_questions)} questions"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/", response_model=dict)
|
||||
@router.get("", response_model=ApiResponse)
|
||||
async def list_questions(
|
||||
project_id: UUID,
|
||||
chunk_id: Optional[UUID] = Query(None),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""List questions for a project"""
|
||||
query = select(Question).where(Question.project_id == project_id)
|
||||
|
||||
filters = {"project_id": project_id}
|
||||
if chunk_id:
|
||||
query = query.where(Question.chunk_id == chunk_id)
|
||||
filters["chunk_id"] = chunk_id
|
||||
|
||||
result = await db.execute(query)
|
||||
questions = result.scalars().all()
|
||||
skip = (page - 1) * page_size
|
||||
questions, total = await question_crud.get_multi(
|
||||
db,
|
||||
skip=skip,
|
||||
limit=page_size,
|
||||
filters=filters,
|
||||
order_by="created_at",
|
||||
descending=True
|
||||
)
|
||||
|
||||
return {"questions": [QuestionResponse.model_validate(q) for q in questions]}
|
||||
question_responses = [QuestionResponse.model_validate(q) for q in questions]
|
||||
return PaginatedResponse.ok(
|
||||
items=question_responses,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
total=total
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{question_id}", response_model=dict)
|
||||
@router.put("/{question_id}", response_model=ApiResponse)
|
||||
async def update_question(
|
||||
project_id: UUID,
|
||||
question_id: UUID,
|
||||
question: QuestionCreate,
|
||||
question: QuestionCreateSchema,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Update question"""
|
||||
result = await db.execute(
|
||||
select(Question).where(Question.id == question_id, Question.project_id == project_id)
|
||||
db_question = await question_crud.get(db, question_id)
|
||||
if not db_question or db_question.project_id != project_id:
|
||||
raise NotFoundException("Question", question_id)
|
||||
|
||||
updated_question = await question_crud.update(db, db_question, question)
|
||||
return ApiResponse.ok(
|
||||
data=QuestionResponse.model_validate(updated_question),
|
||||
message="Question updated successfully"
|
||||
)
|
||||
db_question = result.scalar_one_or_none()
|
||||
if not db_question:
|
||||
raise HTTPException(status_code=404, detail="Question not found")
|
||||
|
||||
for key, value in question.model_dump(exclude_unset=True).items():
|
||||
setattr(db_question, key, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(db_question)
|
||||
return QuestionResponse.model_validate(db_question)
|
||||
|
||||
|
||||
@router.delete("/{question_id}", response_model=dict)
|
||||
async def delete_question(project_id: UUID, question_id: UUID, db: AsyncSession = Depends(get_db)):
|
||||
@router.delete("/{question_id}", response_model=ApiResponse)
|
||||
async def delete_question(
|
||||
project_id: UUID,
|
||||
question_id: UUID,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Delete question"""
|
||||
result = await db.execute(
|
||||
select(Question).where(Question.id == question_id, Question.project_id == project_id)
|
||||
)
|
||||
question = result.scalar_one_or_none()
|
||||
if not question:
|
||||
raise HTTPException(status_code=404, detail="Question not found")
|
||||
question = await question_crud.get(db, question_id)
|
||||
if not question or question.project_id != project_id:
|
||||
raise NotFoundException("Question", question_id)
|
||||
|
||||
await db.delete(question)
|
||||
await db.commit()
|
||||
return {"message": "Question deleted successfully"}
|
||||
await question_crud.delete(db, question_id)
|
||||
return ApiResponse.ok(message="Question deleted successfully")
|
||||
|
||||
38
backend/app/core/auth.py
Normal file
38
backend/app/core/auth.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""
|
||||
API Key Authentication
|
||||
API Key 认证中间件
|
||||
"""
|
||||
from typing import Optional
|
||||
from fastapi import Header, HTTPException, Request
|
||||
from fastapi.security import APIKeyHeader
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
# API Key header
|
||||
API_KEY_HEADER = APIKeyHeader(name="X-API-Key", auto_error=False)
|
||||
|
||||
|
||||
async def verify_api_key(api_key: Optional[str] = Header(None)) -> str:
|
||||
"""Verify API key from header"""
|
||||
if not api_key:
|
||||
raise HTTPException(status_code=401, detail="API key is required")
|
||||
|
||||
# In production, you would validate against a database or cache
|
||||
# For development, we can use a simple validation
|
||||
if settings.DEBUG and api_key == "dev-api-key":
|
||||
return api_key
|
||||
|
||||
# TODO: Implement proper API key validation
|
||||
# This is a placeholder - in production, validate against stored keys
|
||||
if len(api_key) < 32:
|
||||
raise HTTPException(status_code=401, detail="Invalid API key")
|
||||
|
||||
return api_key
|
||||
|
||||
|
||||
def create_api_key() -> str:
|
||||
"""Generate a new API key"""
|
||||
import secrets
|
||||
return secrets.token_hex(32)
|
||||
@@ -4,7 +4,7 @@ Application Configuration
|
||||
|
||||
from functools import lru_cache
|
||||
from pydantic_settings import BaseSettings
|
||||
from pydantic import Field
|
||||
from pydantic import Field, field_validator
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
@@ -15,12 +15,16 @@ class Settings(BaseSettings):
|
||||
DEBUG: bool = True
|
||||
HOST: str = "0.0.0.0"
|
||||
PORT: int = 8000
|
||||
ALLOWED_ORIGINS: str = Field(
|
||||
default="*",
|
||||
description="Comma-separated list of allowed CORS origins"
|
||||
)
|
||||
|
||||
# Database - 使用 SQLite 进行开发/测试
|
||||
# 生产环境可切换为 PostgreSQL
|
||||
DATABASE_URL: str = Field(
|
||||
default="sqlite:///./ygdataset.db",
|
||||
description="Database connection URL (sqlite:// or postgresql+asyncpg://)"
|
||||
default="sqlite+aiosqlite:///./ygdataset.db",
|
||||
description="Database connection URL (sqlite+aiosqlite:// or postgresql+asyncpg://)"
|
||||
)
|
||||
DATABASE_URL_SYNC: str = Field(
|
||||
default="sqlite:///./ygdataset.db",
|
||||
@@ -38,8 +42,31 @@ class Settings(BaseSettings):
|
||||
DEFAULT_MODEL_PROVIDER: str = "openai"
|
||||
DEFAULT_MODEL_NAME: str = "gpt-4o-mini"
|
||||
|
||||
# Security
|
||||
SECRET_KEY: str = Field(
|
||||
default="your-secret-key-change-in-production",
|
||||
description="Secret key for JWT and other security operations"
|
||||
)
|
||||
API_KEY_HEADER: str = "X-API-Key"
|
||||
|
||||
# Pagination
|
||||
DEFAULT_PAGE_SIZE: int = 20
|
||||
MAX_PAGE_SIZE: int = 100
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL: str = "INFO"
|
||||
|
||||
@field_validator("MAX_FILE_SIZE")
|
||||
@classmethod
|
||||
def validate_max_file_size(cls, v: int) -> int:
|
||||
"""Validate max file size (max 500MB)"""
|
||||
if v > 500 * 1024 * 1024:
|
||||
return 500 * 1024 * 1024
|
||||
return v
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
env_file_encoding = "utf-8"
|
||||
extra = "allow"
|
||||
|
||||
|
||||
@@ -47,3 +74,7 @@ class Settings(BaseSettings):
|
||||
def get_settings() -> Settings:
|
||||
"""Get cached settings"""
|
||||
return Settings()
|
||||
|
||||
|
||||
# Create global settings instance
|
||||
settings = get_settings()
|
||||
|
||||
178
backend/app/core/crud.py
Normal file
178
backend/app/core/crud.py
Normal file
@@ -0,0 +1,178 @@
|
||||
"""
|
||||
Database CRUD Operations
|
||||
数据库通用 CRUD 操作
|
||||
"""
|
||||
from typing import Any, Generic, List, Optional, Type, TypeVar
|
||||
from uuid import UUID
|
||||
from sqlalchemy import select, func, and_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload, joinedload
|
||||
|
||||
from app.core.exceptions import NotFoundException, DuplicateException
|
||||
from app.core.logging import LoggerMixin
|
||||
|
||||
ModelType = TypeVar("ModelType")
|
||||
|
||||
|
||||
class CRUDBase(Generic[ModelType], LoggerMixin):
|
||||
"""Base CRUD class with common operations"""
|
||||
|
||||
def __init__(self, model: Type[ModelType]):
|
||||
"""Initialize CRUD with model"""
|
||||
self.model = model
|
||||
|
||||
async def get(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
id: UUID,
|
||||
load_relations: Optional[List[str]] = None
|
||||
) -> Optional[ModelType]:
|
||||
"""Get single record by ID"""
|
||||
query = select(self.model).where(self.model.id == id)
|
||||
|
||||
# Load relationships if specified
|
||||
if load_relations:
|
||||
for relation in load_relations:
|
||||
if hasattr(self.model, relation):
|
||||
query = query.options(selectinload(getattr(self.model, relation)))
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_or_raise(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
id: UUID,
|
||||
resource_name: str = "Resource",
|
||||
load_relations: Optional[List[str]] = None
|
||||
) -> ModelType:
|
||||
"""Get single record by ID or raise NotFoundException"""
|
||||
obj = await self.get(db, id, load_relations)
|
||||
if not obj:
|
||||
raise NotFoundException(resource_name, id)
|
||||
return obj
|
||||
|
||||
async def get_multi(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
skip: int = 0,
|
||||
limit: int = 20,
|
||||
load_relations: Optional[List[str]] = None,
|
||||
filters: Optional[dict] = None,
|
||||
order_by: Optional[str] = "created_at",
|
||||
descending: bool = True
|
||||
) -> tuple[List[ModelType], int]:
|
||||
"""Get multiple records with pagination"""
|
||||
query = select(self.model)
|
||||
count_query = select(func.count()).select_from(self.model)
|
||||
|
||||
# Apply filters
|
||||
if filters:
|
||||
conditions = []
|
||||
for key, value in filters.items():
|
||||
if hasattr(self.model, key):
|
||||
conditions.append(getattr(self.model, key) == value)
|
||||
if conditions:
|
||||
query = query.where(and_(*conditions))
|
||||
count_query = count_query.where(and_(*conditions))
|
||||
|
||||
# Load relationships if specified
|
||||
if load_relations:
|
||||
for relation in load_relations:
|
||||
if hasattr(self.model, relation):
|
||||
query = query.options(selectinload(getattr(self.model, relation)))
|
||||
|
||||
# Count total
|
||||
total_result = await db.execute(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
# Apply ordering
|
||||
if order_by and hasattr(self.model, order_by):
|
||||
order_column = getattr(self.model, order_by)
|
||||
if descending:
|
||||
query = query.order_by(order_column.desc())
|
||||
else:
|
||||
query = query.order_by(order_column.asc())
|
||||
|
||||
# Apply pagination
|
||||
query = query.offset(skip).limit(limit)
|
||||
|
||||
result = await db.execute(query)
|
||||
items = result.scalars().all()
|
||||
|
||||
return list(items), total
|
||||
|
||||
async def create(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
obj_in: Any,
|
||||
commit: bool = True
|
||||
) -> ModelType:
|
||||
"""Create new record"""
|
||||
obj_data = obj_in.model_dump() if hasattr(obj_in, 'model_dump') else obj_in.dict()
|
||||
db_obj = self.model(**obj_data)
|
||||
db.add(db_obj)
|
||||
|
||||
if commit:
|
||||
await db.commit()
|
||||
await db.refresh(db_obj)
|
||||
self.log.debug(f"Created {self.model.__name__}: {db_obj.id}")
|
||||
|
||||
return db_obj
|
||||
|
||||
async def update(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
db_obj: ModelType,
|
||||
obj_in: Any,
|
||||
commit: bool = True
|
||||
) -> ModelType:
|
||||
"""Update existing record"""
|
||||
if hasattr(obj_in, 'model_dump'):
|
||||
obj_data = obj_in.model_dump(exclude_unset=True)
|
||||
elif hasattr(obj_in, 'dict'):
|
||||
obj_data = obj_in.dict(exclude_unset=True)
|
||||
else:
|
||||
obj_data = obj_in
|
||||
|
||||
for field, value in obj_data.items():
|
||||
if hasattr(db_obj, field):
|
||||
setattr(db_obj, field, value)
|
||||
|
||||
if commit:
|
||||
await db.commit()
|
||||
await db.refresh(db_obj)
|
||||
self.log.debug(f"Updated {self.model.__name__}: {db_obj.id}")
|
||||
|
||||
return db_obj
|
||||
|
||||
async def delete(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
id: UUID,
|
||||
commit: bool = True
|
||||
) -> bool:
|
||||
"""Delete record by ID"""
|
||||
obj = await self.get(db, id)
|
||||
if obj:
|
||||
await db.delete(obj)
|
||||
if commit:
|
||||
await db.commit()
|
||||
self.log.debug(f"Deleted {self.model.__name__}: {id}")
|
||||
return True
|
||||
return False
|
||||
|
||||
async def exists(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
filters: dict
|
||||
) -> bool:
|
||||
"""Check if record exists"""
|
||||
query = select(func.count()).select_from(self.model)
|
||||
for key, value in filters.items():
|
||||
if hasattr(self.model, key):
|
||||
query = query.where(getattr(self.model, key) == value)
|
||||
|
||||
result = await db.execute(query)
|
||||
count = result.scalar() or 0
|
||||
return count > 0
|
||||
@@ -2,25 +2,32 @@
|
||||
Database Configuration and Session Management
|
||||
支持 SQLite 和 PostgreSQL
|
||||
"""
|
||||
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import AsyncGenerator
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy import create_engine, event
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
def get_engine_config():
|
||||
"""根据数据库类型返回引擎配置"""
|
||||
if settings.DATABASE_URL.startswith("sqlite"):
|
||||
return {"echo": settings.DEBUG}
|
||||
return {"echo": settings.DEBUG, "poolclass": NullPool}
|
||||
else:
|
||||
return {
|
||||
"echo": settings.DEBUG,
|
||||
"pool_pre_ping": True,
|
||||
"pool_size": 10,
|
||||
"max_overflow": 20,
|
||||
"pool_recycle": 3600,
|
||||
"pool_timeout": 30,
|
||||
}
|
||||
|
||||
|
||||
@@ -30,14 +37,14 @@ async_engine = create_async_engine(
|
||||
**get_engine_config()
|
||||
)
|
||||
|
||||
# Sync engine for migrations
|
||||
# Sync engine for migrations (use NullPool for SQLite)
|
||||
sync_engine = create_engine(
|
||||
settings.DATABASE_URL_SYNC,
|
||||
echo=settings.DEBUG,
|
||||
pool_pre_ping=True,
|
||||
poolclass=NullPool if settings.DATABASE_URL_SYNC.startswith("sqlite") else None,
|
||||
)
|
||||
|
||||
|
||||
# Async session factory
|
||||
AsyncSessionLocal = async_sessionmaker(
|
||||
async_engine,
|
||||
@@ -55,8 +62,31 @@ class Base(DeclarativeBase):
|
||||
|
||||
async def init_db():
|
||||
"""Initialize database tables"""
|
||||
logger.info("Initializing database...")
|
||||
async with async_engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
logger.info("Database initialized successfully")
|
||||
|
||||
|
||||
async def close_db():
|
||||
"""Close database connections"""
|
||||
logger.info("Closing database connections...")
|
||||
await async_engine.dispose()
|
||||
logger.info("Database connections closed")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def get_db_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""Context manager for database sessions with automatic cleanup"""
|
||||
session = AsyncSessionLocal()
|
||||
try:
|
||||
yield session
|
||||
except Exception as e:
|
||||
logger.error(f"Database session error: {str(e)}")
|
||||
await session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
async def get_db() -> AsyncSession:
|
||||
@@ -64,5 +94,9 @@ async def get_db() -> AsyncSession:
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
yield session
|
||||
except Exception as e:
|
||||
logger.error(f"Database error in dependency: {str(e)}")
|
||||
await session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
119
backend/app/core/exceptions.py
Normal file
119
backend/app/core/exceptions.py
Normal file
@@ -0,0 +1,119 @@
|
||||
"""
|
||||
Custom Exceptions
|
||||
自定义异常类
|
||||
"""
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
class AppException(Exception):
|
||||
"""基础应用异常"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
code: str = "INTERNAL_ERROR",
|
||||
status_code: int = 500,
|
||||
details: Optional[dict] = None
|
||||
):
|
||||
self.message = message
|
||||
self.code = code
|
||||
self.status_code = status_code
|
||||
self.details = details
|
||||
super().__init__(self.message)
|
||||
|
||||
|
||||
class NotFoundException(AppException):
|
||||
"""资源未找到异常"""
|
||||
|
||||
def __init__(self, resource: str, resource_id: Any = None):
|
||||
message = f"{resource} not found"
|
||||
if resource_id:
|
||||
message = f"{resource} with id '{resource_id}' not found"
|
||||
super().__init__(
|
||||
message=message,
|
||||
code="NOT_FOUND",
|
||||
status_code=404
|
||||
)
|
||||
|
||||
|
||||
class ValidationException(AppException):
|
||||
"""验证异常"""
|
||||
|
||||
def __init__(self, message: str, field: str = None, details: dict = None):
|
||||
super().__init__(
|
||||
message=message,
|
||||
code="VALIDATION_ERROR",
|
||||
status_code=422,
|
||||
details={"field": field, **(details or {})}
|
||||
)
|
||||
|
||||
|
||||
class DuplicateException(AppException):
|
||||
"""重复资源异常"""
|
||||
|
||||
def __init__(self, resource: str, field: str = None):
|
||||
message = f"{resource} already exists"
|
||||
if field:
|
||||
message = f"{resource} with {field} already exists"
|
||||
super().__init__(
|
||||
message=message,
|
||||
code="DUPLICATE",
|
||||
status_code=409
|
||||
)
|
||||
|
||||
|
||||
class UnauthorizedException(AppException):
|
||||
"""未授权异常"""
|
||||
|
||||
def __init__(self, message: str = "Unauthorized"):
|
||||
super().__init__(
|
||||
message=message,
|
||||
code="UNAUTHORIZED",
|
||||
status_code=401
|
||||
)
|
||||
|
||||
|
||||
class ForbiddenException(AppException):
|
||||
"""禁止访问异常"""
|
||||
|
||||
def __init__(self, message: str = "Forbidden"):
|
||||
super().__init__(
|
||||
message=message,
|
||||
code="FORBIDDEN",
|
||||
status_code=403
|
||||
)
|
||||
|
||||
|
||||
class RateLimitException(AppException):
|
||||
"""速率限制异常"""
|
||||
|
||||
def __init__(self, message: str = "Rate limit exceeded"):
|
||||
super().__init__(
|
||||
message=message,
|
||||
code="RATE_LIMIT",
|
||||
status_code=429
|
||||
)
|
||||
|
||||
|
||||
class FileProcessingException(AppException):
|
||||
"""文件处理异常"""
|
||||
|
||||
def __init__(self, message: str, file_name: str = None):
|
||||
details = {"file_name": file_name} if file_name else None
|
||||
super().__init__(
|
||||
message=message,
|
||||
code="FILE_PROCESSING_ERROR",
|
||||
status_code=422,
|
||||
details=details
|
||||
)
|
||||
|
||||
|
||||
class DatabaseException(AppException):
|
||||
"""数据库异常"""
|
||||
|
||||
def __init__(self, message: str = "Database operation failed"):
|
||||
super().__init__(
|
||||
message=message,
|
||||
code="DATABASE_ERROR",
|
||||
status_code=500
|
||||
)
|
||||
66
backend/app/core/logging.py
Normal file
66
backend/app/core/logging.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""
|
||||
Logging Configuration
|
||||
日志配置
|
||||
"""
|
||||
import logging
|
||||
import sys
|
||||
from typing import Any
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from pathlib import Path
|
||||
from app.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
# Log directory
|
||||
LOG_DIR = Path("./logs")
|
||||
LOG_DIR.mkdir(exist_ok=True)
|
||||
|
||||
|
||||
def setup_logging(name: str = "yg_dataset") -> logging.Logger:
|
||||
"""Setup application logging"""
|
||||
logger = logging.getLogger(name)
|
||||
logger.setLevel(logging.DEBUG if settings.DEBUG else logging.INFO)
|
||||
|
||||
# Avoid duplicate handlers
|
||||
if logger.handlers:
|
||||
return logger
|
||||
|
||||
# Console handler
|
||||
console_handler = logging.StreamHandler(sys.stdout)
|
||||
console_handler.setLevel(logging.DEBUG if settings.DEBUG else logging.INFO)
|
||||
console_formatter = logging.Formatter(
|
||||
fmt="%(asctime)s | %(levelname)-8s | %(name)s:%(funcName)s:%(lineno)d | %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S"
|
||||
)
|
||||
console_handler.setFormatter(console_formatter)
|
||||
logger.addHandler(console_handler)
|
||||
|
||||
# File handler
|
||||
file_handler = RotatingFileHandler(
|
||||
LOG_DIR / f"{name}.log",
|
||||
maxBytes=10 * 1024 * 1024, # 10MB
|
||||
backupCount=5,
|
||||
encoding="utf-8"
|
||||
)
|
||||
file_handler.setLevel(logging.INFO)
|
||||
file_formatter = logging.Formatter(
|
||||
fmt="%(asctime)s | %(levelname)-8s | %(name)s:%(funcName)s:%(lineno)d | %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S"
|
||||
)
|
||||
file_handler.setFormatter(file_formatter)
|
||||
logger.addHandler(file_handler)
|
||||
|
||||
return logger
|
||||
|
||||
|
||||
# Create default logger
|
||||
logger = setup_logging()
|
||||
|
||||
|
||||
class LoggerMixin:
|
||||
"""Mixin to add logging capability to classes"""
|
||||
|
||||
@property
|
||||
def log(self) -> logging.Logger:
|
||||
"""Get logger for this class"""
|
||||
return logging.getLogger(self.__class__.__module__ + "." + self.__class__.__name__)
|
||||
@@ -3,23 +3,71 @@ YG-Dataset Backend Application
|
||||
FastAPI-based API server for dataset generation platform
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from app.api.v1 import api_router
|
||||
from app.api.response import ApiResponse
|
||||
from app.core.config import settings
|
||||
from app.core.database import init_db
|
||||
from app.core.database import init_db, close_db
|
||||
from app.core.exceptions import AppException
|
||||
from app.core.logging import logger
|
||||
|
||||
|
||||
class RequestIDMiddleware(BaseHTTPMiddleware):
|
||||
"""Middleware to add request ID to each request"""
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
request_id = str(uuid.uuid4())
|
||||
request.state.request_id = request_id
|
||||
|
||||
# Add request ID to response headers
|
||||
response = await call_next(request)
|
||||
response.headers["X-Request-ID"] = request_id
|
||||
|
||||
return response
|
||||
|
||||
|
||||
class TimingMiddleware(BaseHTTPMiddleware):
|
||||
"""Middleware to measure request processing time"""
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
start_time = time.time()
|
||||
|
||||
# Log request
|
||||
logger.info(f"→ {request.method} {request.url.path}")
|
||||
|
||||
response = await call_next(request)
|
||||
|
||||
process_time = time.time() - start_time
|
||||
response.headers["X-Process-Time"] = str(process_time)
|
||||
|
||||
# Log response
|
||||
logger.info(f"← {request.method} {request.url.path} | Status: {response.status_code} | Time: {process_time:.3f}s")
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Application lifespan events"""
|
||||
# Startup
|
||||
logger.info("Starting YG-Dataset application...")
|
||||
await init_db()
|
||||
logger.info("Database initialized successfully")
|
||||
yield
|
||||
# Shutdown
|
||||
pass
|
||||
logger.info("Shutting down YG-Dataset application...")
|
||||
await close_db()
|
||||
logger.info("Database connections closed")
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
@@ -29,15 +77,83 @@ app = FastAPI(
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# CORS
|
||||
# Add custom middleware (order matters: last added = first executed)
|
||||
app.add_middleware(TimingMiddleware)
|
||||
app.add_middleware(RequestIDMiddleware)
|
||||
|
||||
# CORS - Configure properly for production
|
||||
# For development, you can use ["*"] but for production, specify exact origins
|
||||
ALLOWED_ORIGINS = settings.ALLOWED_ORIGINS.split(",") if settings.ALLOWED_ORIGINS else ["*"]
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_origins=ALLOWED_ORIGINS,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
# Exception handlers
|
||||
@app.exception_handler(AppException)
|
||||
async def app_exception_handler(request: Request, exc: AppException):
|
||||
"""Handle custom application exceptions"""
|
||||
logger.warning(f"App exception: {exc.message} | Code: {exc.code}")
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content=ApiResponse.fail(
|
||||
message=exc.message,
|
||||
error={"code": exc.code, "details": exc.details}
|
||||
).model_dump()
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def validation_exception_handler(request: Request, exc: RequestValidationError):
|
||||
"""Handle validation exceptions"""
|
||||
errors = []
|
||||
for error in exc.errors():
|
||||
errors.append({
|
||||
"field": ".".join(str(loc) for loc in error["loc"]),
|
||||
"message": error["msg"],
|
||||
"type": error["type"]
|
||||
})
|
||||
logger.warning(f"Validation error: {errors}")
|
||||
return JSONResponse(
|
||||
status_code=422,
|
||||
content=ApiResponse.fail(
|
||||
message="Validation error",
|
||||
error={"code": "VALIDATION_ERROR", "details": {"errors": errors}}
|
||||
).model_dump()
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(SQLAlchemyError)
|
||||
async def database_exception_handler(request: Request, exc: SQLAlchemyError):
|
||||
"""Handle database exceptions"""
|
||||
logger.error(f"Database error: {str(exc)}", exc_info=True)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content=ApiResponse.fail(
|
||||
message="Database operation failed",
|
||||
error={"code": "DATABASE_ERROR"}
|
||||
).model_dump()
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def general_exception_handler(request: Request, exc: Exception):
|
||||
"""Handle unhandled exceptions"""
|
||||
logger.error(f"Unhandled exception: {str(exc)}", exc_info=True)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content=ApiResponse.fail(
|
||||
message="Internal server error",
|
||||
error={"code": "INTERNAL_ERROR"}
|
||||
).model_dump()
|
||||
)
|
||||
|
||||
|
||||
# Include API routes
|
||||
app.include_router(api_router, prefix="/api/v1")
|
||||
|
||||
@@ -45,7 +161,10 @@ app.include_router(api_router, prefix="/api/v1")
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
"""Health check endpoint"""
|
||||
return {"status": "healthy", "version": "1.0.0"}
|
||||
return ApiResponse.ok(
|
||||
data={"status": "healthy", "version": "1.0.0"},
|
||||
message="Service is running"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -51,7 +51,7 @@ class Chunk(Base, UUIDMixin, TimestampMixin):
|
||||
content = Column(Text, nullable=False)
|
||||
summary = Column(Text)
|
||||
word_count = Column(Integer)
|
||||
metadata = Column(JSON) # store additional info like headings, page numbers
|
||||
extra_data = Column(JSON) # store additional info like headings, page numbers
|
||||
|
||||
# Relationships
|
||||
project = relationship("Project", back_populates="chunks")
|
||||
@@ -112,7 +112,7 @@ class Dataset(Base, UUIDMixin, TimestampMixin):
|
||||
name = Column(String(255), nullable=False)
|
||||
description = Column(Text)
|
||||
dataset_type = Column(String(50)) # qa, conversation, instruction
|
||||
metadata = Column(JSON)
|
||||
extra_data = Column(JSON)
|
||||
|
||||
# Relationships
|
||||
project = relationship("Project", back_populates="datasets")
|
||||
@@ -125,7 +125,7 @@ class EvalDataset(Base, UUIDMixin, TimestampMixin):
|
||||
project_id = Column(UUID(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False)
|
||||
name = Column(String(255), nullable=False)
|
||||
question_type = Column(String(50)) # mixed, fact, reasoning
|
||||
metadata = Column(JSON)
|
||||
extra_data = Column(JSON)
|
||||
|
||||
# Relationships
|
||||
project = relationship("Project", back_populates="eval_datasets")
|
||||
|
||||
@@ -1,3 +1,89 @@
|
||||
"""
|
||||
Pydantic Schemas
|
||||
"""
|
||||
from app.schemas.base import (
|
||||
TimestampMixin,
|
||||
UUIDMixin,
|
||||
)
|
||||
|
||||
from app.schemas.project import (
|
||||
ProjectBase,
|
||||
ProjectCreate,
|
||||
ProjectUpdate,
|
||||
ProjectResponse,
|
||||
)
|
||||
|
||||
from app.schemas.file import (
|
||||
FileBase,
|
||||
FileCreate,
|
||||
FileUpdate,
|
||||
FileResponse,
|
||||
)
|
||||
|
||||
from app.schemas.chunk import (
|
||||
ChunkBase,
|
||||
ChunkCreate,
|
||||
ChunkUpdate,
|
||||
ChunkResponse,
|
||||
)
|
||||
|
||||
from app.schemas.question import (
|
||||
QuestionBase,
|
||||
QuestionCreate,
|
||||
QuestionUpdate,
|
||||
QuestionResponse,
|
||||
)
|
||||
|
||||
from app.schemas.dataset import (
|
||||
DatasetBase,
|
||||
DatasetCreate,
|
||||
DatasetUpdate,
|
||||
DatasetResponse,
|
||||
)
|
||||
|
||||
from app.schemas.eval import (
|
||||
EvalDatasetBase,
|
||||
EvalDatasetCreate,
|
||||
EvalDatasetUpdate,
|
||||
EvalDatasetResponse,
|
||||
TaskBase,
|
||||
TaskResponse,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Base
|
||||
"TimestampMixin",
|
||||
"UUIDMixin",
|
||||
# Project
|
||||
"ProjectBase",
|
||||
"ProjectCreate",
|
||||
"ProjectUpdate",
|
||||
"ProjectResponse",
|
||||
# File
|
||||
"FileBase",
|
||||
"FileCreate",
|
||||
"FileUpdate",
|
||||
"FileResponse",
|
||||
# Chunk
|
||||
"ChunkBase",
|
||||
"ChunkCreate",
|
||||
"ChunkUpdate",
|
||||
"ChunkResponse",
|
||||
# Question
|
||||
"QuestionBase",
|
||||
"QuestionCreate",
|
||||
"QuestionUpdate",
|
||||
"QuestionResponse",
|
||||
# Dataset
|
||||
"DatasetBase",
|
||||
"DatasetCreate",
|
||||
"DatasetUpdate",
|
||||
"DatasetResponse",
|
||||
# Eval
|
||||
"EvalDatasetBase",
|
||||
"EvalDatasetCreate",
|
||||
"EvalDatasetUpdate",
|
||||
"EvalDatasetResponse",
|
||||
"TaskBase",
|
||||
"TaskResponse",
|
||||
]
|
||||
|
||||
@@ -4,7 +4,7 @@ Base Pydantic schemas
|
||||
from datetime import datetime
|
||||
from typing import Optional, Any
|
||||
from uuid import UUID
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class TimestampMixin(BaseModel):
|
||||
@@ -18,153 +18,3 @@ class UUIDMixin(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: UUID
|
||||
|
||||
|
||||
class ProjectBase(BaseModel):
|
||||
"""Base project schema"""
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class ProjectCreate(ProjectBase):
|
||||
"""Project create schema"""
|
||||
pass
|
||||
|
||||
|
||||
class ProjectUpdate(ProjectBase):
|
||||
"""Project update schema"""
|
||||
pass
|
||||
|
||||
|
||||
class ProjectResponse(ProjectBase, UUIDMixin, TimestampMixin):
|
||||
"""Project response schema"""
|
||||
pass
|
||||
|
||||
|
||||
class FileBase(BaseModel):
|
||||
"""Base file schema"""
|
||||
filename: str
|
||||
file_type: str
|
||||
size: Optional[int] = None
|
||||
|
||||
|
||||
class FileResponse(FileBase, UUIDMixin, TimestampMixin):
|
||||
"""File response schema"""
|
||||
status: str
|
||||
|
||||
|
||||
class ChunkBase(BaseModel):
|
||||
"""Base chunk schema"""
|
||||
name: Optional[str] = None
|
||||
content: str
|
||||
summary: Optional[str] = None
|
||||
word_count: Optional[int] = None
|
||||
|
||||
|
||||
class ChunkCreate(ChunkBase):
|
||||
"""Chunk create schema"""
|
||||
file_id: Optional[UUID] = None
|
||||
|
||||
|
||||
class ChunkResponse(ChunkBase, UUIDMixin, TimestampMixin):
|
||||
"""Chunk response schema"""
|
||||
pass
|
||||
|
||||
|
||||
class QuestionBase(BaseModel):
|
||||
"""Base question schema"""
|
||||
content: str
|
||||
answer: Optional[str] = None
|
||||
question_type: Optional[str] = None
|
||||
|
||||
|
||||
class QuestionCreate(QuestionBase):
|
||||
"""Question create schema"""
|
||||
chunk_id: Optional[UUID] = None
|
||||
|
||||
|
||||
class QuestionResponse(QuestionBase, UUIDMixin, TimestampMixin):
|
||||
"""Question response schema"""
|
||||
source: str
|
||||
|
||||
|
||||
class DatasetBase(BaseModel):
|
||||
"""Base dataset schema"""
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
dataset_type: Optional[str] = None
|
||||
|
||||
|
||||
class DatasetCreate(DatasetBase):
|
||||
"""Dataset create schema"""
|
||||
pass
|
||||
|
||||
|
||||
class DatasetResponse(DatasetBase, UUIDMixin, TimestampMixin):
|
||||
"""Dataset response schema"""
|
||||
question_count: Optional[int] = None
|
||||
|
||||
|
||||
class EvalDatasetBase(BaseModel):
|
||||
"""Base eval dataset schema"""
|
||||
name: str
|
||||
question_type: Optional[str] = None
|
||||
|
||||
|
||||
class EvalDatasetCreate(EvalDatasetBase):
|
||||
"""Eval dataset create schema"""
|
||||
pass
|
||||
|
||||
|
||||
class EvalDatasetResponse(EvalDatasetBase, UUIDMixin, TimestampMixin):
|
||||
"""Eval dataset response schema"""
|
||||
pass
|
||||
|
||||
|
||||
class TagBase(BaseModel):
|
||||
"""Base tag schema"""
|
||||
label: str
|
||||
parent_id: Optional[UUID] = None
|
||||
color: Optional[str] = None
|
||||
|
||||
|
||||
class TagCreate(TagBase):
|
||||
"""Tag create schema"""
|
||||
pass
|
||||
|
||||
|
||||
class TagResponse(TagBase, UUIDMixin, TimestampMixin):
|
||||
"""Tag response schema"""
|
||||
pass
|
||||
|
||||
|
||||
class ModelConfigBase(BaseModel):
|
||||
"""Base model config schema"""
|
||||
provider: str
|
||||
model_name: Optional[str] = None
|
||||
api_key: Optional[str] = None
|
||||
api_base: Optional[str] = None
|
||||
is_default: Optional[str] = "false"
|
||||
|
||||
|
||||
class ModelConfigCreate(ModelConfigBase):
|
||||
"""Model config create schema"""
|
||||
pass
|
||||
|
||||
|
||||
class ModelConfigResponse(ModelConfigBase, UUIDMixin, TimestampMixin):
|
||||
"""Model config response schema"""
|
||||
pass
|
||||
|
||||
|
||||
class TaskBase(BaseModel):
|
||||
"""Base task schema"""
|
||||
task_type: str
|
||||
status: Optional[str] = "pending"
|
||||
progress: Optional[int] = 0
|
||||
|
||||
|
||||
class TaskResponse(TaskBase, UUIDMixin, TimestampMixin):
|
||||
"""Task response schema"""
|
||||
result: Optional[Any] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
46
backend/app/schemas/chunk.py
Normal file
46
backend/app/schemas/chunk.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""
|
||||
Chunk Schemas
|
||||
"""
|
||||
from datetime import datetime
|
||||
from typing import Optional, Any
|
||||
from uuid import UUID
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class ChunkBase(BaseModel):
|
||||
"""Base chunk schema"""
|
||||
name: Optional[str] = Field(None, max_length=255)
|
||||
content: str = Field(..., min_length=1)
|
||||
summary: Optional[str] = None
|
||||
word_count: Optional[int] = None
|
||||
extra_data: Optional[dict] = None
|
||||
|
||||
|
||||
class ChunkCreate(ChunkBase):
|
||||
"""Chunk create schema"""
|
||||
project_id: Optional[UUID] = None
|
||||
file_id: Optional[UUID] = None
|
||||
|
||||
|
||||
class ChunkUpdate(BaseModel):
|
||||
"""Chunk update schema"""
|
||||
name: Optional[str] = Field(None, max_length=255)
|
||||
content: Optional[str] = Field(None, min_length=1)
|
||||
summary: Optional[str] = None
|
||||
extra_data: Optional[dict] = None
|
||||
|
||||
|
||||
class ChunkResponse(ChunkBase):
|
||||
"""Chunk response schema"""
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: UUID
|
||||
project_id: UUID
|
||||
file_id: Optional[UUID]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
# Alias for CRUD
|
||||
ChunkCreateSchema = ChunkCreate
|
||||
ChunkUpdateSchema = ChunkUpdate
|
||||
43
backend/app/schemas/dataset.py
Normal file
43
backend/app/schemas/dataset.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""
|
||||
Dataset Schemas
|
||||
"""
|
||||
from datetime import datetime
|
||||
from typing import Optional, Any
|
||||
from uuid import UUID
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class DatasetBase(BaseModel):
|
||||
"""Base dataset schema"""
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
description: Optional[str] = Field(None, max_length=2000)
|
||||
dataset_type: Optional[str] = Field(None, max_length=50)
|
||||
extra_data: Optional[dict] = None
|
||||
|
||||
|
||||
class DatasetCreate(DatasetBase):
|
||||
"""Dataset create schema"""
|
||||
pass
|
||||
|
||||
|
||||
class DatasetUpdate(BaseModel):
|
||||
"""Dataset update schema"""
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||
description: Optional[str] = Field(None, max_length=2000)
|
||||
dataset_type: Optional[str] = Field(None, max_length=50)
|
||||
extra_data: Optional[dict] = None
|
||||
|
||||
|
||||
class DatasetResponse(DatasetBase):
|
||||
"""Dataset response schema"""
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: UUID
|
||||
project_id: UUID
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
# Alias for CRUD
|
||||
DatasetCreateSchema = DatasetCreate
|
||||
DatasetUpdateSchema = DatasetUpdate
|
||||
60
backend/app/schemas/eval.py
Normal file
60
backend/app/schemas/eval.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""
|
||||
Evaluation Dataset Schemas
|
||||
"""
|
||||
from datetime import datetime
|
||||
from typing import Optional, Any
|
||||
from uuid import UUID
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class EvalDatasetBase(BaseModel):
|
||||
"""Base eval dataset schema"""
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
question_type: Optional[str] = Field("mixed", max_length=50)
|
||||
extra_data: Optional[dict] = None
|
||||
|
||||
|
||||
class EvalDatasetCreate(EvalDatasetBase):
|
||||
"""Eval dataset create schema"""
|
||||
pass
|
||||
|
||||
|
||||
class EvalDatasetUpdate(BaseModel):
|
||||
"""Eval dataset update schema"""
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||
question_type: Optional[str] = Field(None, max_length=50)
|
||||
extra_data: Optional[dict] = None
|
||||
|
||||
|
||||
class EvalDatasetResponse(EvalDatasetBase):
|
||||
"""Eval dataset response schema"""
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: UUID
|
||||
project_id: UUID
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class TaskBase(BaseModel):
|
||||
"""Base task schema"""
|
||||
task_type: str = Field(..., max_length=50)
|
||||
status: Optional[str] = "pending"
|
||||
progress: Optional[int] = Field(0, ge=0, le=100)
|
||||
result: Optional[Any] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
class TaskResponse(TaskBase):
|
||||
"""Task response schema"""
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: UUID
|
||||
project_id: Optional[UUID]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
# Alias for CRUD
|
||||
EvalDatasetCreateSchema = EvalDatasetCreate
|
||||
EvalDatasetUpdateSchema = EvalDatasetUpdate
|
||||
43
backend/app/schemas/file.py
Normal file
43
backend/app/schemas/file.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""
|
||||
File Schemas
|
||||
"""
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class FileBase(BaseModel):
|
||||
"""Base file schema"""
|
||||
filename: str = Field(..., min_length=1, max_length=255)
|
||||
file_type: str = Field(..., max_length=50)
|
||||
size: Optional[int] = None
|
||||
|
||||
|
||||
class FileCreate(FileBase):
|
||||
"""File create schema"""
|
||||
project_id: UUID
|
||||
file_path: Optional[str] = None
|
||||
status: str = "pending"
|
||||
|
||||
|
||||
class FileUpdate(BaseModel):
|
||||
"""File update schema"""
|
||||
status: Optional[str] = None
|
||||
|
||||
|
||||
class FileResponse(FileBase):
|
||||
"""File response schema"""
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: UUID
|
||||
project_id: UUID
|
||||
file_path: Optional[str]
|
||||
status: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
# Alias for CRUD
|
||||
FileCreateSchema = FileCreate
|
||||
FileUpdateSchema = FileUpdate
|
||||
38
backend/app/schemas/project.py
Normal file
38
backend/app/schemas/project.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""
|
||||
Project Schemas
|
||||
"""
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class ProjectBase(BaseModel):
|
||||
"""Base project schema"""
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
description: Optional[str] = Field(None, max_length=2000)
|
||||
|
||||
|
||||
class ProjectCreate(ProjectBase):
|
||||
"""Project create schema"""
|
||||
pass
|
||||
|
||||
|
||||
class ProjectUpdate(BaseModel):
|
||||
"""Project update schema"""
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||
description: Optional[str] = Field(None, max_length=2000)
|
||||
|
||||
|
||||
class ProjectResponse(ProjectBase):
|
||||
"""Project response schema"""
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: UUID
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
# Alias for CRUD
|
||||
ProjectCreateSchema = ProjectCreate
|
||||
ProjectUpdateSchema = ProjectUpdate
|
||||
43
backend/app/schemas/question.py
Normal file
43
backend/app/schemas/question.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""
|
||||
Question Schemas
|
||||
"""
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class QuestionBase(BaseModel):
|
||||
"""Base question schema"""
|
||||
content: str = Field(..., min_length=1)
|
||||
answer: Optional[str] = None
|
||||
question_type: Optional[str] = Field(None, max_length=50)
|
||||
source: Optional[str] = "manual"
|
||||
|
||||
|
||||
class QuestionCreate(QuestionBase):
|
||||
"""Question create schema"""
|
||||
chunk_id: Optional[UUID] = None
|
||||
|
||||
|
||||
class QuestionUpdate(BaseModel):
|
||||
"""Question update schema"""
|
||||
content: Optional[str] = Field(None, min_length=1)
|
||||
answer: Optional[str] = None
|
||||
question_type: Optional[str] = Field(None, max_length=50)
|
||||
|
||||
|
||||
class QuestionResponse(QuestionBase):
|
||||
"""Question response schema"""
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: UUID
|
||||
project_id: UUID
|
||||
chunk_id: Optional[UUID]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
# Alias for CRUD
|
||||
QuestionCreateSchema = QuestionCreate
|
||||
QuestionUpdateSchema = QuestionUpdate
|
||||
@@ -1,8 +1,9 @@
|
||||
"""
|
||||
DOCX Text Extractor
|
||||
"""
|
||||
import asyncio
|
||||
from typing import Dict
|
||||
from docx import Document
|
||||
from typing import Dict, List
|
||||
|
||||
|
||||
class DOCXProcessor:
|
||||
@@ -26,6 +27,12 @@ class DOCXProcessor:
|
||||
|
||||
return "\n\n".join(text_parts)
|
||||
|
||||
async def extract_text_async(self, file_path: str) -> str:
|
||||
"""Extract all text from DOCX asynchronously"""
|
||||
return await asyncio.get_event_loop().run_in_executor(
|
||||
None, self.extract_text, file_path
|
||||
)
|
||||
|
||||
def extract_with_metadata(self, file_path: str) -> Dict:
|
||||
"""Extract text with DOCX metadata"""
|
||||
doc = Document(file_path)
|
||||
@@ -46,8 +53,14 @@ class DOCXProcessor:
|
||||
|
||||
return result
|
||||
|
||||
async def extract_with_metadata_async(self, file_path: str) -> Dict:
|
||||
"""Extract with metadata asynchronously"""
|
||||
return await asyncio.get_event_loop().run_in_executor(
|
||||
None, self.extract_with_metadata, file_path
|
||||
)
|
||||
|
||||
def process_docx(file_path: str) -> str:
|
||||
|
||||
async def process_docx(file_path: str) -> str:
|
||||
"""Process DOCX file and return text"""
|
||||
processor = DOCXProcessor()
|
||||
return processor.extract_text(file_path)
|
||||
return await processor.extract_text_async(file_path)
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"""
|
||||
Excel/CSV Text Extractor
|
||||
"""
|
||||
import pandas as pd
|
||||
import asyncio
|
||||
from typing import Dict, List
|
||||
import pandas as pd
|
||||
|
||||
|
||||
class ExcelProcessor:
|
||||
@@ -13,6 +14,12 @@ class ExcelProcessor:
|
||||
df = pd.read_csv(file_path)
|
||||
return self._dataframe_to_text(df)
|
||||
|
||||
async def extract_csv_async(self, file_path: str) -> str:
|
||||
"""Extract CSV asynchronously"""
|
||||
return await asyncio.get_event_loop().run_in_executor(
|
||||
None, self.extract_csv, file_path
|
||||
)
|
||||
|
||||
def extract_excel(self, file_path: str, sheet_name: str = None) -> str:
|
||||
"""Extract text from Excel file"""
|
||||
if sheet_name:
|
||||
@@ -27,6 +34,12 @@ class ExcelProcessor:
|
||||
text_parts.append(self._dataframe_to_text(df))
|
||||
return "\n\n".join(text_parts)
|
||||
|
||||
async def extract_excel_async(self, file_path: str, sheet_name: str = None) -> str:
|
||||
"""Extract Excel asynchronously"""
|
||||
return await asyncio.get_event_loop().run_in_executor(
|
||||
None, self.extract_excel, file_path, sheet_name
|
||||
)
|
||||
|
||||
def _dataframe_to_text(self, df: pd.DataFrame) -> str:
|
||||
"""Convert DataFrame to readable text"""
|
||||
text_parts = []
|
||||
@@ -48,19 +61,25 @@ class ExcelProcessor:
|
||||
sheets = pd.read_excel(file_path, sheet_name=None)
|
||||
return {name: self._dataframe_to_text(df) for name, df in sheets.items()}
|
||||
|
||||
async def extract_all_sheets_async(self, file_path: str) -> Dict[str, str]:
|
||||
"""Extract all sheets asynchronously"""
|
||||
return await asyncio.get_event_loop().run_in_executor(
|
||||
None, self.extract_all_sheets, file_path
|
||||
)
|
||||
|
||||
def get_sheet_names(self, file_path: str) -> List[str]:
|
||||
"""Get all sheet names from Excel file"""
|
||||
xl = pd.ExcelFile(file_path)
|
||||
return xl.sheet_names
|
||||
|
||||
|
||||
def process_csv(file_path: str) -> str:
|
||||
async def process_csv(file_path: str) -> str:
|
||||
"""Process CSV file and return text"""
|
||||
processor = ExcelProcessor()
|
||||
return processor.extract_csv(file_path)
|
||||
return await processor.extract_csv_async(file_path)
|
||||
|
||||
|
||||
def process_excel(file_path: str) -> str:
|
||||
async def process_excel(file_path: str) -> str:
|
||||
"""Process Excel file and return text"""
|
||||
processor = ExcelProcessor()
|
||||
return processor.extract_excel(file_path)
|
||||
return await processor.extract_excel_async(file_path)
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"""
|
||||
PDF Text Extractor
|
||||
"""
|
||||
import asyncio
|
||||
from typing import Dict, List
|
||||
import pdfplumber
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
|
||||
class PDFProcessor:
|
||||
@@ -20,6 +21,12 @@ class PDFProcessor:
|
||||
|
||||
return "\n\n".join(text_parts)
|
||||
|
||||
async def extract_text_async(self, file_path: str) -> str:
|
||||
"""Extract all text from PDF asynchronously"""
|
||||
return await asyncio.get_event_loop().run_in_executor(
|
||||
None, self.extract_text, file_path
|
||||
)
|
||||
|
||||
def extract_pages(self, file_path: str) -> List[Dict]:
|
||||
"""Extract text page by page with metadata"""
|
||||
pages = []
|
||||
@@ -36,6 +43,12 @@ class PDFProcessor:
|
||||
|
||||
return pages
|
||||
|
||||
async def extract_pages_async(self, file_path: str) -> List[Dict]:
|
||||
"""Extract pages asynchronously"""
|
||||
return await asyncio.get_event_loop().run_in_executor(
|
||||
None, self.extract_pages, file_path
|
||||
)
|
||||
|
||||
def extract_with_metadata(self, file_path: str) -> Dict:
|
||||
"""Extract text with PDF metadata"""
|
||||
result = {
|
||||
@@ -58,8 +71,14 @@ class PDFProcessor:
|
||||
|
||||
return result
|
||||
|
||||
async def extract_with_metadata_async(self, file_path: str) -> Dict:
|
||||
"""Extract with metadata asynchronously"""
|
||||
return await asyncio.get_event_loop().run_in_executor(
|
||||
None, self.extract_with_metadata, file_path
|
||||
)
|
||||
|
||||
def process_pdf(file_path: str) -> str:
|
||||
|
||||
async def process_pdf(file_path: str) -> str:
|
||||
"""Process PDF file and return text"""
|
||||
processor = PDFProcessor()
|
||||
return processor.extract_with_metadata(file_path)["text"]
|
||||
return await processor.extract_with_metadata_async(file_path)
|
||||
|
||||
125
backend/pyproject.toml
Normal file
125
backend/pyproject.toml
Normal file
@@ -0,0 +1,125 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=61.0", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "yg-dataset"
|
||||
version = "1.0.0"
|
||||
description = "Dataset Generation Platform API"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
license = {text = "MIT"}
|
||||
authors = [
|
||||
{name = "YG-Dataset Team", email = "team@yg-dataset.com"}
|
||||
]
|
||||
keywords = ["dataset", "machine-learning", "llm", "data-generation"]
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
"Framework :: FastAPI",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
]
|
||||
|
||||
dependencies = [
|
||||
"fastapi>=0.115.0",
|
||||
"uvicorn[standard]>=0.30.0",
|
||||
"python-multipart>=0.0.9",
|
||||
"sqlalchemy>=2.0.0",
|
||||
"alembic>=1.13.0",
|
||||
"pydantic>=2.0.0",
|
||||
"pydantic-settings>=2.0.0",
|
||||
"pdfplumber>=0.10.4",
|
||||
"python-docx>=1.1.0",
|
||||
"openpyxl>=3.1.2",
|
||||
"pandas>=2.2.0",
|
||||
"ebooklib>=0.5",
|
||||
"PyMuPDF>=1.24.0",
|
||||
"langchain>=0.3.0",
|
||||
"langchain-community>=0.2.0",
|
||||
"langchain-openai>=0.1.0",
|
||||
"tiktoken>=0.7.0",
|
||||
"python-dotenv>=1.0.0",
|
||||
"python-dateutil>=2.8.2",
|
||||
"httpx>=0.27.0",
|
||||
"aiofiles>=23.2.1",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.0.0",
|
||||
"pytest-asyncio>=0.23.0",
|
||||
"pytest-cov>=4.1.0",
|
||||
"ruff>=0.3.0",
|
||||
"mypy>=1.8.0",
|
||||
"pre-commit>=3.6.0",
|
||||
"black>=24.2.0",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
dev-dependencies = [
|
||||
"pytest>=8.0.0",
|
||||
"pytest-asyncio>=0.23.0",
|
||||
"pytest-cov>=4.1.0",
|
||||
"ruff>=0.3.0",
|
||||
"mypy>=1.8.0",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py311"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = [
|
||||
"E", # pycodestyle errors
|
||||
"W", # pycodestyle warnings
|
||||
"F", # pyflakes
|
||||
"I", # isort
|
||||
"B", # flake8-bugbear
|
||||
"C4", # flake8-comprehensions
|
||||
"UP", # pyupgrade
|
||||
]
|
||||
ignore = [
|
||||
"E501", # line too long (handled by formatter)
|
||||
"B008", # do not perform function calls in argument defaults
|
||||
]
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.11"
|
||||
warn_return_any = true
|
||||
warn_unused_configs = true
|
||||
disallow_untyped_defs = false
|
||||
disallow_incomplete_defs = false
|
||||
check_untyped_defs = true
|
||||
no_implicit_optional = true
|
||||
warn_redundant_casts = true
|
||||
warn_unused_ignores = true
|
||||
warn_no_return = true
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
python_files = ["test_*.py"]
|
||||
python_classes = ["Test*"]
|
||||
python_functions = ["test_*"]
|
||||
addopts = "-v --tb=short"
|
||||
|
||||
[tool.coverage.run]
|
||||
source = ["app"]
|
||||
omit = [
|
||||
"*/tests/*",
|
||||
"*/venv/*",
|
||||
]
|
||||
|
||||
[tool.coverage.report]
|
||||
exclude_lines = [
|
||||
"pragma: no cover",
|
||||
"def __repr__",
|
||||
"raise AssertionError",
|
||||
"raise NotImplementedError",
|
||||
"if __name__ == .__main__.:",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
yg-dataset = "app.main:main"
|
||||
959
frontend/package-lock.json
generated
959
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -8,17 +8,21 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.0",
|
||||
"@vueuse/core": "^11.0.0",
|
||||
"axios": "^1.7.0",
|
||||
"element-plus": "^2.8.0",
|
||||
"pinia": "^2.2.0",
|
||||
"vue": "^3.5.0",
|
||||
"vue-router": "^4.4.0",
|
||||
"pinia": "^2.2.0",
|
||||
"element-plus": "^2.8.0",
|
||||
"@element-plus/icons-vue": "^2.3.0",
|
||||
"axios": "^1.7.0",
|
||||
"@vueuse/core": "^11.0.0",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.0",
|
||||
"vite": "^6.0.0"
|
||||
"@vitejs/plugin-vue": "^5.2.4",
|
||||
"sass": "^1.77.0",
|
||||
"sass-embedded": "^1.98.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^6.0.0",
|
||||
"vue-tsc": "^3.2.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const request = axios.create({
|
||||
baseURL: '/api/v1',
|
||||
timeout: 60000
|
||||
})
|
||||
|
||||
// Request interceptor
|
||||
request.interceptors.request.use(
|
||||
config => {
|
||||
return config
|
||||
},
|
||||
error => {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// Response interceptor
|
||||
request.interceptors.response.use(
|
||||
response => {
|
||||
return response.data
|
||||
},
|
||||
error => {
|
||||
const message = error.response?.data?.message || error.message || '请求失败'
|
||||
console.error('API Error:', message)
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
export const projectApi = {
|
||||
list: () => request.get('/projects/'),
|
||||
get: (id) => request.get(`/projects/${id}`),
|
||||
create: (data) => request.post('/projects/', data),
|
||||
update: (id, data) => request.put(`/projects/${id}`, data),
|
||||
delete: (id) => request.delete(`/projects/${id}`)
|
||||
}
|
||||
|
||||
export const fileApi = {
|
||||
upload: (projectId, formData) =>
|
||||
request.post(`/projects/${projectId}/files/upload`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
}),
|
||||
list: (projectId) => request.get(`/projects/${projectId}/files/`),
|
||||
get: (projectId, fileId) => request.get(`/projects/${projectId}/files/${fileId}`),
|
||||
delete: (projectId, fileId) => request.delete(`/projects/${projectId}/files/${fileId}`)
|
||||
}
|
||||
|
||||
export const chunkApi = {
|
||||
split: (projectId, data) => request.post(`/projects/${projectId}/chunks/split`, data),
|
||||
list: (projectId, params) => request.get(`/projects/${projectId}/chunks/`, { params }),
|
||||
get: (projectId, chunkId) => request.get(`/projects/${projectId}/chunks/${chunkId}`),
|
||||
update: (projectId, chunkId, data) => request.put(`/projects/${projectId}/chunks/${chunkId}`, data),
|
||||
delete: (projectId, chunkId) => request.delete(`/projects/${projectId}/chunks/${chunkId}`)
|
||||
}
|
||||
|
||||
export const questionApi = {
|
||||
generate: (projectId, data) => request.post(`/projects/${projectId}/generate-questions`, data),
|
||||
list: (projectId, params) => request.get(`/projects/${projectId}/chunks/${params.chunkId}/questions`),
|
||||
update: (projectId, questionId, data) => request.put(`/projects/${projectId}/questions/${questionId}`, data),
|
||||
delete: (projectId, questionId) => request.delete(`/projects/${projectId}/questions/${questionId}`)
|
||||
}
|
||||
|
||||
export const datasetApi = {
|
||||
list: (projectId) => request.get(`/projects/${projectId}/datasets/`),
|
||||
create: (projectId, data) => request.post(`/projects/${projectId}/datasets/`, data),
|
||||
get: (projectId, datasetId) => request.get(`/projects/${projectId}/datasets/${datasetId}`),
|
||||
delete: (projectId, datasetId) => request.delete(`/projects/${projectId}/datasets/${datasetId}`),
|
||||
export: (projectId, datasetId, data) =>
|
||||
request.post(`/projects/${projectId}/datasets/${datasetId}/export`, data, {
|
||||
responseType: 'blob'
|
||||
})
|
||||
}
|
||||
|
||||
export const evalApi = {
|
||||
list: (projectId) => request.get(`/projects/${projectId}/eval-datasets/`),
|
||||
create: (projectId, data) => request.post(`/projects/${projectId}/eval-datasets/`, data),
|
||||
run: (projectId, evalId) => request.post(`/projects/${projectId}/eval-datasets/${evalId}/evaluate`),
|
||||
getResults: (projectId, taskId) => request.get(`/projects/${projectId}/eval-tasks/${taskId}`)
|
||||
}
|
||||
|
||||
export default request
|
||||
94
frontend/src/api/index.ts
Normal file
94
frontend/src/api/index.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import axios from 'axios'
|
||||
import type { AxiosInstance } from 'axios'
|
||||
import type { Project, ProjectCreate, ProjectUpdate } from '@/types'
|
||||
|
||||
const request: AxiosInstance = axios.create({
|
||||
baseURL: import.meta.env.PROD
|
||||
? '/api/v1'
|
||||
: 'http://10.10.10.77:8000/api/v1',
|
||||
timeout: 60000
|
||||
})
|
||||
|
||||
// Request interceptor
|
||||
request.interceptors.request.use(
|
||||
config => {
|
||||
return config
|
||||
},
|
||||
error => {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// Response interceptor
|
||||
request.interceptors.response.use(
|
||||
response => {
|
||||
const data = response.data
|
||||
// Handle new ApiResponse format
|
||||
if (data.success !== undefined) {
|
||||
if (data.success) {
|
||||
return data.data // Return the actual data
|
||||
} else {
|
||||
return Promise.reject(new Error(data.message || data.error || '请求失败'))
|
||||
}
|
||||
}
|
||||
return data
|
||||
},
|
||||
error => {
|
||||
const message = error.response?.data?.message || error.message || '请求失败'
|
||||
console.error('API Error:', message)
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
export const projectApi = {
|
||||
list: () => request.get<Project[]>('/projects/'),
|
||||
get: (id: string) => request.get<Project>(`/projects/${id}`),
|
||||
create: (data: ProjectCreate) => request.post<{ id: string }>('/projects/', data),
|
||||
update: (id: string, data: ProjectUpdate) => request.put<Project>(`/projects/${id}`, data),
|
||||
delete: (id: string) => request.delete(`/projects/${id}`)
|
||||
}
|
||||
|
||||
export const fileApi = {
|
||||
upload: (projectId: string, formData: FormData) =>
|
||||
request.post(`/projects/${projectId}/files/upload`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
}),
|
||||
list: (projectId: string) => request.get(`/projects/${projectId}/files/`),
|
||||
get: (projectId: string, fileId: string) => request.get(`/projects/${projectId}/files/${fileId}`),
|
||||
delete: (projectId: string, fileId: string) => request.delete(`/projects/${projectId}/files/${fileId}`)
|
||||
}
|
||||
|
||||
export const chunkApi = {
|
||||
split: (projectId: string, data: any) => request.post(`/projects/${projectId}/chunks/split`, data),
|
||||
list: (projectId: string, params?: any) => request.get(`/projects/${projectId}/chunks/`, { params }),
|
||||
get: (projectId: string, chunkId: string) => request.get(`/projects/${projectId}/chunks/${chunkId}`),
|
||||
update: (projectId: string, chunkId: string, data: any) => request.put(`/projects/${projectId}/chunks/${chunkId}`, data),
|
||||
delete: (projectId: string, chunkId: string) => request.delete(`/projects/${projectId}/chunks/${chunkId}`)
|
||||
}
|
||||
|
||||
export const questionApi = {
|
||||
generate: (projectId: string, data: any) => request.post(`/projects/${projectId}/generate-questions`, data),
|
||||
list: (projectId: string, params: { chunkId: string }) => request.get(`/projects/${projectId}/chunks/${params.chunkId}/questions`),
|
||||
update: (projectId: string, questionId: string, data: any) => request.put(`/projects/${projectId}/questions/${questionId}`, data),
|
||||
delete: (projectId: string, questionId: string) => request.delete(`/projects/${projectId}/questions/${questionId}`)
|
||||
}
|
||||
|
||||
export const datasetApi = {
|
||||
list: (projectId: string) => request.get(`/projects/${projectId}/datasets/`),
|
||||
create: (projectId: string, data: any) => request.post(`/projects/${projectId}/datasets/`, data),
|
||||
get: (projectId: string, datasetId: string) => request.get(`/projects/${projectId}/datasets/${datasetId}`),
|
||||
delete: (projectId: string, datasetId: string) => request.delete(`/projects/${projectId}/datasets/${datasetId}`),
|
||||
export: (projectId: string, datasetId: string, data: any) =>
|
||||
request.post(`/projects/${projectId}/datasets/${datasetId}/export`, data, {
|
||||
responseType: 'blob'
|
||||
})
|
||||
}
|
||||
|
||||
export const evalApi = {
|
||||
list: (projectId: string) => request.get(`/projects/${projectId}/eval-datasets/`),
|
||||
create: (projectId: string, data: any) => request.post(`/projects/${projectId}/eval-datasets/`, data),
|
||||
run: (projectId: string, evalId: string) => request.post(`/projects/${projectId}/eval-datasets/${evalId}/evaluate`),
|
||||
getResults: (projectId: string, taskId: string) => request.get(`/projects/${projectId}/eval-tasks/${taskId}`)
|
||||
}
|
||||
|
||||
export default request
|
||||
423
frontend/src/components/common/CreateProjectDialog.vue
Normal file
423
frontend/src/components/common/CreateProjectDialog.vue
Normal file
@@ -0,0 +1,423 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
:model-value="visible"
|
||||
title=""
|
||||
width="480px"
|
||||
class="create-dialog"
|
||||
:show-close="false"
|
||||
align-center
|
||||
@update:model-value="$emit('update:visible', $event)"
|
||||
>
|
||||
<template #header>
|
||||
<div class="dialog-header">
|
||||
<div class="header-glow"></div>
|
||||
<div class="header-content">
|
||||
<div class="dialog-icon-new">
|
||||
<el-icon size="24"><FolderAdd /></el-icon>
|
||||
</div>
|
||||
<div class="header-text">
|
||||
<h3>创建新项目</h3>
|
||||
<p>开始构建您的AI训练数据集</p>
|
||||
</div>
|
||||
</div>
|
||||
<button class="close-btn" @click="handleClose">
|
||||
<el-icon><Close /></el-icon>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<div class="dialog-body">
|
||||
<div class="form-card">
|
||||
<div class="input-group">
|
||||
<label class="input-label">
|
||||
<span class="label-icon">✦</span>
|
||||
项目名称
|
||||
</label>
|
||||
<el-input
|
||||
v-model="formData.name"
|
||||
placeholder="例如:客服问答数据集"
|
||||
size="large"
|
||||
class="custom-input"
|
||||
/>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label class="input-label">
|
||||
<span class="label-icon">✦</span>
|
||||
项目描述
|
||||
</label>
|
||||
<el-input
|
||||
v-model="formData.description"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="描述这个项目的用途和数据类型..."
|
||||
class="custom-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Templates -->
|
||||
<div class="templates-section">
|
||||
<span class="templates-label">快速开始模板</span>
|
||||
<div class="templates-grid">
|
||||
<div class="template-card" @click="useTemplate('qa')">
|
||||
<el-icon><ChatDotRound /></el-icon>
|
||||
<span>问答对</span>
|
||||
</div>
|
||||
<div class="template-card" @click="useTemplate('conversation')">
|
||||
<el-icon><ChatLineRound /></el-icon>
|
||||
<span>对话</span>
|
||||
</div>
|
||||
<div class="template-card" @click="useTemplate('instruction')">
|
||||
<el-icon><Promotion /></el-icon>
|
||||
<span>指令</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button @click="handleClose" class="btn-cancel">取消</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="loading"
|
||||
@click="handleSubmit"
|
||||
class="btn-create"
|
||||
>
|
||||
<el-icon v-if="!loading"><Plus /></el-icon>
|
||||
创建项目
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:visible', 'submit'])
|
||||
|
||||
const formData = reactive({
|
||||
name: '',
|
||||
description: ''
|
||||
})
|
||||
|
||||
const templates = {
|
||||
qa: { name: '问答数据集', description: '基于文档生成问答对训练数据' },
|
||||
conversation: { name: '对话数据集', description: '创建多轮对话训练数据' },
|
||||
instruction: { name: '指令数据集', description: '构建指令跟随训练数据' }
|
||||
}
|
||||
|
||||
const useTemplate = (type) => {
|
||||
const t = templates[type]
|
||||
formData.name = t.name
|
||||
formData.description = t.description
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
emit('update:visible', false)
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
emit('submit', { ...formData })
|
||||
}
|
||||
|
||||
// Reset form when dialog opens
|
||||
watch(() => props.visible, (newVal) => {
|
||||
if (newVal) {
|
||||
formData.name = ''
|
||||
formData.description = ''
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.create-dialog :deep(.el-dialog) {
|
||||
border-radius: 20px;
|
||||
background: linear-gradient(145deg, rgba(20, 20, 30, 0.98) 0%, rgba(15, 15, 25, 0.98) 100%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
box-shadow:
|
||||
0 25px 50px -12px rgba(0, 0, 0, 0.6),
|
||||
0 0 40px rgba(0, 212, 255, 0.1),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.create-dialog :deep(.el-dialog__header) {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
position: relative;
|
||||
padding: 24px 28px;
|
||||
background: linear-gradient(135deg, rgba(0, 212, 255, 0.08) 0%, rgba(124, 58, 237, 0.08) 100%);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header-glow {
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
left: -50%;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
background: radial-gradient(circle at 30% 50%, rgba(0, 212, 255, 0.15) 0%, transparent 50%);
|
||||
animation: headerGlow 4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes headerGlow {
|
||||
0%, 100% { opacity: 0.5; transform: scale(1); }
|
||||
50% { opacity: 0.8; transform: scale(1.1); }
|
||||
}
|
||||
|
||||
.header-content {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.dialog-icon-new {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, rgba(0, 212, 255, 0.2) 0%, rgba(124, 58, 237, 0.2) 100%);
|
||||
border: 1px solid rgba(0, 212, 255, 0.3);
|
||||
border-radius: 14px;
|
||||
color: var(--accent-primary);
|
||||
animation: iconPulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes iconPulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(0, 212, 255, 0.4); }
|
||||
50% { box-shadow: 0 0 20px 5px rgba(0, 212, 255, 0.2); }
|
||||
}
|
||||
|
||||
.header-text h3 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 4px 0;
|
||||
}
|
||||
|
||||
.header-text p {
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 8px;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: var(--text-primary);
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
/* Dialog Body */
|
||||
.create-dialog :deep(.el-dialog__body) {
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
.dialog-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.form-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
padding: 24px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.input-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.label-icon {
|
||||
color: var(--accent-primary);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.custom-input :deep(.el-input__wrapper) {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 10px;
|
||||
box-shadow: none;
|
||||
padding: 4px 14px;
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
|
||||
.custom-input :deep(.el-input__wrapper:hover) {
|
||||
border-color: rgba(0, 212, 255, 0.3);
|
||||
}
|
||||
|
||||
.custom-input :deep(.el-input__wrapper.is-focus) {
|
||||
border-color: var(--accent-primary);
|
||||
box-shadow: 0 0 0 3px rgba(0, 212, 255, 0.15);
|
||||
}
|
||||
|
||||
.custom-input :deep(.el-input__inner) {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.custom-input :deep(.el-input__inner::placeholder) {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.custom-input :deep(.el-textarea__inner) {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 10px;
|
||||
box-shadow: none;
|
||||
padding: 12px 14px;
|
||||
color: var(--text-primary);
|
||||
resize: none;
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
|
||||
.custom-input :deep(.el-textarea__inner:hover) {
|
||||
border-color: rgba(0, 212, 255, 0.3);
|
||||
}
|
||||
|
||||
.custom-input :deep(.el-textarea__inner:focus) {
|
||||
border-color: var(--accent-primary);
|
||||
box-shadow: 0 0 0 3px rgba(0, 212, 255, 0.15);
|
||||
}
|
||||
|
||||
/* Templates Section */
|
||||
.templates-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.templates-label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.templates-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.template-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 16px 12px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
|
||||
.template-card:hover {
|
||||
background: rgba(0, 212, 255, 0.08);
|
||||
border-color: rgba(0, 212, 255, 0.25);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.template-card .el-icon {
|
||||
font-size: 22px;
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.template-card span {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Dialog Footer */
|
||||
.create-dialog :deep(.el-dialog__footer) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
padding: 20px 28px;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
padding: 10px 20px;
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 10px;
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-cancel:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-create {
|
||||
padding: 10px 24px;
|
||||
background: linear-gradient(135deg, var(--accent-primary) 0%, #0891b2 100%);
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-weight: 500;
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
|
||||
.btn-create:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 15px rgba(0, 212, 255, 0.35);
|
||||
}
|
||||
</style>
|
||||
188
frontend/src/components/common/DeleteDialog.vue
Normal file
188
frontend/src/components/common/DeleteDialog.vue
Normal file
@@ -0,0 +1,188 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
:model-value="visible"
|
||||
title=""
|
||||
width="420px"
|
||||
class="delete-dialog"
|
||||
:show-close="false"
|
||||
align-center
|
||||
:close-on-click-modal="false"
|
||||
@update:model-value="$emit('update:visible', $event)"
|
||||
>
|
||||
<template #header>
|
||||
<div class="delete-dialog-header">
|
||||
<div class="delete-icon-wrapper">
|
||||
<el-icon size="28"><WarningFilled /></el-icon>
|
||||
</div>
|
||||
<h3>{{ title }}</h3>
|
||||
</div>
|
||||
</template>
|
||||
<div class="delete-dialog-body">
|
||||
<p>
|
||||
确定要删除 <strong>{{ itemName }}</strong> 吗?
|
||||
</p>
|
||||
<p class="warning-text">{{ warningText }}</p>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="delete-dialog-footer">
|
||||
<el-button
|
||||
@click="handleCancel"
|
||||
class="btn-cancel-delete"
|
||||
>
|
||||
取消
|
||||
</el-button>
|
||||
<el-button
|
||||
type="danger"
|
||||
:loading="loading"
|
||||
@click="handleConfirm"
|
||||
class="btn-delete"
|
||||
>
|
||||
<el-icon v-if="!loading"><Delete /></el-icon>
|
||||
确认删除
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: '删除项目'
|
||||
},
|
||||
itemName: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
warningText: {
|
||||
type: String,
|
||||
default: '此操作不可恢复,所有相关数据将被永久删除'
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:visible', 'confirm', 'cancel'])
|
||||
|
||||
const handleConfirm = () => {
|
||||
emit('confirm')
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
emit('update:visible', false)
|
||||
emit('cancel')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.delete-dialog :deep(.el-dialog) {
|
||||
background: linear-gradient(145deg, #1a1a2e 0%, #16162a 100%);
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.5), 0 0 30px rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
.delete-dialog :deep(.el-dialog__header) {
|
||||
padding: 24px 24px 12px;
|
||||
}
|
||||
|
||||
.delete-dialog :deep(.el-dialog__body) {
|
||||
padding: 0 24px 24px;
|
||||
}
|
||||
|
||||
.delete-dialog :deep(.el-dialog__footer) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.delete-dialog-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.delete-icon-wrapper {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, rgba(239, 68, 68, 0.2) 0%, rgba(239, 68, 68, 0.1) 100%);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
border-radius: 50%;
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.delete-dialog-header h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.delete-dialog-body {
|
||||
text-align: center;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.delete-dialog-body p {
|
||||
margin: 0;
|
||||
color: #ccc;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.delete-dialog-body p strong {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.delete-dialog-body .warning-text {
|
||||
margin-top: 8px;
|
||||
color: #ef4444;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.delete-dialog-footer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 20px 24px;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.btn-cancel-delete {
|
||||
padding: 10px 24px;
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 10px;
|
||||
color: #ccc;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-cancel-delete:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-delete {
|
||||
padding: 10px 24px;
|
||||
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-weight: 500;
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
|
||||
.btn-delete:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 15px rgba(239, 68, 68, 0.4);
|
||||
}
|
||||
</style>
|
||||
96
frontend/src/components/common/EmptyState.vue
Normal file
96
frontend/src/components/common/EmptyState.vue
Normal file
@@ -0,0 +1,96 @@
|
||||
<template>
|
||||
<div class="empty-state">
|
||||
<div class="empty-illustration">
|
||||
<div class="circle-1"></div>
|
||||
<div class="circle-2"></div>
|
||||
<div class="circle-3"></div>
|
||||
<el-icon size="48"><component :is="icon" /></el-icon>
|
||||
</div>
|
||||
<h3>{{ title }}</h3>
|
||||
<p>{{ description }}</p>
|
||||
<el-button v-if="actionText" type="primary" @click="$emit('action')">
|
||||
{{ actionText }}
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
icon: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: '暂无数据'
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: '暂无相关内容'
|
||||
},
|
||||
actionText: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
defineEmits(['action'])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.empty-state {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 80px 40px;
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(20px);
|
||||
border: 1px dashed var(--border-default);
|
||||
border-radius: var(--radius-xl);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-illustration {
|
||||
position: relative;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.empty-illustration .el-icon {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.circle-1, .circle-2, .circle-3 {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
.circle-1 { width: 100%; height: 100%; animation: rotate 20s linear infinite; }
|
||||
.circle-2 { width: 70%; height: 70%; animation: rotate 15s linear infinite reverse; }
|
||||
.circle-3 { width: 40%; height: 40%; background: var(--bg-tertiary); }
|
||||
|
||||
@keyframes rotate {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
font-size: 20px;
|
||||
margin-bottom: 8px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
color: var(--text-tertiary);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
</style>
|
||||
202
frontend/src/components/common/ProjectCard.vue
Normal file
202
frontend/src/components/common/ProjectCard.vue
Normal file
@@ -0,0 +1,202 @@
|
||||
<template>
|
||||
<div
|
||||
class="project-card"
|
||||
:style="{ '--delay': delay }"
|
||||
@click="$emit('click', project)"
|
||||
>
|
||||
<div class="card-glow"></div>
|
||||
<button
|
||||
v-if="showDelete"
|
||||
class="card-delete-btn"
|
||||
@click.stop="$emit('delete', project)"
|
||||
>
|
||||
<el-icon><Delete /></el-icon>
|
||||
</button>
|
||||
<div class="card-header">
|
||||
<div class="card-avatar">
|
||||
<el-icon><Folder /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="card-title">{{ project.name }}</h3>
|
||||
<p class="card-desc">{{ project.description || '暂无描述' }}</p>
|
||||
<div class="card-footer">
|
||||
<span class="card-date">
|
||||
<el-icon><Calendar /></el-icon>
|
||||
{{ formattedDate }}
|
||||
</span>
|
||||
<div class="card-status">
|
||||
<span class="status-dot"></span>
|
||||
{{ statusText }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
project: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
index: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
showDelete: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
statusText: {
|
||||
type: String,
|
||||
default: '活跃'
|
||||
}
|
||||
})
|
||||
|
||||
defineEmits(['click', 'delete'])
|
||||
|
||||
const delay = computed(() => `${props.index * 0.1}s`)
|
||||
|
||||
const formattedDate = computed(() => {
|
||||
if (!props.project.created_at) return ''
|
||||
const d = new Date(props.project.created_at)
|
||||
return d.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.project-card {
|
||||
position: relative;
|
||||
padding: 24px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-base);
|
||||
overflow: hidden;
|
||||
animation: cardFadeIn 0.5s ease backwards;
|
||||
animation-delay: var(--delay);
|
||||
}
|
||||
|
||||
@keyframes cardFadeIn {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.project-card:hover {
|
||||
border-color: var(--accent-primary);
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.card-glow {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, var(--accent-primary), transparent);
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition-base);
|
||||
}
|
||||
|
||||
.project-card:hover .card-glow { opacity: 1; }
|
||||
|
||||
.card-delete-btn {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: var(--danger);
|
||||
border: none;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transform: scale(0.8);
|
||||
transition: all 0.2s ease;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.card-delete-btn:hover {
|
||||
transform: scale(1.1);
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
.project-card:hover .card-delete-btn {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.card-avatar {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--accent-primary-muted);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 20px;
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.card-desc {
|
||||
font-size: 14px;
|
||||
color: var(--text-tertiary);
|
||||
line-height: 1.5;
|
||||
margin-bottom: 20px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.card-date {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.card-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: var(--success);
|
||||
border-radius: 50%;
|
||||
}
|
||||
</style>
|
||||
@@ -48,9 +48,9 @@ const routes = [
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/playground',
|
||||
name: 'Playground',
|
||||
component: () => import('@/views/PlaygroundView.vue')
|
||||
path: '/models',
|
||||
name: 'ModelSettings',
|
||||
component: () => import('@/views/ModelSettingsView.vue')
|
||||
},
|
||||
{
|
||||
path: '/data-square',
|
||||
|
||||
426
frontend/src/styles/home.scss
Normal file
426
frontend/src/styles/home.scss
Normal file
@@ -0,0 +1,426 @@
|
||||
/* ========================
|
||||
HomeView Styles
|
||||
======================== */
|
||||
|
||||
.home {
|
||||
min-height: 100vh;
|
||||
padding: 60px 40px 80px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Hero Section */
|
||||
.hero {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 60px;
|
||||
align-items: center;
|
||||
margin-bottom: 60px;
|
||||
}
|
||||
|
||||
.hero-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 14px;
|
||||
background: var(--accent-primary-muted);
|
||||
border: 1px solid rgba(0, 212, 255, 0.2);
|
||||
border-radius: 100px;
|
||||
font-size: 13px;
|
||||
color: var(--accent-primary);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.badge-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: var(--accent-primary);
|
||||
border-radius: 50%;
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.5; transform: scale(1.2); }
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 56px;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.03em;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
font-size: 18px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 32px;
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
.hero-actions {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
padding: 14px 28px;
|
||||
font-size: 15px;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
padding: 14px 28px;
|
||||
font-size: 15px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-default);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
/* ========================
|
||||
Hero Visual - 全息粒子矩阵
|
||||
======================== */
|
||||
.hero-visual {
|
||||
position: relative;
|
||||
height: 420px;
|
||||
perspective: 1000px;
|
||||
}
|
||||
|
||||
/* 全息卡片基础样式 */
|
||||
.hologram-card {
|
||||
position: absolute;
|
||||
width: 180px;
|
||||
padding: 24px 20px;
|
||||
background: linear-gradient(135deg, rgba(20, 20, 30, 0.9) 0%, rgba(10, 10, 18, 0.95) 100%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 20px;
|
||||
backdrop-filter: blur(20px);
|
||||
cursor: pointer;
|
||||
transition: all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
transform-style: preserve-3d;
|
||||
animation: hologramFloat 6s ease-in-out infinite;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 卡片位置 */
|
||||
.hologram-card.card-1 {
|
||||
top: 10px;
|
||||
right: 60px;
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
.hologram-card.card-2 {
|
||||
top: 130px;
|
||||
left: 30px;
|
||||
animation-delay: -2s;
|
||||
}
|
||||
|
||||
.hologram-card.card-3 {
|
||||
bottom: 20px;
|
||||
right: 80px;
|
||||
animation-delay: -4s;
|
||||
}
|
||||
|
||||
@keyframes hologramFloat {
|
||||
0%, 100% { transform: translateY(0) rotateX(0) rotateY(0); }
|
||||
25% { transform: translateY(-8px) rotateX(2deg) rotateY(-2deg); }
|
||||
50% { transform: translateY(0) rotateX(0) rotateY(0); }
|
||||
75% { transform: translateY(-5px) rotateX(-1deg) rotateY(2deg); }
|
||||
}
|
||||
|
||||
/* 悬浮时的3D效果 */
|
||||
.hologram-card:hover {
|
||||
transform: translateY(-15px) scale(1.05);
|
||||
border-color: rgba(0, 212, 255, 0.4);
|
||||
box-shadow:
|
||||
0 25px 50px -12px rgba(0, 0, 0, 0.5),
|
||||
0 0 30px rgba(0, 212, 255, 0.15),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.hologram-card:hover .scan-line {
|
||||
animation: scanMove 1.5s linear infinite;
|
||||
}
|
||||
|
||||
.hologram-card:hover .pulse-ring {
|
||||
animation: pulseRing 2s ease-out infinite;
|
||||
}
|
||||
|
||||
.hologram-card:hover .particle {
|
||||
animation: particleBurst 1s ease-out forwards;
|
||||
animation-delay: calc(var(--i, 0) * 0.1s);
|
||||
}
|
||||
|
||||
/* 卡片背景 */
|
||||
.card-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(ellipse at top, rgba(0, 212, 255, 0.08) 0%, transparent 50%),
|
||||
radial-gradient(ellipse at bottom right, rgba(124, 58, 237, 0.08) 0%, transparent 50%);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.card-2 .card-bg {
|
||||
background: radial-gradient(ellipse at top, rgba(124, 58, 237, 0.12) 0%, transparent 50%),
|
||||
radial-gradient(ellipse at bottom right, rgba(0, 212, 255, 0.06) 0%, transparent 50%);
|
||||
}
|
||||
|
||||
.card-3 .card-bg {
|
||||
background: radial-gradient(ellipse at top, rgba(6, 182, 212, 0.12) 0%, transparent 50%),
|
||||
radial-gradient(ellipse at bottom right, rgba(124, 58, 237, 0.06) 0%, transparent 50%);
|
||||
}
|
||||
|
||||
/* 扫描线效果 */
|
||||
.scan-line {
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
left: -50%;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
background: linear-gradient(
|
||||
transparent 0%,
|
||||
rgba(0, 212, 255, 0.03) 50%,
|
||||
transparent 100%
|
||||
);
|
||||
transform: rotate(30deg);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes scanMove {
|
||||
0% { transform: translateY(-100%) rotate(30deg); }
|
||||
100% { transform: translateY(100%) rotate(30deg); }
|
||||
}
|
||||
|
||||
/* 粒子容器 */
|
||||
.particles-container {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.particle {
|
||||
position: absolute;
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
border-radius: 50%;
|
||||
opacity: 0;
|
||||
left: var(--x);
|
||||
top: var(--y);
|
||||
}
|
||||
|
||||
.card-1 .particle {
|
||||
background: var(--accent-primary);
|
||||
box-shadow: 0 0 6px var(--accent-primary);
|
||||
}
|
||||
|
||||
.card-2 .particle {
|
||||
background: var(--accent-secondary);
|
||||
box-shadow: 0 0 6px var(--accent-secondary);
|
||||
}
|
||||
|
||||
.card-3 .particle {
|
||||
background: var(--accent-tertiary);
|
||||
box-shadow: 0 0 6px var(--accent-tertiary);
|
||||
}
|
||||
|
||||
@keyframes particleBurst {
|
||||
0% { opacity: 0; transform: scale(0); }
|
||||
20% { opacity: 1; transform: scale(1); }
|
||||
100% { opacity: 0; transform: scale(2); }
|
||||
}
|
||||
|
||||
/* 脉动光环 */
|
||||
.pulse-ring {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
transform: translate(-50%, -50%);
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(0, 212, 255, 0.3);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.card-2 .pulse-ring { border-color: rgba(124, 58, 237, 0.3); }
|
||||
.card-3 .pulse-ring { border-color: rgba(6, 182, 212, 0.3); }
|
||||
|
||||
@keyframes pulseRing {
|
||||
0% { opacity: 0.6; transform: translate(-50%, -50%) scale(0.5); }
|
||||
100% { opacity: 0; transform: translate(-50%, -50%) scale(2); }
|
||||
}
|
||||
|
||||
/* 卡片内容 */
|
||||
.card-content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* 图标包装器 */
|
||||
.icon-wrapper {
|
||||
position: relative;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 18px;
|
||||
background: linear-gradient(135deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0.02) 100%);
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.icon-wrapper.cyan {
|
||||
background: linear-gradient(135deg, rgba(0, 212, 255, 0.2) 0%, rgba(0, 212, 255, 0.05) 100%);
|
||||
border-color: rgba(0, 212, 255, 0.3);
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.icon-wrapper.violet {
|
||||
background: linear-gradient(135deg, rgba(124, 58, 237, 0.2) 0%, rgba(124, 58, 237, 0.05) 100%);
|
||||
border-color: rgba(124, 58, 237, 0.3);
|
||||
color: var(--accent-secondary);
|
||||
}
|
||||
|
||||
.icon-wrapper.teal {
|
||||
background: linear-gradient(135deg, rgba(6, 182, 212, 0.2) 0%, rgba(6, 182, 212, 0.05) 100%);
|
||||
border-color: rgba(6, 182, 212, 0.3);
|
||||
color: var(--accent-tertiary);
|
||||
}
|
||||
|
||||
/* 图标发光 */
|
||||
.icon-glow {
|
||||
position: absolute;
|
||||
inset: -2px;
|
||||
border-radius: 20px;
|
||||
background: inherit;
|
||||
filter: blur(15px);
|
||||
opacity: 0.5;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.hologram-card:hover .icon-wrapper {
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 0 30px rgba(0, 212, 255, 0.4);
|
||||
}
|
||||
|
||||
.hologram-card:hover .icon-glow { opacity: 0.8; }
|
||||
|
||||
/* 标签文字 */
|
||||
.card-label {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.card-sublabel {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* 响应式 */
|
||||
@media (max-width: 1200px) {
|
||||
.hero-visual { display: none; }
|
||||
}
|
||||
|
||||
/* Quick Actions */
|
||||
.quick-actions { margin-bottom: 50px; }
|
||||
|
||||
.action-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
padding: 24px;
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(20px);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-lg);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-base);
|
||||
}
|
||||
|
||||
.action-card:hover {
|
||||
border-color: var(--accent-primary);
|
||||
transform: translateX(6px);
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--accent-primary-muted);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 24px;
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.action-info h3 { font-size: 16px; font-weight: 600; margin-bottom: 4px; }
|
||||
.action-info p { font-size: 14px; color: var(--text-tertiary); }
|
||||
|
||||
.action-arrow {
|
||||
margin-left: auto;
|
||||
font-size: 20px;
|
||||
color: var(--text-muted);
|
||||
transition: transform var(--transition-base);
|
||||
}
|
||||
|
||||
.action-card:hover .action-arrow {
|
||||
transform: translateX(4px);
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
/* Projects Section */
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.section-title h2 { font-size: 24px; font-weight: 600; margin-bottom: 4px; }
|
||||
.section-title p { font-size: 14px; color: var(--text-tertiary); }
|
||||
|
||||
.add-btn { padding: 10px 18px; border-radius: var(--radius-md); }
|
||||
|
||||
.projects-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 1024px) {
|
||||
.hero {
|
||||
grid-template-columns: 1fr;
|
||||
text-align: center;
|
||||
}
|
||||
.hero-subtitle { max-width: 100%; }
|
||||
.hero-actions { justify-content: center; }
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.home { padding: 40px 20px 60px; }
|
||||
.hero-title { font-size: 36px; }
|
||||
.hero-actions { flex-direction: column; }
|
||||
.projects-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
32
frontend/src/types/api.d.ts
vendored
Normal file
32
frontend/src/types/api.d.ts
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* API Response Types
|
||||
*/
|
||||
|
||||
// Base API response wrapper
|
||||
export interface ApiResponse<T = any> {
|
||||
success: boolean
|
||||
message: string
|
||||
data: T
|
||||
error: string | null
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
// Paginated response
|
||||
export interface PaginatedResponse<T = any> extends ApiResponse<T> {
|
||||
page?: number
|
||||
page_size?: number
|
||||
total?: number
|
||||
}
|
||||
|
||||
// List items wrapper
|
||||
export interface ListResponse<T> {
|
||||
items: T[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
|
||||
// Simple ID response
|
||||
export interface IdResponse {
|
||||
id: string
|
||||
}
|
||||
60
frontend/src/types/common.d.ts
vendored
Normal file
60
frontend/src/types/common.d.ts
vendored
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Common Types
|
||||
*/
|
||||
|
||||
// File types
|
||||
export interface FileItem {
|
||||
id: string
|
||||
filename: string
|
||||
file_type: string
|
||||
size?: number
|
||||
status: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
// Chunk types
|
||||
export interface Chunk {
|
||||
id: string
|
||||
name?: string
|
||||
content: string
|
||||
summary?: string
|
||||
word_count?: number
|
||||
file_id?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
// Question types
|
||||
export interface Question {
|
||||
id: string
|
||||
content: string
|
||||
answer?: string
|
||||
question_type?: string
|
||||
chunk_id?: string
|
||||
source: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
// Dataset types
|
||||
export interface Dataset {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
dataset_type?: string
|
||||
question_count?: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
// Dialog props
|
||||
export interface DialogProps {
|
||||
visible: boolean
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export interface DeleteDialogProps extends DialogProps {
|
||||
itemName?: string
|
||||
itemType?: string
|
||||
}
|
||||
7
frontend/src/types/index.ts
Normal file
7
frontend/src/types/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Type exports
|
||||
*/
|
||||
export * from './api'
|
||||
export * from './project'
|
||||
export * from './model'
|
||||
export * from './common'
|
||||
30
frontend/src/types/model.d.ts
vendored
Normal file
30
frontend/src/types/model.d.ts
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Model Configuration Types
|
||||
*/
|
||||
|
||||
export interface ModelConfig {
|
||||
id: string
|
||||
provider: ModelProvider
|
||||
model_name: string
|
||||
api_key?: string
|
||||
api_base?: string
|
||||
is_default: 'true' | 'false'
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
export type ModelProvider = 'openai' | 'anthropic' | 'google' | 'other'
|
||||
|
||||
export interface ModelCreate {
|
||||
provider: ModelProvider
|
||||
model_name: string
|
||||
api_key: string
|
||||
api_base?: string
|
||||
is_default: boolean
|
||||
}
|
||||
|
||||
export interface ProviderOption {
|
||||
value: ModelProvider
|
||||
label: string
|
||||
abbr: string
|
||||
}
|
||||
21
frontend/src/types/project.d.ts
vendored
Normal file
21
frontend/src/types/project.d.ts
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Project Types
|
||||
*/
|
||||
|
||||
export interface Project {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface ProjectCreate {
|
||||
name: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface ProjectUpdate {
|
||||
name?: string
|
||||
description?: string
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
975
frontend/src/views/ModelSettingsView.vue
Normal file
975
frontend/src/views/ModelSettingsView.vue
Normal file
@@ -0,0 +1,975 @@
|
||||
<template>
|
||||
<div class="model-settings">
|
||||
<!-- 背景效果 -->
|
||||
<div class="bg-effects">
|
||||
<div class="glow-orb glow-1"></div>
|
||||
<div class="glow-orb glow-2"></div>
|
||||
</div>
|
||||
|
||||
<!-- 页面头部 -->
|
||||
<header class="page-header">
|
||||
<div class="header-left">
|
||||
<el-button text class="back-btn" @click="goHome">
|
||||
<el-icon><ArrowLeft /></el-icon>
|
||||
<span>返回</span>
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="header-content">
|
||||
<h1 class="page-title">
|
||||
<el-icon class="title-icon"><Cpu /></el-icon>
|
||||
模型配置
|
||||
</h1>
|
||||
<p class="page-subtitle">管理您的 AI 模型 API 配置</p>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<el-button type="primary" class="add-btn" @click="openAddDialog">
|
||||
<el-icon><Plus /></el-icon>
|
||||
<span>添加模型</span>
|
||||
</el-button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 主内容 -->
|
||||
<main class="page-main">
|
||||
<!-- 统计卡片 -->
|
||||
<section class="stats-grid">
|
||||
<div class="stat-card" v-for="stat in stats" :key="stat.label">
|
||||
<div class="stat-icon" :class="stat.class">
|
||||
{{ stat.icon }}
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<span class="stat-value">{{ stat.value }}</span>
|
||||
<span class="stat-label">{{ stat.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 模型列表 -->
|
||||
<section class="models-section">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">
|
||||
<span class="title-line"></span>
|
||||
已配置的模型
|
||||
</h2>
|
||||
<span class="count-badge">{{ models.length }} 个</span>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-if="models.length === 0 && !loading" class="empty-state">
|
||||
<div class="empty-illustration">
|
||||
<div class="pulse-ring"></div>
|
||||
<el-icon size="48"><Setting /></el-icon>
|
||||
</div>
|
||||
<h3>暂无模型配置</h3>
|
||||
<p>添加您的第一个 AI 模型开始使用</p>
|
||||
<el-button type="primary" @click="openAddDialog">添加模型</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 模型卡片 -->
|
||||
<div v-else class="models-grid">
|
||||
<article
|
||||
v-for="(model, index) in models"
|
||||
:key="model.id"
|
||||
class="model-card"
|
||||
:class="{ 'is-default': model.is_default === 'true' }"
|
||||
:style="{ '--delay': index * 0.08 + 's' }"
|
||||
>
|
||||
<div class="card-glow"></div>
|
||||
|
||||
<!-- 默认标识 -->
|
||||
<div v-if="model.is_default === 'true'" class="default-badge">
|
||||
<el-icon><Star /></el-icon>
|
||||
默认
|
||||
</div>
|
||||
|
||||
<!-- 提供商图标 -->
|
||||
<div class="provider-logo" :class="model.provider">
|
||||
{{ getProviderAbbr(model.provider) }}
|
||||
</div>
|
||||
|
||||
<!-- 模型信息 -->
|
||||
<div class="model-info">
|
||||
<h3 class="model-name">{{ model.model_name }}</h3>
|
||||
<p class="model-endpoint">
|
||||
<el-icon><Link /></el-icon>
|
||||
{{ model.api_base || '默认端点' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 底部操作 -->
|
||||
<div class="card-footer">
|
||||
<div class="status-badge">
|
||||
<span class="status-dot online"></span>
|
||||
已配置
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<el-tooltip content="测试连接" placement="top">
|
||||
<el-button text circle class="action-btn" @click="testConnection(model)">
|
||||
<el-icon><Connection /></el-icon>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="删除" placement="top">
|
||||
<el-button text circle class="action-btn delete" @click="confirmDelete(model)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- 添加模型弹窗 -->
|
||||
<el-dialog
|
||||
v-model="showAddDialog"
|
||||
:show-close="false"
|
||||
width="500"
|
||||
class="model-dialog"
|
||||
:append-to-body="true"
|
||||
>
|
||||
<template #header>
|
||||
<div class="dialog-header">
|
||||
<div class="dialog-icon">
|
||||
<el-icon size="20"><Plus /></el-icon>
|
||||
</div>
|
||||
<div class="dialog-title">
|
||||
<h3>添加模型</h3>
|
||||
<p>配置新的 AI 模型</p>
|
||||
</div>
|
||||
<button class="dialog-close" @click="showAddDialog = false">
|
||||
<el-icon><Close /></el-icon>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-form :model="modelForm" label-position="top" class="model-form">
|
||||
<!-- 提供商选择 -->
|
||||
<el-form-item label="选择提供商">
|
||||
<div class="provider-grid">
|
||||
<div
|
||||
v-for="provider in providers"
|
||||
:key="provider.value"
|
||||
class="provider-option"
|
||||
:class="{ active: modelForm.provider === provider.value }"
|
||||
@click="modelForm.provider = provider.value"
|
||||
>
|
||||
<span class="provider-abbr">{{ provider.abbr }}</span>
|
||||
<span class="provider-name">{{ provider.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 模型名称 -->
|
||||
<el-form-item label="模型名称" required>
|
||||
<el-input
|
||||
v-model="modelForm.model_name"
|
||||
placeholder="例如: gpt-4o-mini"
|
||||
size="large"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<!-- API Key -->
|
||||
<el-form-item label="API Key" required>
|
||||
<el-input
|
||||
v-model="modelForm.api_key"
|
||||
type="password"
|
||||
placeholder="输入 API Key"
|
||||
size="large"
|
||||
show-password
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<!-- API 地址 -->
|
||||
<el-form-item label="API 地址 (可选)">
|
||||
<el-input
|
||||
v-model="modelForm.api_base"
|
||||
placeholder="自定义 API 地址"
|
||||
size="large"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 默认开关 -->
|
||||
<el-form-item>
|
||||
<el-checkbox v-model="modelForm.is_default">
|
||||
设为默认模型
|
||||
</el-checkbox>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button @click="showAddDialog = false" size="large">取消</el-button>
|
||||
<el-button type="primary" @click="addModel" :loading="submitting" size="large">
|
||||
添加模型
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 删除确认弹窗 -->
|
||||
<el-dialog
|
||||
v-model="deleteDialogVisible"
|
||||
:show-close="false"
|
||||
width="400"
|
||||
class="delete-dialog"
|
||||
:append-to-body="true"
|
||||
>
|
||||
<template #header>
|
||||
<div class="delete-header">
|
||||
<div class="delete-icon">
|
||||
<el-icon size="24"><WarningFilled /></el-icon>
|
||||
</div>
|
||||
<h3>确认删除</h3>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="delete-content">
|
||||
<p>确定要删除模型 <strong>{{ modelToDelete?.model_name }}</strong> 吗?</p>
|
||||
<p class="warning-text">此操作不可恢复</p>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="delete-footer">
|
||||
<el-button @click="deleteDialogVisible = false" size="large">取消</el-button>
|
||||
<el-button type="danger" @click="handleDelete" :loading="deleting" size="large">
|
||||
确认删除
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import type { ModelConfig, ProviderOption, ModelCreate } from '@/types'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// 状态
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const deleting = ref(false)
|
||||
const showAddDialog = ref(false)
|
||||
const deleteDialogVisible = ref(false)
|
||||
const modelToDelete = ref<ModelConfig | null>(null)
|
||||
const models = ref<ModelConfig[]>([])
|
||||
|
||||
// 表单
|
||||
const modelForm = reactive<ModelCreate>({
|
||||
provider: 'openai',
|
||||
model_name: '',
|
||||
api_key: '',
|
||||
api_base: '',
|
||||
is_default: false
|
||||
})
|
||||
|
||||
// 提供商
|
||||
const providers: ProviderOption[] = [
|
||||
{ value: 'openai', label: 'OpenAI', abbr: 'OP' },
|
||||
{ value: 'anthropic', label: 'Anthropic', abbr: 'AN' },
|
||||
{ value: 'google', label: 'Google', abbr: 'GO' },
|
||||
{ value: 'other', label: '其他', abbr: 'OT' }
|
||||
]
|
||||
|
||||
// Mock
|
||||
const mockModels: ModelConfig[] = [
|
||||
{ id: '1', provider: 'openai', model_name: 'gpt-4o', api_base: 'https://api.openai.com/v1', is_default: 'true' },
|
||||
{ id: '2', provider: 'openai', model_name: 'gpt-4o-mini', api_base: 'https://api.openai.com/v1', is_default: 'false' },
|
||||
{ id: '3', provider: 'anthropic', model_name: 'claude-3-5-sonnet', api_base: 'https://api.anthropic.com', is_default: 'false' }
|
||||
]
|
||||
|
||||
// 统计
|
||||
const stats = computed(() => [
|
||||
{ label: 'OpenAI', value: models.value.filter(m => m.provider === 'openai').length, icon: 'OP', class: 'openai' },
|
||||
{ label: 'Anthropic', value: models.value.filter(m => m.provider === 'anthropic').length, icon: 'AN', class: 'anthropic' },
|
||||
{ label: 'Google', value: models.value.filter(m => m.provider === 'google').length, icon: 'GO', class: 'google' },
|
||||
{ label: '默认模型', value: models.value.find(m => m.is_default === 'true')?.model_name || '未设置', icon: '★', class: 'default' }
|
||||
])
|
||||
|
||||
// 方法
|
||||
const goHome = () => router.push('/')
|
||||
|
||||
const getProviderAbbr = (provider: string) => {
|
||||
const p = providers.find(p => p.value === provider)
|
||||
return p?.abbr || '?'
|
||||
}
|
||||
|
||||
const fetchModels = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
models.value = mockModels
|
||||
} catch {
|
||||
ElMessage.error('加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const openAddDialog = () => {
|
||||
modelForm.provider = 'openai'
|
||||
modelForm.model_name = ''
|
||||
modelForm.api_key = ''
|
||||
modelForm.api_base = ''
|
||||
modelForm.is_default = false
|
||||
showAddDialog.value = true
|
||||
}
|
||||
|
||||
const addModel = async () => {
|
||||
if (!modelForm.model_name || !modelForm.api_key) {
|
||||
ElMessage.warning('请填写模型名称和 API Key')
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
ElMessage.success('添加成功')
|
||||
showAddDialog.value = false
|
||||
fetchModels()
|
||||
} catch {
|
||||
ElMessage.error('添加失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const confirmDelete = (model: ModelConfig) => {
|
||||
modelToDelete.value = model
|
||||
deleteDialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
deleting.value = true
|
||||
try {
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
ElMessage.success('删除成功')
|
||||
deleteDialogVisible.value = false
|
||||
modelToDelete.value = null
|
||||
fetchModels()
|
||||
} catch {
|
||||
ElMessage.error('删除失败')
|
||||
} finally {
|
||||
deleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const testConnection = (model: ModelConfig) => {
|
||||
ElMessage.info(`测试 ${model.model_name}...`)
|
||||
}
|
||||
|
||||
onMounted(() => fetchModels())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 使用全局 CSS 变量 */
|
||||
.model-settings {
|
||||
min-height: 100vh;
|
||||
background: var(--bg-primary);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 背景效果 */
|
||||
.bg-effects {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.glow-orb {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(120px);
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.glow-1 {
|
||||
width: 500px;
|
||||
height: 500px;
|
||||
background: var(--accent-primary);
|
||||
top: -200px;
|
||||
right: -100px;
|
||||
}
|
||||
|
||||
.glow-2 {
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
background: var(--accent-secondary);
|
||||
bottom: -100px;
|
||||
left: -100px;
|
||||
}
|
||||
|
||||
/* 页面头部 */
|
||||
.page-header {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20px 32px;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.header-content {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.title-icon {
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary);
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
|
||||
.header-left, .header-right {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--text-secondary);
|
||||
padding: 8px 16px;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
background: var(--bg-hover) !important;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: var(--accent-primary);
|
||||
border: none;
|
||||
color: #030407;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.add-btn:hover {
|
||||
background: var(--accent-primary-hover);
|
||||
}
|
||||
|
||||
/* 主内容 */
|
||||
.page-main {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
/* 统计卡片 */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 20px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
border-color: var(--border-default);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.stat-icon.openai { background: linear-gradient(135deg, #10a37f, #0d8c6d); }
|
||||
.stat-icon.anthropic { background: linear-gradient(135deg, #d97757, #c45f3f); }
|
||||
.stat-icon.google { background: linear-gradient(135deg, #4285f4, #3367d6); }
|
||||
.stat-icon.default { background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary)); }
|
||||
|
||||
.stat-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* 模型列表 */
|
||||
.models-section {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.title-line {
|
||||
width: 4px;
|
||||
height: 18px;
|
||||
background: linear-gradient(180deg, var(--accent-primary), var(--accent-secondary));
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.count-badge {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
background: var(--bg-hover);
|
||||
padding: 6px 14px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 80px 20px;
|
||||
}
|
||||
|
||||
.empty-illustration {
|
||||
position: relative;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
margin: 0 auto 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.pulse-ring {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border: 2px solid var(--border-subtle);
|
||||
border-radius: 50%;
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { transform: scale(1); opacity: 1; }
|
||||
50% { transform: scale(1.1); opacity: 0.5; }
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
font-size: 18px;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
color: var(--text-tertiary);
|
||||
margin: 0 0 24px;
|
||||
}
|
||||
|
||||
/* 模型网格 */
|
||||
.models-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.model-card {
|
||||
position: relative;
|
||||
padding: 24px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
transition: all var(--transition-base);
|
||||
animation: fadeInUp 0.4s ease backwards;
|
||||
animation-delay: var(--delay);
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from { opacity: 0; transform: translateY(16px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.model-card:hover {
|
||||
border-color: var(--accent-primary-muted);
|
||||
transform: translateY(-4px);
|
||||
box-shadow: var(--glow-primary);
|
||||
}
|
||||
|
||||
.model-card.is-default {
|
||||
border-color: rgba(52, 211, 153, 0.3);
|
||||
}
|
||||
|
||||
.card-glow {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: var(--radius-lg);
|
||||
background: radial-gradient(circle at top right, var(--accent-primary-muted), transparent 60%);
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition-base);
|
||||
}
|
||||
|
||||
.model-card:hover .card-glow {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.default-badge {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--success);
|
||||
background: var(--success-muted);
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.provider-logo {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.provider-logo.openai { background: linear-gradient(135deg, #10a37f, #0d8c6d); }
|
||||
.provider-logo.anthropic { background: linear-gradient(135deg, #d97757, #c45f3f); }
|
||||
.provider-logo.google { background: linear-gradient(135deg, #4285f4, #3367d6); }
|
||||
.provider-logo.other { background: linear-gradient(135deg, #6b7280, #4b5563); }
|
||||
|
||||
.model-info {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.model-name {
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.model-endpoint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-muted);
|
||||
}
|
||||
|
||||
.status-dot.online {
|
||||
background: var(--success);
|
||||
box-shadow: 0 0 8px var(--success);
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
color: var(--text-tertiary);
|
||||
background: var(--bg-hover);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
|
||||
.action-btn.delete:hover {
|
||||
color: var(--danger);
|
||||
background: var(--danger-muted);
|
||||
}
|
||||
|
||||
/* 弹窗样式 */
|
||||
:deep(.model-dialog .el-dialog) {
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-xl);
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 20px 24px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.dialog-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--accent-primary);
|
||||
border-radius: var(--radius-md);
|
||||
color: #030407;
|
||||
}
|
||||
|
||||
.dialog-title h3 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dialog-title p {
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary);
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
|
||||
.dialog-close {
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dialog-close:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.provider-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.provider-option {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 16px 8px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.provider-option:hover {
|
||||
border-color: var(--border-default);
|
||||
}
|
||||
|
||||
.provider-option.active {
|
||||
border-color: var(--accent-primary);
|
||||
background: var(--accent-primary-muted);
|
||||
}
|
||||
|
||||
.provider-option .provider-abbr {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.provider-option .provider-name {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.provider-option.active .provider-name {
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
padding: 16px 24px;
|
||||
background: var(--bg-tertiary);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
/* 删除弹窗 */
|
||||
:deep(.delete-dialog .el-dialog) {
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--danger-muted);
|
||||
border-radius: var(--radius-xl);
|
||||
}
|
||||
|
||||
.delete-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.delete-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--danger-muted);
|
||||
border: 1px solid var(--danger-muted);
|
||||
border-radius: 50%;
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.delete-header h3 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.delete-content {
|
||||
text-align: center;
|
||||
padding: 0 24px 24px;
|
||||
}
|
||||
|
||||
.delete-content p {
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.delete-content p strong {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.warning-text {
|
||||
color: var(--danger) !important;
|
||||
font-size: 13px;
|
||||
margin-top: 8px !important;
|
||||
}
|
||||
|
||||
.delete-footer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 16px 24px;
|
||||
background: var(--bg-tertiary);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
/* 响应式 */
|
||||
@media (max-width: 768px) {
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.page-main {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.provider-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -76,7 +76,8 @@ const isActive = (path) => route.path.includes(path)
|
||||
const fetchProject = async () => {
|
||||
try {
|
||||
const res = await projectApi.get(id.value)
|
||||
project.value = res.data
|
||||
// New format: returns project directly
|
||||
project.value = res
|
||||
} catch (error) {
|
||||
ElMessage.error('加载项目失败')
|
||||
}
|
||||
|
||||
@@ -125,8 +125,9 @@ const prompts = reactive({
|
||||
const fetchProject = async () => {
|
||||
try {
|
||||
const res = await projectApi.get(projectId.value)
|
||||
projectInfo.name = res.data.name
|
||||
projectInfo.description = res.data.description || ''
|
||||
// New format: project directly in response
|
||||
projectInfo.name = res.name
|
||||
projectInfo.description = res.description || ''
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
|
||||
26
frontend/tsconfig.json
Normal file
26
frontend/tsconfig.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"baseUrl": ".",
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
11
frontend/tsconfig.node.json
Normal file
11
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -10,6 +10,7 @@ export default defineConfig({
|
||||
}
|
||||
},
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': {
|
||||
@@ -17,5 +18,8 @@ export default defineConfig({
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
},
|
||||
build: {
|
||||
assetsDir: 'assets'
|
||||
}
|
||||
})
|
||||
|
||||
71
start.sh
Executable file
71
start.sh
Executable file
@@ -0,0 +1,71 @@
|
||||
#!/bin/bash
|
||||
|
||||
# YG-Datasets 一键启动脚本
|
||||
|
||||
echo "========================================="
|
||||
echo " YG-Datasets 一键启动"
|
||||
echo "========================================="
|
||||
|
||||
# 颜色定义
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# 检查并创建必要目录
|
||||
echo -e "${YELLOW}[1/5] 检查环境...${NC}"
|
||||
mkdir -p backend/uploads
|
||||
mkdir -p frontend
|
||||
|
||||
# 创建并激活 Python 虚拟环境
|
||||
echo -e "${YELLOW}[2/5] 创建 Python 虚拟环境...${NC}"
|
||||
cd backend
|
||||
if [ ! -d "venv" ]; then
|
||||
python3 -m venv venv
|
||||
fi
|
||||
source venv/bin/activate
|
||||
|
||||
# 安装后端依赖
|
||||
echo -e "${YELLOW}[3/5] 安装后端依赖...${NC}"
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 安装前端依赖
|
||||
echo -e "${YELLOW}[4/5] 安装前端依赖...${NC}"
|
||||
cd ../frontend
|
||||
if [ ! -d "node_modules" ]; then
|
||||
npm install
|
||||
fi
|
||||
|
||||
# 启动后端
|
||||
echo -e "${YELLOW}[5/5] 启动服务...${NC}"
|
||||
cd ../backend
|
||||
echo -e "${GREEN}启动后端 (端口 8000)...${NC}"
|
||||
export HOST="0.0.0.0"
|
||||
export DATABASE_URL="sqlite+aiosqlite:///./ygdataset.db"
|
||||
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 &
|
||||
BACKEND_PID=$!
|
||||
|
||||
# 等待后端启动
|
||||
sleep 3
|
||||
|
||||
# 启动前端
|
||||
cd ../frontend
|
||||
echo -e "${GREEN}启动前端 (端口 3000)...${NC}"
|
||||
npm run dev -- --host 0.0.0.0 &
|
||||
FRONTEND_PID=$!
|
||||
|
||||
echo ""
|
||||
echo "========================================="
|
||||
echo -e " ${GREEN}启动完成!${NC}"
|
||||
echo "========================================="
|
||||
echo " 后端: http://localhost:8000"
|
||||
echo " API文档: http://localhost:8000/docs"
|
||||
echo " 前端: http://localhost:3000"
|
||||
echo ""
|
||||
echo " 按 Ctrl+C 停止所有服务"
|
||||
echo "========================================="
|
||||
|
||||
# 捕获 Ctrl+C 并停止所有服务
|
||||
trap "kill $BACKEND_PID $FRONTEND_PID 2>/dev/null; exit" INT TERM
|
||||
|
||||
# 等待
|
||||
wait
|
||||
Reference in New Issue
Block a user