Spaces:
Paused
Paused
File size: 16,823 Bytes
7c15d35 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 | #!/usr/bin/env python3
"""
AI 学术写作助手 - 统一入口
将前后端整合为一个可执行文件
"""
import os
import sys
import webbrowser
import threading
import time
import signal
from typing import Optional
# 获取应用运行目录
if getattr(sys, 'frozen', False):
# PyInstaller 打包后的 exe 运行
APP_DIR = os.path.dirname(sys.executable)
# 静态文件在 exe 内部的 _internal 目录或与 exe 同级目录
STATIC_DIR = os.path.join(sys._MEIPASS, 'static')
else:
# 正常 Python 运行
APP_DIR = os.path.dirname(os.path.abspath(__file__))
STATIC_DIR = os.path.join(APP_DIR, 'static')
# 设置工作目录为应用目录(确保数据库和配置文件在正确位置)
os.chdir(APP_DIR)
# 设置环境变量指向 exe 同目录的 .env 文件
ENV_FILE = os.path.join(APP_DIR, '.env')
DB_FILE = os.path.join(APP_DIR, 'ai_polish.db')
# 加载环境变量
if os.path.exists(ENV_FILE):
from dotenv import load_dotenv
load_dotenv(ENV_FILE)
# 设置默认数据库路径到 exe 同目录
if 'DATABASE_URL' not in os.environ:
os.environ['DATABASE_URL'] = f"sqlite:///{DB_FILE}"
# 添加 backend 到 Python 路径
backend_path = os.path.join(APP_DIR, 'backend') if not getattr(sys, 'frozen', False) else APP_DIR
if backend_path not in sys.path:
sys.path.insert(0, backend_path)
from fastapi import FastAPI, Request, HTTPException, Response
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
import uvicorn
# 导入后端应用组件
from app.config import settings
from app.database import init_db
from app.routes import admin, prompts, optimization
from app.word_formatter import router as word_formatter_router
from app.word_formatter.services import get_job_manager
from app.models.models import CustomPrompt
from app.database import SessionLocal
from app.services.ai_service import get_default_polish_prompt, get_default_enhance_prompt
# 检查默认密钥(仅警告,不退出)
if settings.SECRET_KEY == "your-secret-key-change-this-in-production":
print("\n" + "="*60)
print("⚠️ 安全警告: 检测到默认 SECRET_KEY!")
print("="*60)
print("生产环境必须修改 SECRET_KEY,否则 JWT token 可被伪造!")
print(f"请在 {ENV_FILE} 文件中设置强密钥:")
print(" 使用命令生成: python -c \"import secrets; print(secrets.token_urlsafe(32))\"")
print("="*60 + "\n")
if settings.ADMIN_PASSWORD == "admin123":
print("\n" + "="*60)
print("⚠️ 安全警告: 检测到默认管理员密码!")
print("="*60)
print("生产环境必须修改 ADMIN_PASSWORD!")
print(f"请在 {ENV_FILE} 文件中设置强密码 (建议12位以上)")
print("="*60 + "\n")
# 创建 FastAPI 应用
app = FastAPI(
title="AI 论文润色增强系统",
description="高质量论文润色与原创性学术表达增强",
version="1.0.0"
)
# 添加 Gzip 压缩中间件以减少响应体积
app.add_middleware(GZipMiddleware, minimum_size=1000)
# CORS 配置
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 添加中间件:为所有 API 响应添加禁止缓存的头部
@app.middleware("http")
async def add_no_cache_headers(request: Request, call_next):
"""为 API 请求添加禁止缓存的响应头"""
response = await call_next(request)
# 只对 API 路径添加禁止缓存头,静态资源可以缓存
if request.url.path.startswith('/api/'):
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate, max-age=0"
response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0"
return response
# 注册 API 路由(添加 /api 前缀,与 backend/app/main.py 保持一致)
app.include_router(admin.router, prefix="/api")
app.include_router(prompts.router, prefix="/api")
app.include_router(optimization.router, prefix="/api")
app.include_router(word_formatter_router, prefix="/api")
@app.on_event("startup")
async def startup_event():
"""启动时初始化"""
print(f"\n📁 应用目录: {APP_DIR}")
print(f"📁 配置文件: {ENV_FILE}")
print(f"📁 数据库文件: {DB_FILE}")
print(f"📁 静态文件目录: {STATIC_DIR}")
# 初始化数据库
init_db()
# 创建系统默认提示词
db = SessionLocal()
try:
# 检查是否已存在系统提示词
polish_prompt = db.query(CustomPrompt).filter(
CustomPrompt.is_system.is_(True),
CustomPrompt.stage == "polish"
).first()
if not polish_prompt:
polish_prompt = CustomPrompt(
name="默认润色提示词",
stage="polish",
content=get_default_polish_prompt(),
is_default=True,
is_system=True
)
db.add(polish_prompt)
enhance_prompt = db.query(CustomPrompt).filter(
CustomPrompt.is_system.is_(True),
CustomPrompt.stage == "enhance"
).first()
if not enhance_prompt:
enhance_prompt = CustomPrompt(
name="默认增强提示词",
stage="enhance",
content=get_default_enhance_prompt(),
is_default=True,
is_system=True
)
db.add(enhance_prompt)
db.commit()
finally:
db.close()
@app.on_event("shutdown")
async def shutdown_event():
"""关闭时清理资源"""
job_manager = get_job_manager()
await job_manager.shutdown()
@app.get("/health")
async def health_check():
"""健康检查"""
return JSONResponse(
content={"status": "healthy"},
headers={
"Cache-Control": "no-cache, no-store, must-revalidate",
"Pragma": "no-cache",
"Expires": "0"
}
)
def _check_url_format(base_url: Optional[str]) -> tuple:
"""检查 URL 格式是否正确
Returns:
tuple: (is_valid, error_message)
"""
import re
if not base_url or not base_url.strip():
return False, "Base URL 未配置"
# 验证 base_url 是否符合 OpenAI API 格式
# 使用更严格的 URL 验证模式
url_pattern = re.compile(r'^https?://[^\s/$.?#].[^\s]*$', re.IGNORECASE)
if not url_pattern.match(base_url):
return False, "Base URL 格式不正确,应为有效的 HTTP/HTTPS URL"
return True, None
# 缓存已检查的 URL 结果,避免重复检查
_url_check_cache: dict = {}
async def _check_model_health(model_name: str, model: str, api_key: Optional[str], base_url: Optional[str]) -> dict:
"""检查单个模型的健康状态 - 只验证URL格式,不测试实际连接"""
try:
# 检查必需的配置项
if not model or not model.strip():
return {
"status": "unavailable",
"model": model,
"base_url": base_url,
"error": "模型名称未配置"
}
# 先检查 URL 格式是否有效
is_valid, error_msg = _check_url_format(base_url)
if not is_valid:
return {
"status": "unavailable",
"model": model,
"base_url": base_url,
"error": error_msg
}
# URL 有效时才检查缓存(此时 base_url 不为 None)
if base_url in _url_check_cache:
cached_result = _url_check_cache[base_url]
result = {
"status": cached_result["status"],
"model": model,
"base_url": base_url
}
if cached_result["status"] == "unavailable":
result["error"] = cached_result.get("error")
return result
# URL 格式正确,认为配置有效
result = {
"status": "available",
"model": model,
"base_url": base_url
}
# 缓存检查结果
_url_check_cache[base_url] = {"status": "available"}
return result
except Exception as e:
error_msg = str(e) if str(e) else "未知错误"
return {
"status": "unavailable",
"model": model,
"base_url": base_url,
"error": error_msg
}
@app.get("/api/health/models")
async def check_models_health():
"""检查 AI 模型可用性 - 只验证URL格式,如果URL相同则只检查一次"""
global _url_check_cache
# 清空缓存以确保每次请求都重新检查
_url_check_cache = {}
results = {
"overall_status": "healthy",
"models": {}
}
# 检查润色模型
results["models"]["polish"] = await _check_model_health(
"polish",
settings.POLISH_MODEL,
settings.POLISH_API_KEY,
settings.POLISH_BASE_URL
)
if results["models"]["polish"]["status"] == "unavailable":
results["overall_status"] = "degraded"
# 检查增强模型
results["models"]["enhance"] = await _check_model_health(
"enhance",
settings.ENHANCE_MODEL,
settings.ENHANCE_API_KEY,
settings.ENHANCE_BASE_URL
)
if results["models"]["enhance"]["status"] == "unavailable":
results["overall_status"] = "degraded"
# 检查感情润色模型(如果配置了)
if settings.EMOTION_MODEL:
results["models"]["emotion"] = await _check_model_health(
"emotion",
settings.EMOTION_MODEL,
settings.EMOTION_API_KEY,
settings.EMOTION_BASE_URL
)
if results["models"]["emotion"]["status"] == "unavailable":
results["overall_status"] = "degraded"
# 返回带缓存控制头的响应,确保数据始终是最新的
return JSONResponse(
content=results,
headers={
"Cache-Control": "no-cache, no-store, must-revalidate",
"Pragma": "no-cache",
"Expires": "0"
}
)
# 挂载静态文件(前端构建产物)
if os.path.exists(STATIC_DIR):
# 挂载 assets 目录(JS, CSS 等)
assets_dir = os.path.join(STATIC_DIR, 'assets')
if os.path.exists(assets_dir):
app.mount("/assets", StaticFiles(directory=assets_dir), name="assets")
# 处理根路径和其他前端路由
@app.get("/")
async def serve_root():
"""服务根路径"""
index_file = os.path.join(STATIC_DIR, 'index.html')
if os.path.exists(index_file):
return FileResponse(index_file)
return {"message": "AI 论文润色增强系统 API", "version": "1.0.0", "docs": "/docs"}
@app.get("/admin")
@app.get("/admin/{path:path}")
async def serve_admin(path: str = ""):
"""服务管理后台页面"""
index_file = os.path.join(STATIC_DIR, 'index.html')
if os.path.exists(index_file):
return FileResponse(index_file)
return {"error": "Admin page not found"}
@app.get("/workspace")
@app.get("/workspace/{path:path}")
async def serve_workspace(path: str = ""):
"""服务工作区页面"""
index_file = os.path.join(STATIC_DIR, 'index.html')
if os.path.exists(index_file):
return FileResponse(index_file)
return {"error": "Workspace page not found"}
@app.get("/word-formatter")
@app.get("/word-formatter/{path:path}")
async def serve_word_formatter(path: str = ""):
"""服务 Word 格式化页面"""
index_file = os.path.join(STATIC_DIR, 'index.html')
if os.path.exists(index_file):
return FileResponse(index_file)
return {"error": "Word formatter page not found"}
@app.get("/session/{session_id}")
async def serve_session(session_id: str):
"""服务会话详情页面"""
index_file = os.path.join(STATIC_DIR, 'index.html')
if os.path.exists(index_file):
return FileResponse(index_file)
return {"error": "Session page not found"}
@app.get("/access/{card_key}")
async def serve_access(card_key: str):
"""服务访问页面"""
index_file = os.path.join(STATIC_DIR, 'index.html')
if os.path.exists(index_file):
return FileResponse(index_file)
return {"error": "Access page not found"}
# 处理其他静态文件
@app.get("/{file_path:path}")
async def serve_static(file_path: str):
"""服务其他静态文件"""
# 如果是 API 路径,抛出 404 让 FastAPI 正确处理
if file_path.startswith('api/') or file_path.startswith('docs') or file_path.startswith('openapi'):
raise HTTPException(status_code=404, detail="Not found")
full_path = os.path.join(STATIC_DIR, file_path)
if os.path.exists(full_path) and os.path.isfile(full_path):
return FileResponse(full_path)
# 对于 SPA 路由,返回 index.html
index_file = os.path.join(STATIC_DIR, 'index.html')
if os.path.exists(index_file):
return FileResponse(index_file)
raise HTTPException(status_code=404, detail="File not found")
else:
@app.get("/")
async def root():
"""根路径"""
return {
"message": "AI 论文润色增强系统 API",
"version": "1.0.0",
"docs": "/docs",
"note": "静态文件目录不存在,仅 API 可用"
}
def open_browser(port: int):
"""延迟打开浏览器"""
time.sleep(2) # 等待服务器启动
url = f"http://localhost:{port}"
print(f"\n🌐 正在打开浏览器: {url}")
webbrowser.open(url)
def create_sample_env():
"""创建示例 .env 文件(如果不存在)"""
if not os.path.exists(ENV_FILE):
sample_content = """# AI 学术写作助手配置文件
# 请根据实际情况修改以下配置
# 数据库配置 (SQLite 默认在 exe 同目录)
# DATABASE_URL=sqlite:///./ai_polish.db
# Redis 配置 (用于并发控制和队列)
REDIS_URL=redis://localhost:6379/0
# OpenAI API 配置
OPENAI_API_KEY=your-api-key-here
OPENAI_BASE_URL=https://api.openai.com/v1
# 第一阶段模型配置 (论文润色) - 推荐使用 gemini-2.5-pro
POLISH_MODEL=gemini-2.5-pro
POLISH_API_KEY=your-api-key-here
POLISH_BASE_URL=https://api.openai.com/v1
# 第二阶段模型配置 (原创性增强) - 推荐使用 gemini-2.5-pro
ENHANCE_MODEL=gemini-2.5-pro
ENHANCE_API_KEY=your-api-key-here
ENHANCE_BASE_URL=https://api.openai.com/v1
# 感情文章润色模型配置 - 推荐使用 gemini-2.5-pro
EMOTION_MODEL=gemini-2.5-pro
EMOTION_API_KEY=your-api-key-here
EMOTION_BASE_URL=https://api.openai.com/v1
# 并发配置
MAX_CONCURRENT_USERS=7
# 会话压缩配置
HISTORY_COMPRESSION_THRESHOLD=2000
COMPRESSION_MODEL=gemini-2.5-pro
COMPRESSION_API_KEY=your-api-key-here
COMPRESSION_BASE_URL=https://api.openai.com/v1
# JWT 密钥 (请修改为随机字符串)
SECRET_KEY=please-change-this-to-a-random-string-32-chars
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=60
# 管理员账户 (请修改默认密码)
ADMIN_USERNAME=admin
ADMIN_PASSWORD=please-change-this-password
DEFAULT_USAGE_LIMIT=1
SEGMENT_SKIP_THRESHOLD=15
"""
with open(ENV_FILE, 'w', encoding='utf-8') as f:
f.write(sample_content)
print(f"✅ 已创建示例配置文件: {ENV_FILE}")
print(" 请编辑此文件,填入您的 API Key 和其他配置")
def main():
"""主入口函数"""
port = 8000
host = "127.0.0.1"
print("\n" + "="*60)
print("🚀 AI 学术写作助手 - 启动中...")
print("="*60)
# 创建示例配置文件
create_sample_env()
print(f"\n📍 服务地址: http://{host}:{port}")
print(f"📍 管理后台: http://{host}:{port}/admin")
print(f"📍 API 文档: http://{host}:{port}/docs")
print("\n按 Ctrl+C 停止服务")
print("="*60 + "\n")
# 在后台线程中打开浏览器
browser_thread = threading.Thread(target=open_browser, args=(port,))
browser_thread.daemon = True
browser_thread.start()
# 启动 uvicorn 服务器
try:
uvicorn.run(
app,
host=host,
port=port,
log_level="info",
access_log=True
)
except KeyboardInterrupt:
print("\n\n👋 服务已停止")
sys.exit(0)
if __name__ == "__main__":
main()
|