新增前后端AI聊天模块,包括流式对话、配置管理和UI组件优化。
This commit is contained in:
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
20
backend/app/database.py
Normal file
20
backend/app/database.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from pathlib import Path
|
||||
|
||||
from sqlmodel import SQLModel, Session, create_engine
|
||||
|
||||
# 数据库文件存放在 backend/data/ 目录下
|
||||
DATA_DIR = Path(__file__).resolve().parent.parent / "data"
|
||||
DATA_DIR.mkdir(exist_ok=True)
|
||||
|
||||
DATABASE_URL = f"sqlite:///{DATA_DIR / 'noval.db'}"
|
||||
|
||||
engine = create_engine(DATABASE_URL, echo=False)
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
SQLModel.metadata.create_all(engine)
|
||||
|
||||
|
||||
def get_session():
|
||||
with Session(engine) as session:
|
||||
yield session
|
||||
37
backend/app/main.py
Normal file
37
backend/app/main.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from .database import init_db
|
||||
from .routers.novels import router as novels_router
|
||||
from .routers.outlines import router as outlines_router
|
||||
from .routers.ai_config import router as ai_config_router
|
||||
from .routers.chat import router as chat_router
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
init_db()
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(title="Noval", version="0.1.0", lifespan=lifespan)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["http://localhost:5173"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(novels_router)
|
||||
app.include_router(outlines_router)
|
||||
app.include_router(ai_config_router)
|
||||
app.include_router(chat_router)
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
def health():
|
||||
return {"status": "ok"}
|
||||
50
backend/app/models.py
Normal file
50
backend/app/models.py
Normal file
@@ -0,0 +1,50 @@
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from sqlmodel import SQLModel, Field
|
||||
|
||||
|
||||
def _now_utc() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class Novel(SQLModel, table=True):
|
||||
"""小说主表"""
|
||||
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
title: str = Field(max_length=200, index=True)
|
||||
description: str = Field(default="", max_length=2000)
|
||||
created_at: datetime = Field(default_factory=_now_utc)
|
||||
updated_at: datetime = Field(default_factory=_now_utc)
|
||||
|
||||
|
||||
class AIConfig(SQLModel, table=True):
|
||||
"""AI服务配置(单行表)"""
|
||||
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
provider: str = Field(default="openai") # "openai" | "anthropic"
|
||||
base_url: str = Field(default="https://api.openai.com/v1")
|
||||
api_key: str = Field(default="")
|
||||
model: str = Field(default="gpt-4o")
|
||||
updated_at: datetime = Field(default_factory=_now_utc)
|
||||
|
||||
|
||||
class ChatMessage(SQLModel, table=True):
|
||||
"""聊天消息"""
|
||||
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
novel_id: Optional[int] = Field(default=None, foreign_key="novel.id", index=True)
|
||||
role: str = Field(max_length=20) # "user" | "assistant"
|
||||
content: str = Field(default="")
|
||||
created_at: datetime = Field(default_factory=_now_utc)
|
||||
|
||||
class Outline(SQLModel, table=True):
|
||||
"""大纲表,属于小说的子资源"""
|
||||
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
novel_id: int = Field(foreign_key="novel.id", index=True)
|
||||
sort_order: int = Field(default=0)
|
||||
summary: str = Field(max_length=200)
|
||||
detail: str = Field(default="", max_length=1000)
|
||||
created_at: datetime = Field(default_factory=_now_utc)
|
||||
updated_at: datetime = Field(default_factory=_now_utc)
|
||||
0
backend/app/routers/__init__.py
Normal file
0
backend/app/routers/__init__.py
Normal file
99
backend/app/routers/ai_config.py
Normal file
99
backend/app/routers/ai_config.py
Normal file
@@ -0,0 +1,99 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from ..database import get_session
|
||||
from ..models import AIConfig
|
||||
from ..schemas import AIConfigSave, AIConfigRead
|
||||
|
||||
router = APIRouter(prefix="/api/ai", tags=["ai-config"])
|
||||
|
||||
|
||||
def _mask_key(key: str) -> str:
|
||||
if len(key) <= 8:
|
||||
return "****"
|
||||
return key[:4] + "****" + key[-4:]
|
||||
|
||||
|
||||
def _get_or_create_config(session: Session) -> AIConfig:
|
||||
config = session.exec(select(AIConfig)).first()
|
||||
if not config:
|
||||
config = AIConfig()
|
||||
session.add(config)
|
||||
session.commit()
|
||||
session.refresh(config)
|
||||
return config
|
||||
|
||||
|
||||
def _config_to_read(config: AIConfig) -> AIConfigRead:
|
||||
return AIConfigRead(
|
||||
id=config.id,
|
||||
provider=config.provider,
|
||||
base_url=config.base_url,
|
||||
api_key_masked=_mask_key(config.api_key) if config.api_key else "",
|
||||
model=config.model,
|
||||
updated_at=config.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/config", response_model=AIConfigRead)
|
||||
def get_config(session: Session = Depends(get_session)):
|
||||
config = _get_or_create_config(session)
|
||||
return _config_to_read(config)
|
||||
|
||||
|
||||
@router.put("/config", response_model=AIConfigRead)
|
||||
def save_config(data: AIConfigSave, session: Session = Depends(get_session)):
|
||||
config = _get_or_create_config(session)
|
||||
config.provider = data.provider
|
||||
config.base_url = data.base_url
|
||||
config.api_key = data.api_key
|
||||
config.model = data.model
|
||||
config.updated_at = datetime.now(timezone.utc)
|
||||
session.add(config)
|
||||
session.commit()
|
||||
session.refresh(config)
|
||||
return _config_to_read(config)
|
||||
|
||||
|
||||
@router.post("/config/test")
|
||||
async def test_config(data: AIConfigSave):
|
||||
"""测试AI连接是否有效"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
if data.provider == "anthropic":
|
||||
resp = await client.post(
|
||||
f"{data.base_url.rstrip('/')}/messages",
|
||||
headers={
|
||||
"x-api-key": data.api_key,
|
||||
"anthropic-version": "2023-06-01",
|
||||
"content-type": "application/json",
|
||||
},
|
||||
json={
|
||||
"model": data.model,
|
||||
"max_tokens": 10,
|
||||
"messages": [{"role": "user", "content": "Hi"}],
|
||||
},
|
||||
)
|
||||
else:
|
||||
resp = await client.post(
|
||||
f"{data.base_url.rstrip('/')}/chat/completions",
|
||||
headers={
|
||||
"Authorization": f"Bearer {data.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={
|
||||
"model": data.model,
|
||||
"max_tokens": 10,
|
||||
"messages": [{"role": "user", "content": "Hi"}],
|
||||
},
|
||||
)
|
||||
if resp.status_code < 400:
|
||||
return {"ok": True, "message": "连接成功"}
|
||||
return {"ok": False, "message": f"API返回错误: {resp.status_code}"}
|
||||
except httpx.ConnectError:
|
||||
return {"ok": False, "message": "无法连接到API地址"}
|
||||
except Exception as e:
|
||||
return {"ok": False, "message": str(e)}
|
||||
132
backend/app/routers/chat.py
Normal file
132
backend/app/routers/chat.py
Normal file
@@ -0,0 +1,132 @@
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlmodel import Session, select, func
|
||||
|
||||
from ..database import get_session, engine
|
||||
from ..models import AIConfig, ChatMessage
|
||||
from ..schemas import ChatRequest, ChatMessageRead, ChatMessageList
|
||||
from ..services.ai_provider import stream_chat
|
||||
|
||||
router = APIRouter(prefix="/api/chat", tags=["chat"])
|
||||
|
||||
|
||||
@router.post("/stream")
|
||||
async def chat_stream(
|
||||
data: ChatRequest,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""SSE 流式对话端点"""
|
||||
config = session.exec(select(AIConfig)).first()
|
||||
if not config or not config.api_key:
|
||||
return StreamingResponse(
|
||||
_error_stream("请先配置AI服务"),
|
||||
media_type="text/event-stream",
|
||||
)
|
||||
|
||||
# 保存用户消息
|
||||
user_msg = data.messages[-1] if data.messages else None
|
||||
if user_msg and user_msg.role == "user":
|
||||
db_msg = ChatMessage(
|
||||
novel_id=data.novel_id,
|
||||
role="user",
|
||||
content=user_msg.content,
|
||||
)
|
||||
session.add(db_msg)
|
||||
session.commit()
|
||||
|
||||
# 构建消息列表
|
||||
messages = [{"role": m.role, "content": m.content} for m in data.messages]
|
||||
|
||||
# 构建上下文摘要(前端展示用)
|
||||
context_preview = [{"role": m["role"], "content": m["content"][:200]} for m in messages]
|
||||
|
||||
async def generate():
|
||||
# 先发送上下文预览
|
||||
yield f"data: {json.dumps({'context': context_preview})}\n\n"
|
||||
|
||||
full_response = ""
|
||||
usage_info = {}
|
||||
try:
|
||||
async for event in stream_chat(config, messages):
|
||||
if event.text:
|
||||
full_response += event.text
|
||||
yield f"data: {json.dumps({'content': event.text})}\n\n"
|
||||
if event.usage:
|
||||
# 累加usage
|
||||
for k, v in event.usage.items():
|
||||
if v:
|
||||
usage_info[k] = usage_info.get(k, 0) + v
|
||||
except Exception as e:
|
||||
yield f"data: {json.dumps({'error': str(e)})}\n\n"
|
||||
finally:
|
||||
# 保存AI回复
|
||||
if full_response:
|
||||
with Session(engine) as s:
|
||||
ai_msg = ChatMessage(
|
||||
novel_id=data.novel_id,
|
||||
role="assistant",
|
||||
content=full_response,
|
||||
)
|
||||
s.add(ai_msg)
|
||||
s.commit()
|
||||
yield f"data: {json.dumps({'done': True, 'usage': usage_info or None})}\n\n"
|
||||
|
||||
return StreamingResponse(
|
||||
generate(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def _error_stream(message: str):
|
||||
yield f"data: {json.dumps({'error': message})}\n\n"
|
||||
yield f"data: {json.dumps({'done': True})}\n\n"
|
||||
|
||||
|
||||
@router.get("/messages", response_model=ChatMessageList)
|
||||
def list_messages(
|
||||
novel_id: int | None = Query(default=None),
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
query = select(ChatMessage)
|
||||
count_query = select(func.count(ChatMessage.id))
|
||||
if novel_id is not None:
|
||||
query = query.where(ChatMessage.novel_id == novel_id)
|
||||
count_query = count_query.where(ChatMessage.novel_id == novel_id)
|
||||
else:
|
||||
query = query.where(ChatMessage.novel_id.is_(None)) # type: ignore
|
||||
count_query = count_query.where(ChatMessage.novel_id.is_(None)) # type: ignore
|
||||
|
||||
total = session.exec(count_query).one()
|
||||
items = session.exec(
|
||||
query.order_by(ChatMessage.created_at.desc()).limit(limit)
|
||||
).all()
|
||||
# 返回时按时间正序
|
||||
items = list(reversed(items))
|
||||
return ChatMessageList(
|
||||
items=[ChatMessageRead.model_validate(m, from_attributes=True) for m in items],
|
||||
total=total,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/messages", status_code=204)
|
||||
def clear_messages(
|
||||
novel_id: int | None = Query(default=None),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
query = select(ChatMessage)
|
||||
if novel_id is not None:
|
||||
query = query.where(ChatMessage.novel_id == novel_id)
|
||||
else:
|
||||
query = query.where(ChatMessage.novel_id.is_(None)) # type: ignore
|
||||
messages = session.exec(query).all()
|
||||
for msg in messages:
|
||||
session.delete(msg)
|
||||
session.commit()
|
||||
67
backend/app/routers/novels.py
Normal file
67
backend/app/routers/novels.py
Normal file
@@ -0,0 +1,67 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlmodel import Session, select, func
|
||||
|
||||
from ..database import get_session
|
||||
from ..models import Novel
|
||||
from ..schemas import NovelCreate, NovelUpdate, NovelRead, NovelList
|
||||
|
||||
router = APIRouter(prefix="/api/novels", tags=["novels"])
|
||||
|
||||
|
||||
def _get_novel_or_404(novel_id: int, session: Session) -> Novel:
|
||||
novel = session.get(Novel, novel_id)
|
||||
if not novel:
|
||||
raise HTTPException(status_code=404, detail="小说不存在")
|
||||
return novel
|
||||
|
||||
|
||||
@router.get("", response_model=NovelList)
|
||||
def list_novels(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
total = session.exec(select(func.count(Novel.id))).one()
|
||||
novels = session.exec(
|
||||
select(Novel).order_by(Novel.updated_at.desc()).offset(skip).limit(limit)
|
||||
).all()
|
||||
items = [NovelRead.model_validate(n, from_attributes=True) for n in novels]
|
||||
return NovelList(items=items, total=total)
|
||||
|
||||
|
||||
@router.post("", response_model=NovelRead, status_code=201)
|
||||
def create_novel(data: NovelCreate, session: Session = Depends(get_session)):
|
||||
novel = Novel(**data.model_dump())
|
||||
session.add(novel)
|
||||
session.commit()
|
||||
session.refresh(novel)
|
||||
return novel
|
||||
|
||||
|
||||
@router.get("/{novel_id}", response_model=NovelRead)
|
||||
def get_novel(novel_id: int, session: Session = Depends(get_session)):
|
||||
return _get_novel_or_404(novel_id, session)
|
||||
|
||||
|
||||
@router.patch("/{novel_id}", response_model=NovelRead)
|
||||
def update_novel(
|
||||
novel_id: int, data: NovelUpdate, session: Session = Depends(get_session)
|
||||
):
|
||||
novel = _get_novel_or_404(novel_id, session)
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
setattr(novel, key, value)
|
||||
novel.updated_at = datetime.now(timezone.utc)
|
||||
session.add(novel)
|
||||
session.commit()
|
||||
session.refresh(novel)
|
||||
return novel
|
||||
|
||||
|
||||
@router.delete("/{novel_id}", status_code=204)
|
||||
def delete_novel(novel_id: int, session: Session = Depends(get_session)):
|
||||
novel = _get_novel_or_404(novel_id, session)
|
||||
session.delete(novel)
|
||||
session.commit()
|
||||
137
backend/app/routers/outlines.py
Normal file
137
backend/app/routers/outlines.py
Normal file
@@ -0,0 +1,137 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlmodel import Session, select, func
|
||||
|
||||
from ..database import get_session
|
||||
from ..models import Novel, Outline
|
||||
from ..schemas import (
|
||||
OutlineCreate,
|
||||
OutlineUpdate,
|
||||
OutlineRead,
|
||||
OutlineList,
|
||||
OutlineReorder,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/novels/{novel_id}/outlines", tags=["outlines"])
|
||||
|
||||
|
||||
def _get_novel_or_404(novel_id: int, session: Session) -> Novel:
|
||||
novel = session.get(Novel, novel_id)
|
||||
if not novel:
|
||||
raise HTTPException(status_code=404, detail="小说不存在")
|
||||
return novel
|
||||
|
||||
|
||||
def _get_outline_or_404(
|
||||
outline_id: int, novel_id: int, session: Session
|
||||
) -> Outline:
|
||||
outline = session.get(Outline, outline_id)
|
||||
if not outline or outline.novel_id != novel_id:
|
||||
raise HTTPException(status_code=404, detail="大纲不存在")
|
||||
return outline
|
||||
|
||||
|
||||
@router.get("", response_model=OutlineList)
|
||||
def list_outlines(
|
||||
novel_id: int,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
_get_novel_or_404(novel_id, session)
|
||||
total = session.exec(
|
||||
select(func.count(Outline.id)).where(Outline.novel_id == novel_id)
|
||||
).one()
|
||||
items = session.exec(
|
||||
select(Outline)
|
||||
.where(Outline.novel_id == novel_id)
|
||||
.order_by(Outline.sort_order)
|
||||
).all()
|
||||
return OutlineList(
|
||||
items=[OutlineRead.model_validate(o, from_attributes=True) for o in items],
|
||||
total=total,
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=OutlineRead, status_code=201)
|
||||
def create_outline(
|
||||
novel_id: int,
|
||||
data: OutlineCreate,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
_get_novel_or_404(novel_id, session)
|
||||
# 自动设置 sort_order 为当前最大值 + 1
|
||||
max_order = session.exec(
|
||||
select(func.max(Outline.sort_order)).where(Outline.novel_id == novel_id)
|
||||
).one()
|
||||
next_order = (max_order or 0) + 1
|
||||
|
||||
outline = Outline(novel_id=novel_id, sort_order=next_order, **data.model_dump())
|
||||
session.add(outline)
|
||||
session.commit()
|
||||
session.refresh(outline)
|
||||
return outline
|
||||
|
||||
|
||||
@router.put("/reorder", response_model=OutlineList)
|
||||
def reorder_outlines(
|
||||
novel_id: int,
|
||||
data: OutlineReorder,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
_get_novel_or_404(novel_id, session)
|
||||
now = datetime.now(timezone.utc)
|
||||
for idx, oid in enumerate(data.ordered_ids):
|
||||
outline = _get_outline_or_404(oid, novel_id, session)
|
||||
outline.sort_order = idx + 1
|
||||
outline.updated_at = now
|
||||
session.add(outline)
|
||||
session.commit()
|
||||
|
||||
items = session.exec(
|
||||
select(Outline)
|
||||
.where(Outline.novel_id == novel_id)
|
||||
.order_by(Outline.sort_order)
|
||||
).all()
|
||||
total = len(items)
|
||||
return OutlineList(
|
||||
items=[OutlineRead.model_validate(o, from_attributes=True) for o in items],
|
||||
total=total,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{outline_id}", response_model=OutlineRead)
|
||||
def get_outline(
|
||||
novel_id: int,
|
||||
outline_id: int,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
return _get_outline_or_404(outline_id, novel_id, session)
|
||||
|
||||
|
||||
@router.patch("/{outline_id}", response_model=OutlineRead)
|
||||
def update_outline(
|
||||
novel_id: int,
|
||||
outline_id: int,
|
||||
data: OutlineUpdate,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
outline = _get_outline_or_404(outline_id, novel_id, session)
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
setattr(outline, key, value)
|
||||
outline.updated_at = datetime.now(timezone.utc)
|
||||
session.add(outline)
|
||||
session.commit()
|
||||
session.refresh(outline)
|
||||
return outline
|
||||
|
||||
|
||||
@router.delete("/{outline_id}", status_code=204)
|
||||
def delete_outline(
|
||||
novel_id: int,
|
||||
outline_id: int,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
outline = _get_outline_or_404(outline_id, novel_id, session)
|
||||
session.delete(outline)
|
||||
session.commit()
|
||||
105
backend/app/schemas.py
Normal file
105
backend/app/schemas.py
Normal file
@@ -0,0 +1,105 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class NovelCreate(BaseModel):
|
||||
title: str = Field(min_length=1, max_length=200)
|
||||
description: str = Field(default="", max_length=2000)
|
||||
|
||||
|
||||
class NovelUpdate(BaseModel):
|
||||
title: str | None = Field(default=None, min_length=1, max_length=200)
|
||||
description: str | None = Field(default=None, max_length=2000)
|
||||
|
||||
|
||||
class NovelRead(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
description: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class NovelList(BaseModel):
|
||||
items: list[NovelRead]
|
||||
total: int
|
||||
|
||||
|
||||
# ── 大纲 ──
|
||||
|
||||
|
||||
class OutlineCreate(BaseModel):
|
||||
summary: str = Field(min_length=1, max_length=200)
|
||||
detail: str = Field(default="", max_length=1000)
|
||||
|
||||
|
||||
class OutlineUpdate(BaseModel):
|
||||
summary: str | None = Field(default=None, min_length=1, max_length=200)
|
||||
detail: str | None = Field(default=None, max_length=1000)
|
||||
sort_order: int | None = None
|
||||
|
||||
|
||||
class OutlineRead(BaseModel):
|
||||
id: int
|
||||
novel_id: int
|
||||
sort_order: int
|
||||
summary: str
|
||||
detail: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class OutlineList(BaseModel):
|
||||
items: list[OutlineRead]
|
||||
total: int
|
||||
|
||||
|
||||
class OutlineReorder(BaseModel):
|
||||
"""排序请求:按顺序传入大纲id列表"""
|
||||
ordered_ids: list[int]
|
||||
|
||||
|
||||
# ── AI配置 ──
|
||||
|
||||
|
||||
class AIConfigSave(BaseModel):
|
||||
provider: str = Field(default="openai")
|
||||
base_url: str = Field(default="https://api.openai.com/v1")
|
||||
api_key: str = Field(min_length=1)
|
||||
model: str = Field(default="gpt-4o")
|
||||
|
||||
|
||||
class AIConfigRead(BaseModel):
|
||||
id: int
|
||||
provider: str
|
||||
base_url: str
|
||||
api_key_masked: str # 脱敏后的key
|
||||
model: str
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
# ── 聊天 ──
|
||||
|
||||
|
||||
class ChatMessageInput(BaseModel):
|
||||
role: str
|
||||
content: str
|
||||
|
||||
|
||||
class ChatRequest(BaseModel):
|
||||
messages: list[ChatMessageInput]
|
||||
novel_id: int | None = None
|
||||
|
||||
|
||||
class ChatMessageRead(BaseModel):
|
||||
id: int
|
||||
novel_id: int | None
|
||||
role: str
|
||||
content: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class ChatMessageList(BaseModel):
|
||||
items: list[ChatMessageRead]
|
||||
total: int
|
||||
0
backend/app/services/__init__.py
Normal file
0
backend/app/services/__init__.py
Normal file
171
backend/app/services/ai_provider.py
Normal file
171
backend/app/services/ai_provider.py
Normal file
@@ -0,0 +1,171 @@
|
||||
"""AI Provider 抽象层,支持 OpenAI 兼容 API 和 Anthropic 原生 API。"""
|
||||
|
||||
import json
|
||||
from typing import AsyncGenerator
|
||||
|
||||
import httpx
|
||||
|
||||
from ..models import AIConfig
|
||||
|
||||
|
||||
class StreamEvent:
|
||||
"""流式事件:文本chunk或usage信息"""
|
||||
def __init__(self, text: str = "", usage: dict | None = None):
|
||||
self.text = text
|
||||
self.usage = usage
|
||||
|
||||
|
||||
async def stream_chat(
|
||||
config: AIConfig,
|
||||
messages: list[dict],
|
||||
system_prompt: str = "",
|
||||
) -> AsyncGenerator[StreamEvent, None]:
|
||||
"""根据 provider 类型调用对应的流式 API,返回 StreamEvent。"""
|
||||
if config.provider == "anthropic":
|
||||
async for event in _stream_anthropic(config, messages, system_prompt):
|
||||
yield event
|
||||
else:
|
||||
async for event in _stream_openai(config, messages, system_prompt):
|
||||
yield event
|
||||
|
||||
|
||||
async def _stream_openai(
|
||||
config: AIConfig,
|
||||
messages: list[dict],
|
||||
system_prompt: str,
|
||||
) -> AsyncGenerator[StreamEvent, None]:
|
||||
"""调用 OpenAI 兼容 API(stream=true)。"""
|
||||
url = f"{config.base_url.rstrip('/')}/chat/completions"
|
||||
|
||||
payload_messages = []
|
||||
if system_prompt:
|
||||
payload_messages.append({"role": "system", "content": system_prompt})
|
||||
payload_messages.extend(messages)
|
||||
|
||||
body = {
|
||||
"model": config.model,
|
||||
"messages": payload_messages,
|
||||
"stream": True,
|
||||
"stream_options": {"include_usage": True},
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=120) as client:
|
||||
async with client.stream(
|
||||
"POST",
|
||||
url,
|
||||
headers={
|
||||
"Authorization": f"Bearer {config.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json=body,
|
||||
) as resp:
|
||||
if resp.status_code >= 400:
|
||||
body_text = await resp.aread()
|
||||
try:
|
||||
err = json.loads(body_text)
|
||||
msg = err.get("error", {}).get("message", "") or str(err)
|
||||
except Exception:
|
||||
msg = body_text.decode(errors="replace")[:200]
|
||||
raise RuntimeError(f"API错误 ({resp.status_code}): {msg}")
|
||||
|
||||
ct = resp.headers.get("content-type", "")
|
||||
if "text/html" in ct:
|
||||
raise RuntimeError(f"API地址返回了HTML页面,请检查API地址是否正确(当前: {url})")
|
||||
|
||||
async for line in resp.aiter_lines():
|
||||
if not line.startswith("data: "):
|
||||
continue
|
||||
data = line[6:]
|
||||
if data.strip() == "[DONE]":
|
||||
break
|
||||
try:
|
||||
chunk = json.loads(data)
|
||||
# 提取usage(OpenAI在最后一个chunk返回)
|
||||
usage = chunk.get("usage")
|
||||
if usage:
|
||||
yield StreamEvent(usage={
|
||||
"prompt_tokens": usage.get("prompt_tokens", 0),
|
||||
"completion_tokens": usage.get("completion_tokens", 0),
|
||||
"total_tokens": usage.get("total_tokens", 0),
|
||||
})
|
||||
choices = chunk.get("choices", [])
|
||||
if choices:
|
||||
delta = choices[0].get("delta", {})
|
||||
content = delta.get("content")
|
||||
if content:
|
||||
yield StreamEvent(text=content)
|
||||
except (json.JSONDecodeError, KeyError, IndexError):
|
||||
continue
|
||||
|
||||
|
||||
async def _stream_anthropic(
|
||||
config: AIConfig,
|
||||
messages: list[dict],
|
||||
system_prompt: str,
|
||||
) -> AsyncGenerator[StreamEvent, None]:
|
||||
"""调用 Anthropic Messages API(stream=true)。"""
|
||||
url = f"{config.base_url.rstrip('/')}/messages"
|
||||
|
||||
body: dict = {
|
||||
"model": config.model,
|
||||
"max_tokens": 4096,
|
||||
"messages": messages,
|
||||
"stream": True,
|
||||
}
|
||||
if system_prompt:
|
||||
body["system"] = system_prompt
|
||||
|
||||
async with httpx.AsyncClient(timeout=120) as client:
|
||||
async with client.stream(
|
||||
"POST",
|
||||
url,
|
||||
headers={
|
||||
"x-api-key": config.api_key,
|
||||
"anthropic-version": "2023-06-01",
|
||||
"content-type": "application/json",
|
||||
},
|
||||
json=body,
|
||||
) as resp:
|
||||
if resp.status_code >= 400:
|
||||
body_text = await resp.aread()
|
||||
try:
|
||||
err = json.loads(body_text)
|
||||
msg = err.get("error", {}).get("message", "") or str(err)
|
||||
except Exception:
|
||||
msg = body_text.decode(errors="replace")[:200]
|
||||
raise RuntimeError(f"API错误 ({resp.status_code}): {msg}")
|
||||
|
||||
ct = resp.headers.get("content-type", "")
|
||||
if "text/html" in ct:
|
||||
raise RuntimeError(f"API地址返回了HTML页面,请检查API地址是否正确(当前: {url})")
|
||||
|
||||
async for line in resp.aiter_lines():
|
||||
if not line.startswith("data: "):
|
||||
continue
|
||||
try:
|
||||
event = json.loads(line[6:])
|
||||
if event.get("type") == "content_block_delta":
|
||||
text = event.get("delta", {}).get("text", "")
|
||||
if text:
|
||||
yield StreamEvent(text=text)
|
||||
elif event.get("type") == "message_delta":
|
||||
usage = event.get("usage", {})
|
||||
if usage:
|
||||
yield StreamEvent(usage={
|
||||
"prompt_tokens": 0,
|
||||
"completion_tokens": usage.get("output_tokens", 0),
|
||||
"total_tokens": 0,
|
||||
})
|
||||
elif event.get("type") == "message_start":
|
||||
msg = event.get("message", {})
|
||||
usage = msg.get("usage", {})
|
||||
if usage:
|
||||
yield StreamEvent(usage={
|
||||
"prompt_tokens": usage.get("input_tokens", 0),
|
||||
"completion_tokens": 0,
|
||||
"total_tokens": 0,
|
||||
})
|
||||
elif event.get("type") == "message_stop":
|
||||
break
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
3
backend/requirements.txt
Normal file
3
backend/requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
fastapi>=0.115.0
|
||||
uvicorn[standard]>=0.34.0
|
||||
sqlmodel>=0.0.22
|
||||
Reference in New Issue
Block a user