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
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Stage 2: Dependencies
|
||||||
|
FROM base as deps
|
||||||
|
|
||||||
# Install system dependencies
|
# Install system dependencies
|
||||||
RUN apt-get update && apt-get install -y \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
build-essential \
|
build-essential \
|
||||||
libpq-dev \
|
libpq-dev \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Copy requirements
|
# Create virtual environment
|
||||||
COPY requirements.txt .
|
RUN python -m venv /opt/venv
|
||||||
|
ENV PATH="/opt/venv/bin:$PATH"
|
||||||
|
|
||||||
# Install Python dependencies
|
# Install Python dependencies
|
||||||
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
# Copy application
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# Create uploads directory
|
# Stage 3: Production
|
||||||
RUN mkdir -p uploads
|
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 port
|
||||||
EXPOSE 8000
|
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
|
# 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
|
Chunks API Router
|
||||||
"""
|
"""
|
||||||
|
import asyncio
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, Field
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.api.response import ApiResponse, PaginatedResponse
|
||||||
from app.core.database import get_db
|
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.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.text_splitter.splitter import get_splitter
|
||||||
from app.services.file_processor.pdf_processor import process_pdf
|
from app.services.file_processor.pdf_processor import process_pdf
|
||||||
from app.services.file_processor.docx_processor import process_docx
|
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()
|
router = APIRouter()
|
||||||
|
|
||||||
|
# Initialize CRUD
|
||||||
|
chunk_crud = CRUDBase(Chunk)
|
||||||
|
|
||||||
|
|
||||||
class SplitRequest(BaseModel):
|
class SplitRequest(BaseModel):
|
||||||
"""Request model for splitting text"""
|
"""Request model for splitting text"""
|
||||||
file_id: Optional[UUID] = None
|
file_id: UUID
|
||||||
method: str = "recursive"
|
method: str = "recursive"
|
||||||
chunk_size: int = 500
|
chunk_size: int = Field(500, ge=50, le=5000)
|
||||||
overlap: int = 50
|
overlap: int = Field(50, ge=0, le=500)
|
||||||
separator: Optional[str] = None
|
separator: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class ChunkListResponse(BaseModel):
|
async def process_file_by_type(file: File) -> str:
|
||||||
"""Response for chunk list"""
|
|
||||||
chunks: List[ChunkResponse]
|
|
||||||
total: int
|
|
||||||
|
|
||||||
|
|
||||||
def process_file_by_type(file: File) -> str:
|
|
||||||
"""Process file based on its type"""
|
"""Process file based on its type"""
|
||||||
if not file.file_path:
|
if not file.file_path:
|
||||||
raise HTTPException(status_code=400, detail="File path not found")
|
raise NotFoundException("File", file.id)
|
||||||
|
|
||||||
processors = {
|
processors = {
|
||||||
"pdf": process_pdf,
|
"pdf": process_pdf,
|
||||||
@@ -48,13 +51,17 @@ def process_file_by_type(file: File) -> str:
|
|||||||
processor = processors.get(file.file_type)
|
processor = processors.get(file.file_type)
|
||||||
if not processor:
|
if not processor:
|
||||||
# Return raw text for txt, md files
|
# Return raw text for txt, md files
|
||||||
with open(file.file_path, 'r', encoding='utf-8') as f:
|
loop = asyncio.get_event_loop()
|
||||||
return f.read()
|
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(
|
async def split_text(
|
||||||
project_id: UUID,
|
project_id: UUID,
|
||||||
request: SplitRequest,
|
request: SplitRequest,
|
||||||
@@ -62,22 +69,19 @@ async def split_text(
|
|||||||
):
|
):
|
||||||
"""Split text into chunks"""
|
"""Split text into chunks"""
|
||||||
# Get file
|
# Get file
|
||||||
if request.file_id:
|
result = await db.execute(
|
||||||
result = await db.execute(
|
select(File).where(File.id == request.file_id, File.project_id == project_id)
|
||||||
select(File).where(File.id == request.file_id, File.project_id == project_id)
|
)
|
||||||
)
|
file = result.scalar_one_or_none()
|
||||||
file = result.scalar_one_or_none()
|
if not file:
|
||||||
if not file:
|
raise NotFoundException("File", request.file_id)
|
||||||
raise HTTPException(status_code=404, detail="File not found")
|
|
||||||
|
|
||||||
# Process file
|
# Process file
|
||||||
text = process_file_by_type(file)
|
text = await process_file_by_type(file)
|
||||||
|
|
||||||
# Update file status
|
# Update file status
|
||||||
file.status = "processing"
|
file.status = "processing"
|
||||||
await db.commit()
|
await db.commit()
|
||||||
else:
|
|
||||||
raise HTTPException(status_code=400, detail="file_id is required")
|
|
||||||
|
|
||||||
# Split text
|
# Split text
|
||||||
kwargs = {"chunk_size": request.chunk_size, "overlap": request.overlap}
|
kwargs = {"chunk_size": request.chunk_size, "overlap": request.overlap}
|
||||||
@@ -106,77 +110,87 @@ async def split_text(
|
|||||||
file.status = "completed"
|
file.status = "completed"
|
||||||
await db.commit()
|
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(
|
async def list_chunks(
|
||||||
project_id: UUID,
|
project_id: UUID,
|
||||||
file_id: Optional[UUID] = Query(None),
|
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)
|
db: AsyncSession = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""List chunks for a project"""
|
"""List chunks for a project"""
|
||||||
query = select(Chunk).where(Chunk.project_id == project_id)
|
filters = {"project_id": project_id}
|
||||||
|
|
||||||
if file_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())
|
skip = (page - 1) * page_size
|
||||||
|
chunks, total = await chunk_crud.get_multi(
|
||||||
result = await db.execute(query)
|
db,
|
||||||
chunks = result.scalars().all()
|
skip=skip,
|
||||||
|
limit=page_size,
|
||||||
return {
|
filters=filters,
|
||||||
"chunks": [ChunkResponse.model_validate(c) for c in chunks],
|
order_by="created_at",
|
||||||
"total": len(chunks)
|
descending=True
|
||||||
}
|
)
|
||||||
|
|
||||||
|
chunk_responses = [ChunkResponse.model_validate(c) for c in chunks]
|
||||||
@router.get("/{chunk_id}", response_model=dict)
|
return PaginatedResponse.ok(
|
||||||
async def get_chunk(project_id: UUID, chunk_id: UUID, db: AsyncSession = Depends(get_db)):
|
items=chunk_responses,
|
||||||
"""Get chunk by ID"""
|
page=page,
|
||||||
result = await db.execute(
|
page_size=page_size,
|
||||||
select(Chunk).where(Chunk.id == chunk_id, Chunk.project_id == project_id)
|
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(
|
async def update_chunk(
|
||||||
project_id: UUID,
|
project_id: UUID,
|
||||||
chunk_id: UUID,
|
chunk_id: UUID,
|
||||||
chunk: ChunkCreate,
|
chunk: ChunkCreateSchema,
|
||||||
db: AsyncSession = Depends(get_db)
|
db: AsyncSession = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""Update chunk"""
|
"""Update chunk"""
|
||||||
result = await db.execute(
|
db_chunk = await chunk_crud.get(db, chunk_id)
|
||||||
select(Chunk).where(Chunk.id == chunk_id, Chunk.project_id == project_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)
|
@router.delete("/{chunk_id}", response_model=ApiResponse)
|
||||||
async def delete_chunk(project_id: UUID, chunk_id: UUID, db: AsyncSession = Depends(get_db)):
|
async def delete_chunk(
|
||||||
|
project_id: UUID,
|
||||||
|
chunk_id: UUID,
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
"""Delete chunk"""
|
"""Delete chunk"""
|
||||||
result = await db.execute(
|
chunk = await chunk_crud.get(db, chunk_id)
|
||||||
select(Chunk).where(Chunk.id == chunk_id, Chunk.project_id == project_id)
|
if not chunk or chunk.project_id != project_id:
|
||||||
)
|
raise NotFoundException("Chunk", chunk_id)
|
||||||
chunk = result.scalar_one_or_none()
|
|
||||||
if not chunk:
|
|
||||||
raise HTTPException(status_code=404, detail="Chunk not found")
|
|
||||||
|
|
||||||
await db.delete(chunk)
|
await chunk_crud.delete(db, chunk_id)
|
||||||
await db.commit()
|
return ApiResponse.ok(message="Chunk deleted successfully")
|
||||||
return {"message": "Chunk deleted successfully"}
|
|
||||||
|
|||||||
@@ -3,94 +3,107 @@ Datasets API Router
|
|||||||
"""
|
"""
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, Field
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, Query
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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.core.database import get_db
|
||||||
from app.models.models import Dataset, Question
|
from app.core.exceptions import NotFoundException
|
||||||
from app.schemas.base import DatasetCreate, DatasetResponse
|
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()
|
router = APIRouter()
|
||||||
|
|
||||||
|
# Initialize CRUD
|
||||||
|
dataset_crud = CRUDBase(Dataset)
|
||||||
|
|
||||||
|
|
||||||
class ExportRequest(BaseModel):
|
class ExportRequest(BaseModel):
|
||||||
"""Export request schema"""
|
"""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)
|
@router.get("", response_model=ApiResponse)
|
||||||
async def list_datasets(project_id: UUID, db: AsyncSession = Depends(get_db)):
|
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"""
|
"""List datasets for a project"""
|
||||||
result = await db.execute(
|
skip = (page - 1) * page_size
|
||||||
select(Dataset).where(Dataset.project_id == project_id).order_by(Dataset.created_at.desc())
|
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_responses = [DatasetResponse.model_validate(d) for d in datasets]
|
||||||
dataset_list = []
|
return PaginatedResponse.ok(
|
||||||
for dataset in datasets:
|
items=dataset_responses,
|
||||||
dataset_data = DatasetResponse.model_validate(dataset)
|
page=page,
|
||||||
# TODO: Count questions in dataset
|
page_size=page_size,
|
||||||
dataset_data.question_count = 0
|
total=total
|
||||||
dataset_list.append(dataset_data)
|
)
|
||||||
|
|
||||||
return {"datasets": dataset_list}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/", response_model=dict)
|
@router.post("", response_model=ApiResponse)
|
||||||
async def create_dataset(
|
async def create_dataset(
|
||||||
project_id: UUID,
|
project_id: UUID,
|
||||||
dataset: DatasetCreate,
|
dataset: DatasetCreateSchema,
|
||||||
db: AsyncSession = Depends(get_db)
|
db: AsyncSession = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""Create a new dataset"""
|
"""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)
|
db.add(db_dataset)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(db_dataset)
|
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(
|
async def get_dataset(
|
||||||
project_id: UUID,
|
project_id: UUID,
|
||||||
dataset_id: UUID,
|
dataset_id: UUID,
|
||||||
db: AsyncSession = Depends(get_db)
|
db: AsyncSession = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""Get dataset by ID"""
|
"""Get dataset by ID"""
|
||||||
result = await db.execute(
|
dataset = await dataset_crud.get(db, dataset_id)
|
||||||
select(Dataset).where(Dataset.id == dataset_id, Dataset.project_id == project_id)
|
if not dataset or dataset.project_id != project_id:
|
||||||
)
|
raise NotFoundException("Dataset", dataset_id)
|
||||||
dataset = result.scalar_one_or_none()
|
|
||||||
if not dataset:
|
|
||||||
raise HTTPException(status_code=404, detail="Dataset not found")
|
|
||||||
|
|
||||||
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(
|
async def delete_dataset(
|
||||||
project_id: UUID,
|
project_id: UUID,
|
||||||
dataset_id: UUID,
|
dataset_id: UUID,
|
||||||
db: AsyncSession = Depends(get_db)
|
db: AsyncSession = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""Delete dataset"""
|
"""Delete dataset"""
|
||||||
result = await db.execute(
|
dataset = await dataset_crud.get(db, dataset_id)
|
||||||
select(Dataset).where(Dataset.id == dataset_id, Dataset.project_id == project_id)
|
if not dataset or dataset.project_id != project_id:
|
||||||
)
|
raise NotFoundException("Dataset", dataset_id)
|
||||||
dataset = result.scalar_one_or_none()
|
|
||||||
if not dataset:
|
|
||||||
raise HTTPException(status_code=404, detail="Dataset not found")
|
|
||||||
|
|
||||||
await db.delete(dataset)
|
await dataset_crud.delete(db, dataset_id)
|
||||||
await db.commit()
|
return ApiResponse.ok(message="Dataset deleted successfully")
|
||||||
|
|
||||||
return {"message": "Dataset deleted successfully"}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{dataset_id}/export")
|
@router.post("/{dataset_id}/export", response_model=ApiResponse)
|
||||||
async def export_dataset(
|
async def export_dataset(
|
||||||
project_id: UUID,
|
project_id: UUID,
|
||||||
dataset_id: UUID,
|
dataset_id: UUID,
|
||||||
@@ -98,18 +111,9 @@ async def export_dataset(
|
|||||||
db: AsyncSession = Depends(get_db)
|
db: AsyncSession = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""Export dataset in specified format"""
|
"""Export dataset in specified format"""
|
||||||
# TODO: Implement actual export logic
|
dataset = await dataset_crud.get(db, dataset_id)
|
||||||
|
if not dataset or dataset.project_id != project_id:
|
||||||
# Get dataset
|
raise NotFoundException("Dataset", dataset_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")
|
|
||||||
|
|
||||||
# Get questions for this dataset (placeholder)
|
|
||||||
# In real implementation, would link questions to datasets
|
|
||||||
|
|
||||||
# Return sample data based on format
|
# Return sample data based on format
|
||||||
sample_data = [
|
sample_data = [
|
||||||
@@ -121,6 +125,9 @@ async def export_dataset(
|
|||||||
]
|
]
|
||||||
|
|
||||||
if request.format == "json":
|
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
|
Evaluation API Router
|
||||||
"""
|
"""
|
||||||
from typing import List, Optional
|
from typing import Optional
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, Field
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, Query
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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.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.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()
|
router = APIRouter()
|
||||||
|
|
||||||
|
# Initialize CRUD
|
||||||
|
eval_crud = CRUDBase(EvalDataset)
|
||||||
|
task_crud = CRUDBase(Task)
|
||||||
|
|
||||||
|
|
||||||
class GenerateEvalRequest(BaseModel):
|
class GenerateEvalRequest(BaseModel):
|
||||||
"""Request for generating evaluation dataset"""
|
"""Request for generating evaluation dataset"""
|
||||||
name: str
|
name: str = Field(..., min_length=1, max_length=255)
|
||||||
question_type: str = "mixed"
|
question_type: str = Field("mixed", pattern="^(mixed|fact|reasoning|summary)$")
|
||||||
count: int = 50
|
count: int = Field(50, ge=1, le=500)
|
||||||
|
|
||||||
|
|
||||||
class RunEvalRequest(BaseModel):
|
class RunEvalRequest(BaseModel):
|
||||||
@@ -26,18 +34,34 @@ class RunEvalRequest(BaseModel):
|
|||||||
model_config_id: Optional[UUID] = None
|
model_config_id: Optional[UUID] = None
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", response_model=dict)
|
@router.get("", response_model=ApiResponse)
|
||||||
async def list_eval_datasets(project_id: UUID, db: AsyncSession = Depends(get_db)):
|
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"""
|
"""List evaluation datasets"""
|
||||||
result = await db.execute(
|
skip = (page - 1) * page_size
|
||||||
select(EvalDataset).where(EvalDataset.project_id == project_id).order_by(EvalDataset.created_at.desc())
|
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(
|
async def create_eval_dataset(
|
||||||
project_id: UUID,
|
project_id: UUID,
|
||||||
request: GenerateEvalRequest,
|
request: GenerateEvalRequest,
|
||||||
@@ -53,10 +77,27 @@ async def create_eval_dataset(
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(db_dataset)
|
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(
|
async def run_evaluation(
|
||||||
project_id: UUID,
|
project_id: UUID,
|
||||||
eval_id: UUID,
|
eval_id: UUID,
|
||||||
@@ -65,12 +106,9 @@ async def run_evaluation(
|
|||||||
):
|
):
|
||||||
"""Run evaluation on dataset"""
|
"""Run evaluation on dataset"""
|
||||||
# Check dataset exists
|
# Check dataset exists
|
||||||
result = await db.execute(
|
dataset = await eval_crud.get(db, eval_id)
|
||||||
select(EvalDataset).where(EvalDataset.id == eval_id, EvalDataset.project_id == project_id)
|
if not dataset or dataset.project_id != project_id:
|
||||||
)
|
raise NotFoundException("Evaluation Dataset", eval_id)
|
||||||
dataset = result.scalar_one_or_none()
|
|
||||||
if not dataset:
|
|
||||||
raise HTTPException(status_code=404, detail="Evaluation dataset not found")
|
|
||||||
|
|
||||||
# Create evaluation task
|
# Create evaluation task
|
||||||
task = Task(
|
task = Task(
|
||||||
@@ -82,19 +120,21 @@ async def run_evaluation(
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(task)
|
await db.refresh(task)
|
||||||
|
|
||||||
# TODO: Start evaluation in background
|
return ApiResponse.ok(
|
||||||
|
data={"task_id": str(task.id)},
|
||||||
return {"task_id": str(task.id), "message": "Evaluation task started"}
|
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)
|
|
||||||
)
|
)
|
||||||
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
|
Files API Router
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
import aiofiles
|
import asyncio
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List
|
from typing import Optional
|
||||||
from uuid import UUID
|
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.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.core.config import get_settings
|
||||||
from app.models.models import File
|
from app.core.database import get_db
|
||||||
from app.schemas.base import FileResponse
|
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()
|
settings = get_settings()
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -21,6 +25,9 @@ router = APIRouter()
|
|||||||
UPLOAD_DIR = Path(settings.UPLOAD_DIR)
|
UPLOAD_DIR = Path(settings.UPLOAD_DIR)
|
||||||
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Initialize CRUD
|
||||||
|
file_crud = CRUDBase(FileModel)
|
||||||
|
|
||||||
|
|
||||||
def get_file_type(filename: str) -> str:
|
def get_file_type(filename: str) -> str:
|
||||||
"""Get file type from extension"""
|
"""Get file type from extension"""
|
||||||
@@ -40,71 +47,157 @@ def get_file_type(filename: str) -> str:
|
|||||||
return type_map.get(ext, 'txt')
|
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(
|
async def upload_file(
|
||||||
project_id: UUID,
|
project_id: UUID,
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
db: AsyncSession = Depends(get_db)
|
db: AsyncSession = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""Upload a file"""
|
"""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
|
# Save file to disk
|
||||||
file_path = UPLOAD_DIR / f"{project_id}_{file.filename}"
|
safe_filename = f"{project_id}_{UUID.uuid4().hex[:8]}_{file.filename}"
|
||||||
async with aiofiles.open(file_path, 'wb') as f:
|
file_path = UPLOAD_DIR / safe_filename
|
||||||
content = await file.read()
|
|
||||||
await f.write(content)
|
# Write file asynchronously
|
||||||
|
await asyncio.get_event_loop().run_in_executor(
|
||||||
|
None,
|
||||||
|
lambda: file_path.write_bytes(content)
|
||||||
|
)
|
||||||
|
|
||||||
# Create file record
|
# Create file record
|
||||||
db_file = File(
|
db_file = FileModel(
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
filename=file.filename,
|
filename=file.filename,
|
||||||
file_type=get_file_type(file.filename),
|
file_type=get_file_type(file.filename),
|
||||||
file_path=str(file_path),
|
file_path=str(file_path),
|
||||||
size=len(content),
|
size=file_size,
|
||||||
status="pending"
|
status="pending"
|
||||||
)
|
)
|
||||||
db.add(db_file)
|
db.add(db_file)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(db_file)
|
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)
|
@router.get("", response_model=ApiResponse)
|
||||||
async def list_files(project_id: UUID, db: AsyncSession = Depends(get_db)):
|
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"""
|
"""List files for a project"""
|
||||||
result = await db.execute(
|
skip = (page - 1) * page_size
|
||||||
select(File).where(File.project_id == project_id).order_by(File.created_at.desc())
|
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)
|
@router.get("/{file_id}", response_model=ApiResponse)
|
||||||
async def get_file(project_id: UUID, file_id: UUID, db: AsyncSession = Depends(get_db)):
|
async def get_file(
|
||||||
|
project_id: UUID,
|
||||||
|
file_id: UUID,
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
"""Get file by ID"""
|
"""Get file by ID"""
|
||||||
result = await db.execute(
|
file = await file_crud.get(db, file_id)
|
||||||
select(File).where(File.id == file_id, File.project_id == project_id)
|
if not file or file.project_id != project_id:
|
||||||
)
|
raise NotFoundException("File", file_id)
|
||||||
file = result.scalar_one_or_none()
|
|
||||||
if not file:
|
return ApiResponse.ok(data=FileResponse.model_validate(file))
|
||||||
raise HTTPException(status_code=404, detail="File not found")
|
|
||||||
return FileResponse.model_validate(file)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{file_id}", response_model=dict)
|
@router.delete("/{file_id}", response_model=ApiResponse)
|
||||||
async def delete_file(project_id: UUID, file_id: UUID, db: AsyncSession = Depends(get_db)):
|
async def delete_file(
|
||||||
|
project_id: UUID,
|
||||||
|
file_id: UUID,
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
"""Delete file"""
|
"""Delete file"""
|
||||||
result = await db.execute(
|
file = await file_crud.get(db, file_id)
|
||||||
select(File).where(File.id == file_id, File.project_id == project_id)
|
if not file or file.project_id != project_id:
|
||||||
)
|
raise NotFoundException("File", file_id)
|
||||||
file = result.scalar_one_or_none()
|
|
||||||
if not file:
|
|
||||||
raise HTTPException(status_code=404, detail="File not found")
|
|
||||||
|
|
||||||
# Delete file from disk
|
# Delete file from disk
|
||||||
if file.file_path and os.path.exists(file.file_path):
|
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 file_crud.delete(db, file_id)
|
||||||
await db.commit()
|
return ApiResponse.ok(message="File deleted successfully")
|
||||||
return {"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
|
Projects API Router
|
||||||
"""
|
"""
|
||||||
from typing import List
|
import logging
|
||||||
|
from typing import List, Optional
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, Query
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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.database import get_db
|
||||||
|
from app.core.exceptions import NotFoundException
|
||||||
|
from app.core.crud import CRUDBase
|
||||||
from app.models.models import Project
|
from app.models.models import Project
|
||||||
from app.schemas.base import (
|
from app.schemas.project import (
|
||||||
ProjectCreate,
|
ProjectCreate,
|
||||||
ProjectUpdate,
|
ProjectUpdate,
|
||||||
ProjectResponse
|
ProjectResponse,
|
||||||
|
ProjectCreateSchema,
|
||||||
|
ProjectUpdateSchema
|
||||||
)
|
)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
logger = logging.getLogger("yg_dataset.projects")
|
||||||
|
|
||||||
|
# Initialize CRUD
|
||||||
|
project_crud = CRUDBase(Project)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", response_model=dict)
|
@router.get("", response_model=ApiResponse)
|
||||||
async def list_projects(db: AsyncSession = Depends(get_db)):
|
async def list_projects(
|
||||||
"""List all projects"""
|
page: int = Query(1, ge=1, description="Page number"),
|
||||||
result = await db.execute(select(Project).order_by(Project.created_at.desc()))
|
page_size: int = Query(20, ge=1, le=100, description="Page size"),
|
||||||
projects = result.scalars().all()
|
db: AsyncSession = Depends(get_db)
|
||||||
return {"projects": [ProjectResponse.model_validate(p) for p in projects]}
|
):
|
||||||
|
"""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)
|
@router.post("", response_model=ApiResponse)
|
||||||
async def create_project(project: ProjectCreate, db: AsyncSession = Depends(get_db)):
|
async def create_project(
|
||||||
|
project: ProjectCreateSchema,
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
"""Create a new project"""
|
"""Create a new project"""
|
||||||
db_project = Project(**project.model_dump())
|
logger.info(f"Creating project: name={project.name}, description={project.description}")
|
||||||
db.add(db_project)
|
db_project = await project_crud.create(db, project)
|
||||||
await db.commit()
|
logger.info(f"Project created successfully: id={db_project.id}")
|
||||||
await db.refresh(db_project)
|
return ApiResponse.ok(
|
||||||
return {"id": str(db_project.id)}
|
data={"id": str(db_project.id)},
|
||||||
|
message="Project created successfully"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{project_id}", response_model=dict)
|
@router.get("/{project_id}", response_model=ApiResponse)
|
||||||
async def get_project(project_id: UUID, db: AsyncSession = Depends(get_db)):
|
async def get_project(
|
||||||
|
project_id: UUID,
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
"""Get project by ID"""
|
"""Get project by ID"""
|
||||||
result = await db.execute(select(Project).where(Project.id == project_id))
|
logger.info(f"Getting project: id={project_id}")
|
||||||
project = result.scalar_one_or_none()
|
project = await project_crud.get_or_raise(db, project_id, "Project")
|
||||||
if not project:
|
logger.info(f"Found project: name={project.name}")
|
||||||
raise HTTPException(status_code=404, detail="Project not found")
|
return ApiResponse.ok(data=ProjectResponse.model_validate(project))
|
||||||
return ProjectResponse.model_validate(project)
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{project_id}", response_model=dict)
|
@router.put("/{project_id}", response_model=ApiResponse)
|
||||||
async def update_project(project_id: UUID, project: ProjectUpdate, db: AsyncSession = Depends(get_db)):
|
async def update_project(
|
||||||
|
project_id: UUID,
|
||||||
|
project: ProjectUpdateSchema,
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
"""Update project"""
|
"""Update project"""
|
||||||
result = await db.execute(select(Project).where(Project.id == project_id))
|
logger.info(f"Updating project: id={project_id}")
|
||||||
db_project = result.scalar_one_or_none()
|
db_project = await project_crud.get_or_raise(db, project_id, "Project")
|
||||||
if not db_project:
|
updated_project = await project_crud.update(db, db_project, project)
|
||||||
raise HTTPException(status_code=404, detail="Project not found")
|
logger.info(f"Project updated: name={updated_project.name}")
|
||||||
|
return ApiResponse.ok(
|
||||||
for key, value in project.model_dump(exclude_unset=True).items():
|
data=ProjectResponse.model_validate(updated_project),
|
||||||
setattr(db_project, key, value)
|
message="Project updated successfully"
|
||||||
|
)
|
||||||
await db.commit()
|
|
||||||
await db.refresh(db_project)
|
|
||||||
return ProjectResponse.model_validate(db_project)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{project_id}", response_model=dict)
|
@router.delete("/{project_id}", response_model=ApiResponse)
|
||||||
async def delete_project(project_id: UUID, db: AsyncSession = Depends(get_db)):
|
async def delete_project(
|
||||||
|
project_id: UUID,
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
"""Delete project"""
|
"""Delete project"""
|
||||||
result = await db.execute(select(Project).where(Project.id == project_id))
|
logger.info(f"Deleting project: id={project_id}")
|
||||||
project = result.scalar_one_or_none()
|
await project_crud.get_or_raise(db, project_id, "Project")
|
||||||
if not project:
|
await project_crud.delete(db, project_id)
|
||||||
raise HTTPException(status_code=404, detail="Project not found")
|
logger.info(f"Project deleted: id={project_id}")
|
||||||
|
return ApiResponse.ok(message="Project deleted successfully")
|
||||||
await db.delete(project)
|
|
||||||
await db.commit()
|
|
||||||
return {"message": "Project deleted successfully"}
|
|
||||||
|
|||||||
@@ -3,37 +3,38 @@ Questions API Router
|
|||||||
"""
|
"""
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, Field
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, Query
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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.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.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()
|
router = APIRouter()
|
||||||
|
|
||||||
|
# Initialize CRUD
|
||||||
|
question_crud = CRUDBase(Question)
|
||||||
|
|
||||||
|
|
||||||
class GenerateRequest(BaseModel):
|
class GenerateRequest(BaseModel):
|
||||||
"""Request model for generating questions"""
|
"""Request model for generating questions"""
|
||||||
chunk_ids: List[UUID] = []
|
chunk_ids: List[UUID] = Field(..., min_length=1)
|
||||||
count: int = 5
|
count: int = Field(5, ge=1, le=50)
|
||||||
question_types: List[str] = ["fact", "summary"]
|
question_types: List[str] = ["fact", "summary"]
|
||||||
|
|
||||||
|
|
||||||
@router.post("/generate", response_model=dict)
|
@router.post("/generate", response_model=ApiResponse)
|
||||||
async def generate_questions(
|
async def generate_questions(
|
||||||
project_id: UUID,
|
project_id: UUID,
|
||||||
request: GenerateRequest,
|
request: GenerateRequest,
|
||||||
db: AsyncSession = Depends(get_db)
|
db: AsyncSession = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""Generate questions from chunks using LLM"""
|
"""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
|
# Get chunks
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Chunk).where(Chunk.id.in_(request.chunk_ids), Chunk.project_id == project_id)
|
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()
|
chunks = result.scalars().all()
|
||||||
|
|
||||||
if not chunks:
|
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 = []
|
created_questions = []
|
||||||
for chunk in chunks:
|
for chunk in chunks:
|
||||||
for i in range(request.count):
|
for i in range(request.count):
|
||||||
@@ -60,63 +61,73 @@ async def generate_questions(
|
|||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
return {
|
return ApiResponse.ok(
|
||||||
"questions": len(created_questions),
|
data={"questions": len(created_questions)},
|
||||||
"message": f"Successfully generated {len(created_questions)} questions"
|
message=f"Successfully generated {len(created_questions)} questions"
|
||||||
}
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", response_model=dict)
|
@router.get("", response_model=ApiResponse)
|
||||||
async def list_questions(
|
async def list_questions(
|
||||||
project_id: UUID,
|
project_id: UUID,
|
||||||
chunk_id: Optional[UUID] = Query(None),
|
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)
|
db: AsyncSession = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""List questions for a project"""
|
"""List questions for a project"""
|
||||||
query = select(Question).where(Question.project_id == project_id)
|
filters = {"project_id": project_id}
|
||||||
|
|
||||||
if chunk_id:
|
if chunk_id:
|
||||||
query = query.where(Question.chunk_id == chunk_id)
|
filters["chunk_id"] = chunk_id
|
||||||
|
|
||||||
result = await db.execute(query)
|
skip = (page - 1) * page_size
|
||||||
questions = result.scalars().all()
|
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(
|
async def update_question(
|
||||||
project_id: UUID,
|
project_id: UUID,
|
||||||
question_id: UUID,
|
question_id: UUID,
|
||||||
question: QuestionCreate,
|
question: QuestionCreateSchema,
|
||||||
db: AsyncSession = Depends(get_db)
|
db: AsyncSession = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""Update question"""
|
"""Update question"""
|
||||||
result = await db.execute(
|
db_question = await question_crud.get(db, question_id)
|
||||||
select(Question).where(Question.id == question_id, Question.project_id == project_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)
|
@router.delete("/{question_id}", response_model=ApiResponse)
|
||||||
async def delete_question(project_id: UUID, question_id: UUID, db: AsyncSession = Depends(get_db)):
|
async def delete_question(
|
||||||
|
project_id: UUID,
|
||||||
|
question_id: UUID,
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
"""Delete question"""
|
"""Delete question"""
|
||||||
result = await db.execute(
|
question = await question_crud.get(db, question_id)
|
||||||
select(Question).where(Question.id == question_id, Question.project_id == project_id)
|
if not question or question.project_id != project_id:
|
||||||
)
|
raise NotFoundException("Question", question_id)
|
||||||
question = result.scalar_one_or_none()
|
|
||||||
if not question:
|
|
||||||
raise HTTPException(status_code=404, detail="Question not found")
|
|
||||||
|
|
||||||
await db.delete(question)
|
await question_crud.delete(db, question_id)
|
||||||
await db.commit()
|
return ApiResponse.ok(message="Question deleted successfully")
|
||||||
return {"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 functools import lru_cache
|
||||||
from pydantic_settings import BaseSettings
|
from pydantic_settings import BaseSettings
|
||||||
from pydantic import Field
|
from pydantic import Field, field_validator
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
@@ -15,12 +15,16 @@ class Settings(BaseSettings):
|
|||||||
DEBUG: bool = True
|
DEBUG: bool = True
|
||||||
HOST: str = "0.0.0.0"
|
HOST: str = "0.0.0.0"
|
||||||
PORT: int = 8000
|
PORT: int = 8000
|
||||||
|
ALLOWED_ORIGINS: str = Field(
|
||||||
|
default="*",
|
||||||
|
description="Comma-separated list of allowed CORS origins"
|
||||||
|
)
|
||||||
|
|
||||||
# Database - 使用 SQLite 进行开发/测试
|
# Database - 使用 SQLite 进行开发/测试
|
||||||
# 生产环境可切换为 PostgreSQL
|
# 生产环境可切换为 PostgreSQL
|
||||||
DATABASE_URL: str = Field(
|
DATABASE_URL: str = Field(
|
||||||
default="sqlite:///./ygdataset.db",
|
default="sqlite+aiosqlite:///./ygdataset.db",
|
||||||
description="Database connection URL (sqlite:// or postgresql+asyncpg://)"
|
description="Database connection URL (sqlite+aiosqlite:// or postgresql+asyncpg://)"
|
||||||
)
|
)
|
||||||
DATABASE_URL_SYNC: str = Field(
|
DATABASE_URL_SYNC: str = Field(
|
||||||
default="sqlite:///./ygdataset.db",
|
default="sqlite:///./ygdataset.db",
|
||||||
@@ -38,8 +42,31 @@ class Settings(BaseSettings):
|
|||||||
DEFAULT_MODEL_PROVIDER: str = "openai"
|
DEFAULT_MODEL_PROVIDER: str = "openai"
|
||||||
DEFAULT_MODEL_NAME: str = "gpt-4o-mini"
|
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:
|
class Config:
|
||||||
env_file = ".env"
|
env_file = ".env"
|
||||||
|
env_file_encoding = "utf-8"
|
||||||
extra = "allow"
|
extra = "allow"
|
||||||
|
|
||||||
|
|
||||||
@@ -47,3 +74,7 @@ class Settings(BaseSettings):
|
|||||||
def get_settings() -> Settings:
|
def get_settings() -> Settings:
|
||||||
"""Get cached settings"""
|
"""Get cached settings"""
|
||||||
return 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
|
Database Configuration and Session Management
|
||||||
支持 SQLite 和 PostgreSQL
|
支持 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.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||||
from sqlalchemy.orm import DeclarativeBase
|
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
|
from app.core.config import get_settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
|
|
||||||
def get_engine_config():
|
def get_engine_config():
|
||||||
"""根据数据库类型返回引擎配置"""
|
"""根据数据库类型返回引擎配置"""
|
||||||
if settings.DATABASE_URL.startswith("sqlite"):
|
if settings.DATABASE_URL.startswith("sqlite"):
|
||||||
return {"echo": settings.DEBUG}
|
return {"echo": settings.DEBUG, "poolclass": NullPool}
|
||||||
else:
|
else:
|
||||||
return {
|
return {
|
||||||
"echo": settings.DEBUG,
|
"echo": settings.DEBUG,
|
||||||
"pool_pre_ping": True,
|
"pool_pre_ping": True,
|
||||||
"pool_size": 10,
|
"pool_size": 10,
|
||||||
"max_overflow": 20,
|
"max_overflow": 20,
|
||||||
|
"pool_recycle": 3600,
|
||||||
|
"pool_timeout": 30,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -30,14 +37,14 @@ async_engine = create_async_engine(
|
|||||||
**get_engine_config()
|
**get_engine_config()
|
||||||
)
|
)
|
||||||
|
|
||||||
# Sync engine for migrations
|
# Sync engine for migrations (use NullPool for SQLite)
|
||||||
sync_engine = create_engine(
|
sync_engine = create_engine(
|
||||||
settings.DATABASE_URL_SYNC,
|
settings.DATABASE_URL_SYNC,
|
||||||
echo=settings.DEBUG,
|
echo=settings.DEBUG,
|
||||||
pool_pre_ping=True,
|
pool_pre_ping=True,
|
||||||
|
poolclass=NullPool if settings.DATABASE_URL_SYNC.startswith("sqlite") else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# Async session factory
|
# Async session factory
|
||||||
AsyncSessionLocal = async_sessionmaker(
|
AsyncSessionLocal = async_sessionmaker(
|
||||||
async_engine,
|
async_engine,
|
||||||
@@ -55,8 +62,31 @@ class Base(DeclarativeBase):
|
|||||||
|
|
||||||
async def init_db():
|
async def init_db():
|
||||||
"""Initialize database tables"""
|
"""Initialize database tables"""
|
||||||
|
logger.info("Initializing database...")
|
||||||
async with async_engine.begin() as conn:
|
async with async_engine.begin() as conn:
|
||||||
await conn.run_sync(Base.metadata.create_all)
|
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:
|
async def get_db() -> AsyncSession:
|
||||||
@@ -64,5 +94,9 @@ async def get_db() -> AsyncSession:
|
|||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
try:
|
try:
|
||||||
yield session
|
yield session
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Database error in dependency: {str(e)}")
|
||||||
|
await session.rollback()
|
||||||
|
raise
|
||||||
finally:
|
finally:
|
||||||
await session.close()
|
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
|
FastAPI-based API server for dataset generation platform
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI, Request
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
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.v1 import api_router
|
||||||
|
from app.api.response import ApiResponse
|
||||||
from app.core.config import settings
|
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
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
"""Application lifespan events"""
|
"""Application lifespan events"""
|
||||||
# Startup
|
# Startup
|
||||||
|
logger.info("Starting YG-Dataset application...")
|
||||||
await init_db()
|
await init_db()
|
||||||
|
logger.info("Database initialized successfully")
|
||||||
yield
|
yield
|
||||||
# Shutdown
|
# Shutdown
|
||||||
pass
|
logger.info("Shutting down YG-Dataset application...")
|
||||||
|
await close_db()
|
||||||
|
logger.info("Database connections closed")
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
@@ -29,15 +77,83 @@ app = FastAPI(
|
|||||||
lifespan=lifespan,
|
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(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=["*"],
|
allow_origins=ALLOWED_ORIGINS,
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
allow_methods=["*"],
|
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||||
allow_headers=["*"],
|
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
|
# Include API routes
|
||||||
app.include_router(api_router, prefix="/api/v1")
|
app.include_router(api_router, prefix="/api/v1")
|
||||||
|
|
||||||
@@ -45,7 +161,10 @@ app.include_router(api_router, prefix="/api/v1")
|
|||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
async def health_check():
|
async def health_check():
|
||||||
"""Health check endpoint"""
|
"""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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ class Chunk(Base, UUIDMixin, TimestampMixin):
|
|||||||
content = Column(Text, nullable=False)
|
content = Column(Text, nullable=False)
|
||||||
summary = Column(Text)
|
summary = Column(Text)
|
||||||
word_count = Column(Integer)
|
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
|
# Relationships
|
||||||
project = relationship("Project", back_populates="chunks")
|
project = relationship("Project", back_populates="chunks")
|
||||||
@@ -112,7 +112,7 @@ class Dataset(Base, UUIDMixin, TimestampMixin):
|
|||||||
name = Column(String(255), nullable=False)
|
name = Column(String(255), nullable=False)
|
||||||
description = Column(Text)
|
description = Column(Text)
|
||||||
dataset_type = Column(String(50)) # qa, conversation, instruction
|
dataset_type = Column(String(50)) # qa, conversation, instruction
|
||||||
metadata = Column(JSON)
|
extra_data = Column(JSON)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
project = relationship("Project", back_populates="datasets")
|
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)
|
project_id = Column(UUID(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False)
|
||||||
name = Column(String(255), nullable=False)
|
name = Column(String(255), nullable=False)
|
||||||
question_type = Column(String(50)) # mixed, fact, reasoning
|
question_type = Column(String(50)) # mixed, fact, reasoning
|
||||||
metadata = Column(JSON)
|
extra_data = Column(JSON)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
project = relationship("Project", back_populates="eval_datasets")
|
project = relationship("Project", back_populates="eval_datasets")
|
||||||
|
|||||||
@@ -1,3 +1,89 @@
|
|||||||
"""
|
"""
|
||||||
Pydantic Schemas
|
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 datetime import datetime
|
||||||
from typing import Optional, Any
|
from typing import Optional, Any
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
class TimestampMixin(BaseModel):
|
class TimestampMixin(BaseModel):
|
||||||
@@ -18,153 +18,3 @@ class UUIDMixin(BaseModel):
|
|||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
id: UUID
|
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
|
DOCX Text Extractor
|
||||||
"""
|
"""
|
||||||
|
import asyncio
|
||||||
|
from typing import Dict
|
||||||
from docx import Document
|
from docx import Document
|
||||||
from typing import Dict, List
|
|
||||||
|
|
||||||
|
|
||||||
class DOCXProcessor:
|
class DOCXProcessor:
|
||||||
@@ -26,6 +27,12 @@ class DOCXProcessor:
|
|||||||
|
|
||||||
return "\n\n".join(text_parts)
|
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:
|
def extract_with_metadata(self, file_path: str) -> Dict:
|
||||||
"""Extract text with DOCX metadata"""
|
"""Extract text with DOCX metadata"""
|
||||||
doc = Document(file_path)
|
doc = Document(file_path)
|
||||||
@@ -46,8 +53,14 @@ class DOCXProcessor:
|
|||||||
|
|
||||||
return result
|
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"""
|
"""Process DOCX file and return text"""
|
||||||
processor = DOCXProcessor()
|
processor = DOCXProcessor()
|
||||||
return processor.extract_text(file_path)
|
return await processor.extract_text_async(file_path)
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
"""
|
"""
|
||||||
Excel/CSV Text Extractor
|
Excel/CSV Text Extractor
|
||||||
"""
|
"""
|
||||||
import pandas as pd
|
import asyncio
|
||||||
from typing import Dict, List
|
from typing import Dict, List
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
|
||||||
class ExcelProcessor:
|
class ExcelProcessor:
|
||||||
@@ -13,6 +14,12 @@ class ExcelProcessor:
|
|||||||
df = pd.read_csv(file_path)
|
df = pd.read_csv(file_path)
|
||||||
return self._dataframe_to_text(df)
|
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:
|
def extract_excel(self, file_path: str, sheet_name: str = None) -> str:
|
||||||
"""Extract text from Excel file"""
|
"""Extract text from Excel file"""
|
||||||
if sheet_name:
|
if sheet_name:
|
||||||
@@ -27,6 +34,12 @@ class ExcelProcessor:
|
|||||||
text_parts.append(self._dataframe_to_text(df))
|
text_parts.append(self._dataframe_to_text(df))
|
||||||
return "\n\n".join(text_parts)
|
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:
|
def _dataframe_to_text(self, df: pd.DataFrame) -> str:
|
||||||
"""Convert DataFrame to readable text"""
|
"""Convert DataFrame to readable text"""
|
||||||
text_parts = []
|
text_parts = []
|
||||||
@@ -48,19 +61,25 @@ class ExcelProcessor:
|
|||||||
sheets = pd.read_excel(file_path, sheet_name=None)
|
sheets = pd.read_excel(file_path, sheet_name=None)
|
||||||
return {name: self._dataframe_to_text(df) for name, df in sheets.items()}
|
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]:
|
def get_sheet_names(self, file_path: str) -> List[str]:
|
||||||
"""Get all sheet names from Excel file"""
|
"""Get all sheet names from Excel file"""
|
||||||
xl = pd.ExcelFile(file_path)
|
xl = pd.ExcelFile(file_path)
|
||||||
return xl.sheet_names
|
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"""
|
"""Process CSV file and return text"""
|
||||||
processor = ExcelProcessor()
|
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"""
|
"""Process Excel file and return text"""
|
||||||
processor = ExcelProcessor()
|
processor = ExcelProcessor()
|
||||||
return processor.extract_excel(file_path)
|
return await processor.extract_excel_async(file_path)
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
"""
|
"""
|
||||||
PDF Text Extractor
|
PDF Text Extractor
|
||||||
"""
|
"""
|
||||||
|
import asyncio
|
||||||
|
from typing import Dict, List
|
||||||
import pdfplumber
|
import pdfplumber
|
||||||
from typing import Dict, List, Optional
|
|
||||||
|
|
||||||
|
|
||||||
class PDFProcessor:
|
class PDFProcessor:
|
||||||
@@ -20,6 +21,12 @@ class PDFProcessor:
|
|||||||
|
|
||||||
return "\n\n".join(text_parts)
|
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]:
|
def extract_pages(self, file_path: str) -> List[Dict]:
|
||||||
"""Extract text page by page with metadata"""
|
"""Extract text page by page with metadata"""
|
||||||
pages = []
|
pages = []
|
||||||
@@ -36,6 +43,12 @@ class PDFProcessor:
|
|||||||
|
|
||||||
return pages
|
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:
|
def extract_with_metadata(self, file_path: str) -> Dict:
|
||||||
"""Extract text with PDF metadata"""
|
"""Extract text with PDF metadata"""
|
||||||
result = {
|
result = {
|
||||||
@@ -58,8 +71,14 @@ class PDFProcessor:
|
|||||||
|
|
||||||
return result
|
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"""
|
"""Process PDF file and return text"""
|
||||||
processor = PDFProcessor()
|
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"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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": "^3.5.0",
|
||||||
"vue-router": "^4.4.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"
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-vue": "^5.0.0",
|
"@vitejs/plugin-vue": "^5.2.4",
|
||||||
"vite": "^6.0.0"
|
"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',
|
path: '/models',
|
||||||
name: 'Playground',
|
name: 'ModelSettings',
|
||||||
component: () => import('@/views/PlaygroundView.vue')
|
component: () => import('@/views/ModelSettingsView.vue')
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/data-square',
|
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 () => {
|
const fetchProject = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await projectApi.get(id.value)
|
const res = await projectApi.get(id.value)
|
||||||
project.value = res.data
|
// New format: returns project directly
|
||||||
|
project.value = res
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error('加载项目失败')
|
ElMessage.error('加载项目失败')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,8 +125,9 @@ const prompts = reactive({
|
|||||||
const fetchProject = async () => {
|
const fetchProject = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await projectApi.get(projectId.value)
|
const res = await projectApi.get(projectId.value)
|
||||||
projectInfo.name = res.data.name
|
// New format: project directly in response
|
||||||
projectInfo.description = res.data.description || ''
|
projectInfo.name = res.name
|
||||||
|
projectInfo.description = res.description || ''
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(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: {
|
server: {
|
||||||
|
host: '0.0.0.0',
|
||||||
port: 3000,
|
port: 3000,
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
@@ -17,5 +18,8 @@ export default defineConfig({
|
|||||||
changeOrigin: true
|
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