feature:新增提示词工坊功能

This commit is contained in:
xiamuceer-j
2026-01-27 13:57:32 +08:00
parent 0c3fc6c912
commit 7b72691080
15 changed files with 2252 additions and 27 deletions
+13
View File
@@ -92,3 +92,16 @@ LOCAL_AUTH_DISPLAY_NAME=本地管理员
# ==========================================
SESSION_EXPIRE_MINUTES=120
SESSION_REFRESH_THRESHOLD_MINUTES=30
# ==========================================
# 提示词工坊配置
# ==========================================
# 运行模式:client(本地部署)或 server(云端服务器)
# 只有 mumuverse.space:1566 需要设置为 server
WORKSHOP_MODE=client
# 云端服务地址(client 模式使用)
WORKSHOP_CLOUD_URL=https://mumuverse.space:1566
# 云端 API 请求超时时间(秒)
WORKSHOP_API_TIMEOUT=30
+1
View File
@@ -0,0 +1 @@
eaf3cae6-4a6
@@ -0,0 +1,108 @@
"""添加提示词工坊相关表结构
Revision ID: 927bcb55b756
Revises: 951919659e0f
Create Date: 2026-01-27 13:54:34.486645
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '927bcb55b756'
down_revision: Union[str, None] = '951919659e0f'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('prompt_submissions',
sa.Column('id', sa.String(length=36), nullable=False, comment='UUID'),
sa.Column('submitter_id', sa.String(length=255), nullable=False, comment='提交者标识(实例ID:用户ID'),
sa.Column('submitter_name', sa.String(length=100), nullable=True, comment='提交者显示名称'),
sa.Column('source_instance', sa.String(length=255), nullable=False, comment='来源实例标识'),
sa.Column('name', sa.String(length=100), nullable=False, comment='提示词名称'),
sa.Column('description', sa.Text(), nullable=True, comment='提示词描述'),
sa.Column('prompt_content', sa.Text(), nullable=False, comment='提示词内容'),
sa.Column('category', sa.String(length=50), nullable=True, comment='分类'),
sa.Column('tags', sa.JSON(), nullable=True, comment='标签数组'),
sa.Column('author_display_name', sa.String(length=100), nullable=True, comment='希望显示的作者名'),
sa.Column('is_anonymous', sa.Boolean(), nullable=True, comment='是否匿名发布'),
sa.Column('status', sa.String(length=20), nullable=True, comment='状态:pending/approved/rejected'),
sa.Column('reviewer_id', sa.String(length=100), nullable=True, comment='审核人ID(云端管理员)'),
sa.Column('review_note', sa.Text(), nullable=True, comment='审核备注(拒绝理由等)'),
sa.Column('reviewed_at', sa.DateTime(), nullable=True, comment='审核时间'),
sa.Column('workshop_item_id', sa.String(length=36), nullable=True, comment='审核通过后关联的工坊条目ID'),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True, comment='创建时间'),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True, comment='更新时间'),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('prompt_submissions', schema=None) as batch_op:
batch_op.create_index('idx_submissions_created_at', ['created_at'], unique=False)
batch_op.create_index('idx_submissions_source', ['source_instance'], unique=False)
batch_op.create_index('idx_submissions_status', ['status'], unique=False)
batch_op.create_index('idx_submissions_submitter', ['submitter_id'], unique=False)
op.create_table('prompt_workshop_items',
sa.Column('id', sa.String(length=36), nullable=False, comment='UUID'),
sa.Column('name', sa.String(length=100), nullable=False, comment='提示词名称'),
sa.Column('description', sa.Text(), nullable=True, comment='提示词描述'),
sa.Column('prompt_content', sa.Text(), nullable=False, comment='提示词内容'),
sa.Column('category', sa.String(length=50), nullable=True, comment='分类'),
sa.Column('tags', sa.JSON(), nullable=True, comment='标签数组'),
sa.Column('author_id', sa.String(length=255), nullable=True, comment='作者用户标识(实例ID:用户ID'),
sa.Column('author_name', sa.String(length=100), nullable=True, comment='作者显示名称'),
sa.Column('source_instance', sa.String(length=255), nullable=True, comment='来源实例标识'),
sa.Column('is_official', sa.Boolean(), nullable=True, comment='是否官方提示词'),
sa.Column('download_count', sa.Integer(), nullable=True, comment='下载/导入次数'),
sa.Column('like_count', sa.Integer(), nullable=True, comment='点赞数'),
sa.Column('status', sa.String(length=20), nullable=True, comment='状态:active/hidden/deprecated'),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True, comment='创建时间'),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True, comment='更新时间'),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('prompt_workshop_items', schema=None) as batch_op:
batch_op.create_index('idx_workshop_items_category', ['category'], unique=False)
batch_op.create_index('idx_workshop_items_created_at', ['created_at'], unique=False)
batch_op.create_index('idx_workshop_items_download_count', ['download_count'], unique=False)
batch_op.create_index('idx_workshop_items_status', ['status'], unique=False)
op.create_table('prompt_workshop_likes',
sa.Column('id', sa.String(length=36), nullable=False, comment='UUID'),
sa.Column('user_identifier', sa.String(length=255), nullable=False, comment='用户标识(实例ID:用户ID'),
sa.Column('workshop_item_id', sa.String(length=36), nullable=False, comment='工坊条目ID'),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True, comment='创建时间'),
sa.ForeignKeyConstraint(['workshop_item_id'], ['prompt_workshop_items.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('prompt_workshop_likes', schema=None) as batch_op:
batch_op.create_index('idx_likes_user_item', ['user_identifier', 'workshop_item_id'], unique=True)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('prompt_workshop_likes', schema=None) as batch_op:
batch_op.drop_index('idx_likes_user_item')
op.drop_table('prompt_workshop_likes')
with op.batch_alter_table('prompt_workshop_items', schema=None) as batch_op:
batch_op.drop_index('idx_workshop_items_status')
batch_op.drop_index('idx_workshop_items_download_count')
batch_op.drop_index('idx_workshop_items_created_at')
batch_op.drop_index('idx_workshop_items_category')
op.drop_table('prompt_workshop_items')
with op.batch_alter_table('prompt_submissions', schema=None) as batch_op:
batch_op.drop_index('idx_submissions_submitter')
batch_op.drop_index('idx_submissions_status')
batch_op.drop_index('idx_submissions_source')
batch_op.drop_index('idx_submissions_created_at')
op.drop_table('prompt_submissions')
# ### end Alembic commands ###
+760
View File
@@ -0,0 +1,760 @@
"""提示词工坊 API"""
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, or_
from typing import Optional
import uuid
from app.database import get_db
from app.config import settings, INSTANCE_ID, is_workshop_server
from app.models.writing_style import WritingStyle
from app.models.prompt_workshop import PromptWorkshopItem, PromptSubmission, PromptWorkshopLike
from app.schemas.prompt_workshop import (
ImportRequest, DownloadRequest, PromptSubmissionCreate,
ReviewRequest, AdminItemCreate, AdminItemUpdate
)
from app.services.workshop_client import workshop_client, WorkshopClientError
from app.constants.prompt_categories import PROMPT_CATEGORIES
from app.logger import get_logger
router = APIRouter(prefix="/prompt-workshop", tags=["prompt-workshop"])
logger = get_logger(__name__)
# ==================== 辅助函数 ====================
def get_current_user_id(request: Request) -> str:
"""获取当前登录用户ID"""
user_id = getattr(request.state, 'user_id', None)
if not user_id:
raise HTTPException(status_code=401, detail="未登录")
return user_id
def get_user_identifier(user_id: str) -> str:
"""生成云端用户标识"""
return f"{INSTANCE_ID}:{user_id}"
def _item_to_dict(item: PromptWorkshopItem, is_liked: bool = False) -> dict:
"""将模型转换为字典"""
return {
"id": item.id,
"name": item.name,
"description": item.description,
"prompt_content": item.prompt_content,
"category": item.category,
"tags": item.tags,
"author_name": item.author_name,
"is_official": item.is_official,
"download_count": item.download_count,
"like_count": item.like_count,
"is_liked": is_liked,
"created_at": item.created_at.isoformat() if item.created_at else None
}
def _submission_to_dict(submission: PromptSubmission) -> dict:
"""将提交记录转换为字典"""
return {
"id": submission.id,
"name": submission.name,
"description": submission.description,
"prompt_content": submission.prompt_content,
"category": submission.category,
"tags": submission.tags,
"author_display_name": submission.author_display_name,
"is_anonymous": submission.is_anonymous,
"status": submission.status,
"review_note": submission.review_note,
"reviewed_at": submission.reviewed_at.isoformat() if submission.reviewed_at else None,
"created_at": submission.created_at.isoformat() if submission.created_at else None,
"source_instance": submission.source_instance,
"submitter_name": submission.submitter_name
}
async def check_workshop_admin(request: Request):
"""检查是否为工坊管理员(必须是云端实例的管理员)"""
if not is_workshop_server():
raise HTTPException(status_code=403, detail="此功能仅在云端服务可用")
user = getattr(request.state, "user", None)
if not user:
raise HTTPException(status_code=401, detail="未登录")
if not user.is_admin:
raise HTTPException(status_code=403, detail="需要管理员权限")
return user
# ==================== 公开 API ====================
@router.get("/status")
async def get_status():
"""获取服务状态"""
result = {
"mode": settings.WORKSHOP_MODE,
"instance_id": INSTANCE_ID
}
if not is_workshop_server():
result["cloud_url"] = settings.WORKSHOP_CLOUD_URL
try:
result["cloud_connected"] = await workshop_client.check_connection()
except Exception:
result["cloud_connected"] = False
return result
@router.get("/items")
async def get_items(
request: Request,
category: Optional[str] = None,
search: Optional[str] = None,
tags: Optional[str] = None,
sort: str = "newest",
page: int = 1,
limit: int = 20,
db: AsyncSession = Depends(get_db)
):
"""获取提示词列表"""
user_id = getattr(request.state, 'user_id', None)
user_identifier = get_user_identifier(user_id) if user_id else None
if is_workshop_server():
# 服务端模式:直接查询本地数据库
return await _get_items_local(db, category, search, tags, sort, page, limit, user_identifier)
else:
# 客户端模式:代理到云端
try:
return await workshop_client.get_items(
category=category, search=search, tags=tags,
sort=sort, page=page, limit=limit,
user_identifier=user_identifier
)
except WorkshopClientError as e:
raise HTTPException(status_code=503, detail=str(e))
async def _get_items_local(
db: AsyncSession,
category: Optional[str],
search: Optional[str],
tags: Optional[str],
sort: str,
page: int,
limit: int,
user_identifier: Optional[str]
) -> dict:
"""本地查询提示词列表"""
# 构建查询
query = select(PromptWorkshopItem).where(PromptWorkshopItem.status == "active")
count_query = select(func.count(PromptWorkshopItem.id)).where(PromptWorkshopItem.status == "active")
if category:
query = query.where(PromptWorkshopItem.category == category)
count_query = count_query.where(PromptWorkshopItem.category == category)
if search:
search_filter = or_(
PromptWorkshopItem.name.ilike(f"%{search}%"),
PromptWorkshopItem.description.ilike(f"%{search}%")
)
query = query.where(search_filter)
count_query = count_query.where(search_filter)
# 排序
if sort == "popular":
query = query.order_by(PromptWorkshopItem.like_count.desc())
elif sort == "downloads":
query = query.order_by(PromptWorkshopItem.download_count.desc())
else: # newest
query = query.order_by(PromptWorkshopItem.created_at.desc())
# 计数
count_result = await db.execute(count_query)
total = count_result.scalar_one()
# 分页
query = query.offset((page - 1) * limit).limit(limit)
result = await db.execute(query)
items = result.scalars().all()
# 获取用户点赞状态
liked_ids = set()
if user_identifier:
like_result = await db.execute(
select(PromptWorkshopLike.workshop_item_id).where(
PromptWorkshopLike.user_identifier == user_identifier
)
)
liked_ids = {row[0] for row in like_result.fetchall()}
# 获取分类统计
cat_result = await db.execute(
select(
PromptWorkshopItem.category,
func.count(PromptWorkshopItem.id)
).where(PromptWorkshopItem.status == "active")
.group_by(PromptWorkshopItem.category)
)
categories = [
{"id": cat, "name": PROMPT_CATEGORIES.get(cat, cat), "count": count}
for cat, count in cat_result.fetchall()
]
return {
"success": True,
"data": {
"total": total,
"page": page,
"limit": limit,
"items": [
_item_to_dict(item, is_liked=item.id in liked_ids)
for item in items
],
"categories": categories
}
}
@router.get("/items/{item_id}")
async def get_item(item_id: str, db: AsyncSession = Depends(get_db)):
"""获取单个提示词详情"""
if is_workshop_server():
result = await db.execute(
select(PromptWorkshopItem).where(
PromptWorkshopItem.id == item_id,
PromptWorkshopItem.status == "active"
)
)
item = result.scalar_one_or_none()
if not item:
raise HTTPException(status_code=404, detail="提示词不存在")
return {"success": True, "data": _item_to_dict(item)}
else:
try:
return await workshop_client.get_item(item_id)
except WorkshopClientError as e:
raise HTTPException(status_code=503, detail=str(e))
@router.post("/items/{item_id}/import")
async def import_item(
item_id: str,
data: ImportRequest,
request: Request,
db: AsyncSession = Depends(get_db)
):
"""导入提示词到本地写作风格"""
user_id = get_current_user_id(request)
user_identifier = get_user_identifier(user_id)
# 获取提示词详情
if is_workshop_server():
result = await db.execute(
select(PromptWorkshopItem).where(PromptWorkshopItem.id == item_id)
)
item = result.scalar_one_or_none()
if not item:
raise HTTPException(status_code=404, detail="提示词不存在")
item_data = _item_to_dict(item)
# 增加下载计数
item.download_count += 1
await db.commit()
else:
# 从云端获取
try:
result = await workshop_client.get_item(item_id)
item_data = result.get("data", result)
# 通知云端增加下载计数
try:
await workshop_client.record_download(item_id, user_identifier)
except Exception as e:
logger.warning(f"通知云端下载计数失败: {e}")
except WorkshopClientError as e:
raise HTTPException(status_code=503, detail=str(e))
# 创建本地写作风格
count_result = await db.execute(
select(func.count(WritingStyle.id)).where(WritingStyle.user_id == user_id)
)
max_order = count_result.scalar_one()
new_style = WritingStyle(
user_id=user_id,
name=data.custom_name or item_data["name"],
style_type="custom",
description=f"从提示词工坊导入: {item_data.get('description', '') or ''}",
prompt_content=item_data["prompt_content"],
order_index=max_order + 1
)
db.add(new_style)
await db.commit()
await db.refresh(new_style)
return {
"success": True,
"message": "导入成功",
"writing_style": {
"id": new_style.id,
"name": new_style.name,
"style_type": new_style.style_type,
"prompt_content": new_style.prompt_content
}
}
@router.post("/items/{item_id}/like")
async def toggle_like(
item_id: str,
request: Request,
db: AsyncSession = Depends(get_db)
):
"""点赞/取消点赞"""
user_id = get_current_user_id(request)
user_identifier = get_user_identifier(user_id)
if is_workshop_server():
# 检查是否已点赞
result = await db.execute(
select(PromptWorkshopLike).where(
PromptWorkshopLike.user_identifier == user_identifier,
PromptWorkshopLike.workshop_item_id == item_id
)
)
existing_like = result.scalar_one_or_none()
# 获取提示词
item_result = await db.execute(
select(PromptWorkshopItem).where(PromptWorkshopItem.id == item_id)
)
item = item_result.scalar_one_or_none()
if not item:
raise HTTPException(status_code=404, detail="提示词不存在")
if existing_like:
# 取消点赞
await db.delete(existing_like)
item.like_count = max(0, item.like_count - 1)
liked = False
else:
# 添加点赞
new_like = PromptWorkshopLike(
id=str(uuid.uuid4()),
user_identifier=user_identifier,
workshop_item_id=item_id
)
db.add(new_like)
item.like_count += 1
liked = True
await db.commit()
return {"success": True, "liked": liked, "like_count": item.like_count}
else:
try:
return await workshop_client.toggle_like(item_id, user_identifier)
except WorkshopClientError as e:
raise HTTPException(status_code=503, detail=str(e))
@router.post("/items/{item_id}/download")
async def record_download(
item_id: str,
data: DownloadRequest,
db: AsyncSession = Depends(get_db)
):
"""记录下载(仅云端实例使用)"""
if not is_workshop_server():
raise HTTPException(status_code=403, detail="此接口仅供云端实例使用")
result = await db.execute(
select(PromptWorkshopItem).where(PromptWorkshopItem.id == item_id)
)
item = result.scalar_one_or_none()
if not item:
raise HTTPException(status_code=404, detail="提示词不存在")
item.download_count += 1
await db.commit()
return {"success": True, "download_count": item.download_count}
@router.post("/submit")
async def submit_prompt(
data: PromptSubmissionCreate,
request: Request,
db: AsyncSession = Depends(get_db)
):
"""提交提示词"""
user_id = get_current_user_id(request)
user_identifier = get_user_identifier(user_id)
# 获取用户显示名称
from app.user_manager import user_manager
user = await user_manager.get_user(user_id)
submitter_name = user.display_name if user else "未知用户"
if is_workshop_server():
# 直接创建提交记录
submission = PromptSubmission(
id=str(uuid.uuid4()),
submitter_id=user_identifier,
submitter_name=submitter_name,
source_instance=INSTANCE_ID,
name=data.name,
description=data.description,
prompt_content=data.prompt_content,
category=data.category,
tags=data.tags,
author_display_name=data.author_display_name or submitter_name,
is_anonymous=data.is_anonymous,
status="pending"
)
db.add(submission)
await db.commit()
await db.refresh(submission)
return {
"success": True,
"message": "提交成功,等待管理员审核",
"submission": {
"id": submission.id,
"status": submission.status,
"created_at": submission.created_at.isoformat() if submission.created_at else None
}
}
else:
try:
return await workshop_client.submit(
user_identifier=user_identifier,
submitter_name=submitter_name,
data=data.model_dump()
)
except WorkshopClientError as e:
raise HTTPException(status_code=503, detail=str(e))
@router.get("/my-submissions")
async def get_my_submissions(
request: Request,
status: Optional[str] = None,
db: AsyncSession = Depends(get_db)
):
"""获取我的提交记录"""
user_id = get_current_user_id(request)
user_identifier = get_user_identifier(user_id)
if is_workshop_server():
query = select(PromptSubmission).where(
PromptSubmission.submitter_id == user_identifier
)
if status:
query = query.where(PromptSubmission.status == status)
query = query.order_by(PromptSubmission.created_at.desc())
result = await db.execute(query)
submissions = result.scalars().all()
return {
"success": True,
"data": {
"total": len(submissions),
"items": [_submission_to_dict(s) for s in submissions]
}
}
else:
try:
return await workshop_client.get_submissions(user_identifier, status)
except WorkshopClientError as e:
raise HTTPException(status_code=503, detail=str(e))
@router.delete("/submissions/{submission_id}")
async def withdraw_submission(
submission_id: str,
request: Request,
db: AsyncSession = Depends(get_db)
):
"""撤回待审核的提交"""
user_id = get_current_user_id(request)
user_identifier = get_user_identifier(user_id)
if is_workshop_server():
result = await db.execute(
select(PromptSubmission).where(
PromptSubmission.id == submission_id,
PromptSubmission.submitter_id == user_identifier
)
)
submission = result.scalar_one_or_none()
if not submission:
raise HTTPException(status_code=404, detail="提交记录不存在")
if submission.status != "pending":
raise HTTPException(status_code=400, detail="只能撤回待审核的提交")
await db.delete(submission)
await db.commit()
return {"success": True, "message": "撤回成功"}
else:
try:
return await workshop_client.withdraw_submission(submission_id, user_identifier)
except WorkshopClientError as e:
raise HTTPException(status_code=503, detail=str(e))
# ==================== 管理员 API(仅云端实例) ====================
@router.get("/admin/submissions")
async def admin_get_submissions(
request: Request,
status: Optional[str] = None,
source: Optional[str] = None,
page: int = 1,
limit: int = 20,
db: AsyncSession = Depends(get_db)
):
"""获取待审核列表(管理员)"""
await check_workshop_admin(request)
query = select(PromptSubmission)
count_query = select(func.count(PromptSubmission.id))
if status and status != "all":
query = query.where(PromptSubmission.status == status)
count_query = count_query.where(PromptSubmission.status == status)
if source:
query = query.where(PromptSubmission.source_instance == source)
count_query = count_query.where(PromptSubmission.source_instance == source)
# 计数
count_result = await db.execute(count_query)
total = count_result.scalar_one()
# 待审核数量
pending_result = await db.execute(
select(func.count(PromptSubmission.id)).where(PromptSubmission.status == "pending")
)
pending_count = pending_result.scalar_one()
# 分页查询
query = query.order_by(PromptSubmission.created_at.desc())
query = query.offset((page - 1) * limit).limit(limit)
result = await db.execute(query)
submissions = result.scalars().all()
return {
"success": True,
"data": {
"total": total,
"pending_count": pending_count,
"page": page,
"limit": limit,
"items": [_submission_to_dict(s) for s in submissions]
}
}
@router.post("/admin/submissions/{submission_id}/review")
async def admin_review_submission(
submission_id: str,
data: ReviewRequest,
request: Request,
db: AsyncSession = Depends(get_db)
):
"""审核提交(管理员)"""
admin = await check_workshop_admin(request)
result = await db.execute(
select(PromptSubmission).where(PromptSubmission.id == submission_id)
)
submission = result.scalar_one_or_none()
if not submission:
raise HTTPException(status_code=404, detail="提交记录不存在")
if submission.status != "pending":
raise HTTPException(status_code=400, detail="该提交已被审核")
admin_user_id = getattr(admin, 'user_id', str(admin))
if data.action == "approve":
# 创建工坊条目
new_item = PromptWorkshopItem(
id=str(uuid.uuid4()),
name=submission.name,
description=submission.description,
prompt_content=submission.prompt_content,
category=data.category or submission.category,
tags=data.tags or submission.tags,
author_id=None if submission.is_anonymous else submission.submitter_id,
author_name=submission.author_display_name if not submission.is_anonymous else None,
source_instance=submission.source_instance,
is_official=False,
status="active"
)
db.add(new_item)
submission.status = "approved"
submission.workshop_item_id = new_item.id
submission.reviewer_id = admin_user_id
submission.review_note = data.review_note
submission.reviewed_at = func.now()
await db.commit()
await db.refresh(new_item)
return {
"success": True,
"message": "已通过审核并发布",
"workshop_item": _item_to_dict(new_item)
}
else:
submission.status = "rejected"
submission.reviewer_id = admin_user_id
submission.review_note = data.review_note
submission.reviewed_at = func.now()
await db.commit()
return {
"success": True,
"message": "已拒绝",
"submission": _submission_to_dict(submission)
}
@router.post("/admin/items")
async def admin_create_item(
data: AdminItemCreate,
request: Request,
db: AsyncSession = Depends(get_db)
):
"""添加官方提示词(管理员)"""
await check_workshop_admin(request)
new_item = PromptWorkshopItem(
id=str(uuid.uuid4()),
name=data.name,
description=data.description,
prompt_content=data.prompt_content,
category=data.category,
tags=data.tags,
author_name="官方",
is_official=True,
status="active"
)
db.add(new_item)
await db.commit()
await db.refresh(new_item)
return {"success": True, "item": _item_to_dict(new_item)}
@router.put("/admin/items/{item_id}")
async def admin_update_item(
item_id: str,
data: AdminItemUpdate,
request: Request,
db: AsyncSession = Depends(get_db)
):
"""编辑提示词(管理员)"""
await check_workshop_admin(request)
result = await db.execute(
select(PromptWorkshopItem).where(PromptWorkshopItem.id == item_id)
)
item = result.scalar_one_or_none()
if not item:
raise HTTPException(status_code=404, detail="提示词不存在")
update_data = data.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(item, key, value)
await db.commit()
await db.refresh(item)
return {"success": True, "item": _item_to_dict(item)}
@router.delete("/admin/items/{item_id}")
async def admin_delete_item(
item_id: str,
request: Request,
db: AsyncSession = Depends(get_db)
):
"""删除提示词(管理员)"""
await check_workshop_admin(request)
result = await db.execute(
select(PromptWorkshopItem).where(PromptWorkshopItem.id == item_id)
)
item = result.scalar_one_or_none()
if not item:
raise HTTPException(status_code=404, detail="提示词不存在")
await db.delete(item)
await db.commit()
return {"success": True, "message": "删除成功"}
@router.get("/admin/stats")
async def admin_get_stats(
request: Request,
db: AsyncSession = Depends(get_db)
):
"""获取统计数据(管理员)"""
await check_workshop_admin(request)
# 提示词总数
items_count = await db.execute(
select(func.count(PromptWorkshopItem.id)).where(PromptWorkshopItem.status == "active")
)
total_items = items_count.scalar_one()
# 官方提示词数量
official_count = await db.execute(
select(func.count(PromptWorkshopItem.id)).where(
PromptWorkshopItem.status == "active",
PromptWorkshopItem.is_official == True
)
)
total_official = official_count.scalar_one()
# 待审核数量
pending_count = await db.execute(
select(func.count(PromptSubmission.id)).where(PromptSubmission.status == "pending")
)
total_pending = pending_count.scalar_one()
# 总下载量
downloads_sum = await db.execute(
select(func.sum(PromptWorkshopItem.download_count))
)
total_downloads = downloads_sum.scalar_one() or 0
# 总点赞量
likes_sum = await db.execute(
select(func.sum(PromptWorkshopItem.like_count))
)
total_likes = likes_sum.scalar_one() or 0
return {
"success": True,
"data": {
"total_items": total_items,
"total_official": total_official,
"total_pending": total_pending,
"total_downloads": total_downloads,
"total_likes": total_likes
}
}
+30
View File
@@ -4,6 +4,7 @@ from typing import Optional
from pathlib import Path
import logging
import os
import uuid
# 获取项目根目录(从backend/app/config.py向上两级)
PROJECT_ROOT = Path(__file__).parent.parent
@@ -106,6 +107,11 @@ class Settings(BaseSettings):
SESSION_EXPIRE_MINUTES: int = 120 # 会话过期时间(分钟),默认2小时
SESSION_REFRESH_THRESHOLD_MINUTES: int = 30 # 会话刷新阈值(分钟),剩余时间少于此值时可刷新
# 提示词工坊配置
WORKSHOP_MODE: str = "client" # client: 本地部署实例, server: 云端中央服务器
WORKSHOP_CLOUD_URL: str = "https://mumuverse.space:1566" # 云端服务地址
WORKSHOP_API_TIMEOUT: int = 30 # 云端API请求超时时间(秒)
class Config:
env_file = ".env"
case_sensitive = False
@@ -117,3 +123,27 @@ settings = Settings()
config_logger.info(f"配置加载完成: {settings.app_name} v{settings.app_version}")
config_logger.debug(f"调试模式: {settings.debug}")
config_logger.debug(f"AI提供商: {settings.default_ai_provider}")
# ==================== 提示词工坊实例标识 ====================
def get_or_create_instance_id() -> str:
"""获取或创建实例唯一标识"""
instance_file = PROJECT_ROOT / ".instance_id"
if instance_file.exists():
with open(instance_file, 'r') as f:
return f.read().strip()
else:
instance_id = str(uuid.uuid4())[:12]
with open(instance_file, 'w') as f:
f.write(instance_id)
config_logger.info(f"生成新的实例标识: {instance_id}")
return instance_id
INSTANCE_ID = get_or_create_instance_id()
def is_workshop_server() -> bool:
"""判断当前实例是否为工坊服务端"""
return settings.WORKSHOP_MODE.lower() == "server"
config_logger.info(f"提示词工坊模式: {settings.WORKSHOP_MODE}, 实例ID: {INSTANCE_ID}")
@@ -0,0 +1,28 @@
"""提示词工坊分类常量"""
PROMPT_CATEGORIES = {
"general": "通用",
"fantasy": "玄幻/仙侠",
"martial": "武侠",
"romance": "言情",
"scifi": "科幻",
"horror": "悬疑/惊悚",
"history": "历史",
"urban": "都市",
"game": "游戏/电竞",
"other": "其他",
}
CATEGORY_LIST = [
{"id": k, "name": v} for k, v in PROMPT_CATEGORIES.items()
]
# 热门标签(建议)
POPULAR_TAGS = [
"玄幻", "仙侠", "修真", "升级流", "热血",
"武侠", "古风", "言情", "甜宠", "虐恋",
"科幻", "星际", "末日", "悬疑", "推理",
"惊悚", "历史", "架空", "都市", "职场",
"游戏", "电竞", "二次元", "轻小说", "系统流",
"无敌流", "慢热", "日常", "治愈"
]
+2 -1
View File
@@ -130,7 +130,7 @@ from app.api import (
wizard_stream, relationships, organizations,
auth, users, settings, writing_styles, memories,
mcp_plugins, admin, inspiration, prompt_templates,
changelog, careers, foreshadows
changelog, careers, foreshadows, prompt_workshop
)
app.include_router(auth.router, prefix="/api")
@@ -153,6 +153,7 @@ app.include_router(foreshadows.router) # 伏笔管理API (已包含/api前缀)
app.include_router(mcp_plugins.router, prefix="/api") # MCP插件管理API
app.include_router(prompt_templates.router, prefix="/api") # 提示词模板管理API
app.include_router(changelog.router, prefix="/api") # 更新日志API
app.include_router(prompt_workshop.router, prefix="/api") # 提示词工坊API
static_dir = Path(__file__).parent.parent / "static"
if static_dir.exists():
+5 -1
View File
@@ -17,6 +17,7 @@ from app.models.regeneration_task import RegenerationTask
from app.models.career import Career, CharacterCareer
from app.models.prompt_template import PromptTemplate
from app.models.foreshadow import Foreshadow
from app.models.prompt_workshop import PromptWorkshopItem, PromptSubmission, PromptWorkshopLike
__all__ = [
"Project",
@@ -42,5 +43,8 @@ __all__ = [
"Career",
"CharacterCareer",
"PromptTemplate",
"Foreshadow"
"Foreshadow",
"PromptWorkshopItem",
"PromptSubmission",
"PromptWorkshopLike"
]
+89
View File
@@ -0,0 +1,89 @@
"""提示词工坊数据模型"""
from sqlalchemy import Column, String, Text, Boolean, DateTime, Integer, JSON, ForeignKey, Index
from sqlalchemy.sql import func
from app.database import Base
class PromptWorkshopItem(Base):
"""提示词工坊条目 - 已审核通过的公开提示词"""
__tablename__ = "prompt_workshop_items"
id = Column(String(36), primary_key=True, comment="UUID")
name = Column(String(100), nullable=False, comment="提示词名称")
description = Column(Text, comment="提示词描述")
prompt_content = Column(Text, nullable=False, comment="提示词内容")
category = Column(String(50), default="general", comment="分类")
tags = Column(JSON, comment="标签数组")
author_id = Column(String(255), comment="作者用户标识(实例ID:用户ID")
author_name = Column(String(100), comment="作者显示名称")
source_instance = Column(String(255), comment="来源实例标识")
is_official = Column(Boolean, default=False, comment="是否官方提示词")
download_count = Column(Integer, default=0, comment="下载/导入次数")
like_count = Column(Integer, default=0, comment="点赞数")
status = Column(String(20), default="active", comment="状态:active/hidden/deprecated")
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间")
__table_args__ = (
Index('idx_workshop_items_category', 'category'),
Index('idx_workshop_items_status', 'status'),
Index('idx_workshop_items_download_count', 'download_count'),
Index('idx_workshop_items_created_at', 'created_at'),
)
def __repr__(self):
return f"<PromptWorkshopItem(id={self.id}, name={self.name})>"
class PromptSubmission(Base):
"""用户提交的待审核提示词"""
__tablename__ = "prompt_submissions"
id = Column(String(36), primary_key=True, comment="UUID")
submitter_id = Column(String(255), nullable=False, comment="提交者标识(实例ID:用户ID")
submitter_name = Column(String(100), comment="提交者显示名称")
source_instance = Column(String(255), nullable=False, comment="来源实例标识")
name = Column(String(100), nullable=False, comment="提示词名称")
description = Column(Text, comment="提示词描述")
prompt_content = Column(Text, nullable=False, comment="提示词内容")
category = Column(String(50), default="general", comment="分类")
tags = Column(JSON, comment="标签数组")
author_display_name = Column(String(100), comment="希望显示的作者名")
is_anonymous = Column(Boolean, default=False, comment="是否匿名发布")
# 审核相关
status = Column(String(20), default="pending", comment="状态:pending/approved/rejected")
reviewer_id = Column(String(100), comment="审核人ID(云端管理员)")
review_note = Column(Text, comment="审核备注(拒绝理由等)")
reviewed_at = Column(DateTime, comment="审核时间")
workshop_item_id = Column(String(36), comment="审核通过后关联的工坊条目ID")
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间")
__table_args__ = (
Index('idx_submissions_submitter', 'submitter_id'),
Index('idx_submissions_source', 'source_instance'),
Index('idx_submissions_status', 'status'),
Index('idx_submissions_created_at', 'created_at'),
)
def __repr__(self):
return f"<PromptSubmission(id={self.id}, name={self.name}, status={self.status})>"
class PromptWorkshopLike(Base):
"""提示词点赞记录"""
__tablename__ = "prompt_workshop_likes"
id = Column(String(36), primary_key=True, comment="UUID")
user_identifier = Column(String(255), nullable=False, comment="用户标识(实例ID:用户ID")
workshop_item_id = Column(String(36), ForeignKey("prompt_workshop_items.id", ondelete="CASCADE"), nullable=False, comment="工坊条目ID")
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
__table_args__ = (
Index('idx_likes_user_item', 'user_identifier', 'workshop_item_id', unique=True),
)
def __repr__(self):
return f"<PromptWorkshopLike(user={self.user_identifier}, item={self.workshop_item_id})>"
+119
View File
@@ -0,0 +1,119 @@
"""提示词工坊 Pydantic Schema"""
from pydantic import BaseModel, Field
from typing import Optional, List
from datetime import datetime
# ==================== 请求模型 ====================
class ImportRequest(BaseModel):
"""导入提示词请求"""
custom_name: Optional[str] = Field(None, max_length=100, description="自定义名称")
class DownloadRequest(BaseModel):
"""记录下载请求(云端使用)"""
instance_id: str = Field(..., description="实例标识")
user_identifier: str = Field(..., description="用户标识")
class PromptSubmissionCreate(BaseModel):
"""提交提示词请求"""
name: str = Field(..., max_length=100, description="提示词名称")
description: Optional[str] = Field(None, description="提示词描述")
prompt_content: str = Field(..., description="提示词内容")
category: str = Field(default="general", max_length=50, description="分类")
tags: Optional[List[str]] = Field(None, description="标签列表")
author_display_name: Optional[str] = Field(None, max_length=100, description="作者显示名")
is_anonymous: bool = Field(default=False, description="是否匿名发布")
source_style_id: Optional[int] = Field(None, description="来源写作风格ID")
class ReviewRequest(BaseModel):
"""审核请求"""
action: str = Field(..., pattern="^(approve|reject)$", description="操作:approve/reject")
review_note: Optional[str] = Field(None, description="审核备注")
category: Optional[str] = Field(None, description="分类(可调整)")
tags: Optional[List[str]] = Field(None, description="标签(可调整)")
class AdminItemCreate(BaseModel):
"""管理员创建提示词"""
name: str = Field(..., max_length=100, description="提示词名称")
description: Optional[str] = Field(None, description="提示词描述")
prompt_content: str = Field(..., description="提示词内容")
category: str = Field(default="general", description="分类")
tags: Optional[List[str]] = Field(None, description="标签列表")
class AdminItemUpdate(BaseModel):
"""管理员更新提示词"""
name: Optional[str] = Field(None, max_length=100, description="提示词名称")
description: Optional[str] = Field(None, description="提示词描述")
prompt_content: Optional[str] = Field(None, description="提示词内容")
category: Optional[str] = Field(None, description="分类")
tags: Optional[List[str]] = Field(None, description="标签列表")
status: Optional[str] = Field(None, description="状态")
# ==================== 响应模型 ====================
class PromptWorkshopItemResponse(BaseModel):
"""提示词条目响应"""
id: str
name: str
description: Optional[str] = None
prompt_content: str
category: str
tags: Optional[List[str]] = None
author_name: Optional[str] = None
is_official: bool
download_count: int
like_count: int
is_liked: Optional[bool] = None
created_at: Optional[datetime] = None
class Config:
from_attributes = True
class PromptSubmissionResponse(BaseModel):
"""提交记录响应"""
id: str
name: str
description: Optional[str] = None
prompt_content: Optional[str] = None
category: str
tags: Optional[List[str]] = None
author_display_name: Optional[str] = None
is_anonymous: bool
status: str
review_note: Optional[str] = None
reviewed_at: Optional[datetime] = None
created_at: Optional[datetime] = None
source_instance: Optional[str] = None
submitter_name: Optional[str] = None
class Config:
from_attributes = True
class CategoryInfo(BaseModel):
"""分类信息"""
id: str
name: str
count: int
class WorkshopItemsListResponse(BaseModel):
"""提示词列表响应"""
success: bool = True
data: dict # 包含 total, page, limit, items, categories
class WorkshopStatusResponse(BaseModel):
"""服务状态响应"""
mode: str
instance_id: str
cloud_url: Optional[str] = None
cloud_connected: Optional[bool] = None
+169
View File
@@ -0,0 +1,169 @@
"""云端提示词工坊 API 客户端(client 模式使用)"""
import httpx
from typing import Optional, Dict, Any
from app.config import settings, INSTANCE_ID
from app.logger import get_logger
logger = get_logger(__name__)
class WorkshopClientError(Exception):
"""工坊客户端错误"""
pass
class WorkshopClient:
"""云端 API 客户端"""
def __init__(self):
self.base_url = settings.WORKSHOP_CLOUD_URL
self.timeout = settings.WORKSHOP_API_TIMEOUT
async def _request(
self,
method: str,
path: str,
params: Optional[Dict] = None,
json: Optional[Dict] = None,
user_identifier: Optional[str] = None
) -> Dict[str, Any]:
"""发送请求到云端"""
headers = {
"X-Instance-ID": INSTANCE_ID,
"Content-Type": "application/json"
}
if user_identifier:
headers["X-User-ID"] = user_identifier
url = f"{self.base_url}/api/prompt-workshop{path}"
try:
async with httpx.AsyncClient(timeout=self.timeout, verify=False) as client:
response = await client.request(
method=method,
url=url,
params=params,
json=json,
headers=headers
)
response.raise_for_status()
return response.json()
except httpx.ConnectError as e:
logger.error(f"无法连接到云端服务: {self.base_url}, 错误: {e}")
raise WorkshopClientError("无法连接到云端服务,请检查网络连接")
except httpx.TimeoutException:
logger.error(f"云端服务请求超时: {url}")
raise WorkshopClientError("云端服务请求超时,请稍后重试")
except httpx.HTTPStatusError as e:
logger.error(f"云端服务返回错误: {e.response.status_code}, {e.response.text}")
raise WorkshopClientError(f"云端服务错误: {e.response.status_code}")
except Exception as e:
logger.error(f"请求云端服务异常: {e}")
raise WorkshopClientError(f"请求云端服务失败: {str(e)}")
async def check_connection(self) -> bool:
"""检查云端连接状态"""
try:
await self._request("GET", "/status")
return True
except Exception as e:
logger.warning(f"云端连接检查失败: {e}")
return False
async def get_items(
self,
category: Optional[str] = None,
search: Optional[str] = None,
tags: Optional[str] = None,
sort: str = "newest",
page: int = 1,
limit: int = 20,
user_identifier: Optional[str] = None
) -> Dict:
"""获取提示词列表"""
params = {
"sort": sort,
"page": page,
"limit": limit
}
if category:
params["category"] = category
if search:
params["search"] = search
if tags:
params["tags"] = tags
return await self._request(
"GET", "/items",
params=params,
user_identifier=user_identifier
)
async def get_item(self, item_id: str) -> Dict:
"""获取单个提示词详情"""
return await self._request("GET", f"/items/{item_id}")
async def record_download(self, item_id: str, user_identifier: str) -> Dict:
"""记录下载"""
return await self._request(
"POST",
f"/items/{item_id}/download",
json={
"instance_id": INSTANCE_ID,
"user_identifier": user_identifier
}
)
async def toggle_like(self, item_id: str, user_identifier: str) -> Dict:
"""点赞/取消点赞"""
return await self._request(
"POST",
f"/items/{item_id}/like",
user_identifier=user_identifier
)
async def submit(
self,
user_identifier: str,
submitter_name: str,
data: Dict
) -> Dict:
"""提交提示词"""
payload = {
"instance_id": INSTANCE_ID,
"submitter_id": user_identifier,
"submitter_name": submitter_name,
**data
}
return await self._request("POST", "/submit", json=payload)
async def get_submissions(
self,
user_identifier: str,
status: Optional[str] = None
) -> Dict:
"""获取用户的提交记录"""
params = {}
if status:
params["status"] = status
return await self._request(
"GET", "/my-submissions",
params=params,
user_identifier=user_identifier
)
async def withdraw_submission(
self,
submission_id: str,
user_identifier: str
) -> Dict:
"""撤回提交"""
return await self._request(
"DELETE",
f"/submissions/{submission_id}",
user_identifier=user_identifier
)
# 全局客户端实例
workshop_client = WorkshopClient()