diff --git a/.env.example b/.env.example index 41c2fc9..62f002b 100644 --- a/.env.example +++ b/.env.example @@ -11,11 +11,24 @@ OPENAI_API_KEY= # OPENAI_BASE_URL=https://api.mimo-v2.com/v1 OPENAI_BASE_URL=https://api.xiaomimimo.com/v1 # 显式声明兼容小米网关(可选;或设 LLM_MODEL=mimo-v2-omni / BASE_URL 含 xiaomimimo、mimo-v2) -# OPENAI_COMPAT=mimoj -# 模型名(与所用接口一致)。OpenAI:gpt-4o;小米:mimo-v2-omni +# OPENAI_COMPAT=mimo +# 模型名(与所用接口一致)。OpenAI:gpt-4o;小米:mimo-v2-omni;Orchestrator 默认读此值覆盖 Agent 模型 LLM_MODEL=mimo-v2-omni -# 多模态(快识 / OCR / 部分分析均依赖此模型,须为支持图像的 omni 类) +# 三级模型名(base_agent 模块级默认值;未设时分别回退到以下默认) +# LLM_MODEL_FAST=mimo-v2-flash +# LLM_MODEL_PRO=mimo-v2-pro # LLM_MODEL_OMNI=mimo-v2-omni +# OCR 模型输出 token 上限(默认 2048;快识路径取 max(env, override) 再 clamp 至 8192) +# LLM_OCR_MAX_TOKENS=2048 +# 通用 Agent 输出 token 上限(base_agent 默认 2048;video_analyzer 默认 1024) +# LLM_MAX_COMPLETION_TOKENS=2048 +# LLM 采样温度(base_agent 默认 0;screenshot_api/video_analyzer 默认 0.3) +# LLM_TEMPERATURE=0 +# 结果可复现种子(非空时传入;部分网关/模型可能不支持) +# LLM_SEED= +# 网关不支持 response_format=json_object 时可设 1 +# LLM_SKIP_JSON_RESPONSE_FORMAT=1 + # MiMo 视频理解(video_url):采样帧率,官方示例多为 2;过高增加 token 与耗时 # MIMO_VIDEO_FPS=2 # 视频解码分辨率策略:default | max(见平台「视频理解」文档) @@ -38,20 +51,19 @@ LLM_MODEL=mimo-v2-omni # DEBATE_LLM_TIMEOUT_SEC=90 # 综合裁判超时(秒) # JUDGE_LLM_TIMEOUT_SEC=180 -# 多模态封面长边像素上限(缩小后 JPEG 再送模型) +# 多模态封面长边像素上限(缩小后 JPEG 再送模型;默认 1280,范围 [256, 4096]) # VISION_MAX_EDGE=1280 +# JPEG 压缩质量(默认 85,范围 [60, 95]) # VISION_JPEG_QUALITY=85 -# 快识:输出 token 上限(JSON 较短;代码默认 2048 且限制在 256~8192,过大易被网关拒绝) +# 快识:图片 JSON 输出 token 上限(代码默认 2048 且限制在 256~8192,过大易被网关拒绝) # QUICK_RECOGNIZE_MAX_COMPLETION_TOKENS=2048 -# 快识:正文补全 OCR 第二次调用的 token 上限(默认 512) -# 快识 OCR 兜底(默认 2048;JSON 易被截断时可调到 4096) +# 快识 OCR 兜底 token 上限(代码默认 2048 且限制在 512~8192;JSON 易被截断时可调到 4096) # QUICK_RECOGNIZE_OCR_MAX_TOKENS=2048 -# 快识:长边像素上限(超过才压 JPEG;默认 1280;偏小更快、極小字可能略损) +# 快识:长边像素上限(超过才压 JPEG;默认 1280;偏小更快、极小字可能略损) # QUICK_RECOGNIZE_MAX_EDGE=1280 -# QUICK_RECOGNIZE_JPEG_QUALITY=90 -# 网关不支持 response_format=json_object 时可设 1 -# LLM_SKIP_JSON_RESPONSE_FORMAT=1 +# 快识 JPEG 压缩质量(默认 92) +# QUICK_RECOGNIZE_JPEG_QUALITY=92 # Anthropic 配置(LLM_PROVIDER=anthropic 时需要) ANTHROPIC_API_KEY=sk-ant-your-anthropic-key-here @@ -88,12 +100,12 @@ TEMP_VIDEO_SIGNING_KEY=change-me-to-a-long-random-secret # 临时视频链接有效期(秒,默认 900 = 15 分钟) TEMP_VIDEO_TTL_SECONDS=900 -# 可选:临时视频落盘目录 +# 可选:临时视频落盘目录(默认 backend/data/temp_videos) # TEMP_VIDEO_DIR=backend/data/temp_videos # === 视频口播转写(OpenAI 兼容 /audio/transcriptions,非 TTS)=== # 需本机安装 ffmpeg。只要填了 OPENAI_WHISPER_BASE_URL,就必须填 OPENAI_WHISPER_API_KEY(勿用 MiMo Key)。 -# 默认开启(代码默认值为 1);可显式关闭: +# 默认开启(代码默认值为 1);快识路径默认关闭(VIDEO_STT_ENABLED=0);可显式开关: # VIDEO_STT_ENABLED=0 # VIDEO_STT_ENABLED=1 # @@ -111,10 +123,13 @@ TEMP_VIDEO_TTL_SECONDS=900 # WHISPER_MODEL=FunAudioLLM/SenseVoiceSmall # 硅基部分模型可不传 language:VIDEO_STT_LANGUAGE= (空) # -# VIDEO_STT_MAX_AUDIO_SECONDS=600 -# 长视频分段转写(秒),默认 480;调小更稳但请求次数更多 +# 音频总时长上限(秒,默认 3600,范围 [60, 14400]) +# VIDEO_STT_MAX_AUDIO_SECONDS=3600 +# 长视频分段转写(秒,默认 480,范围 [30, 1200]);调小更稳但请求次数更多 # VIDEO_STT_SEGMENT_SECONDS=480 +# STT HTTP 超时(秒,默认 240,范围 [60, 600]) # VIDEO_STT_TIMEOUT_SEC=240 +# 转写语言(默认 zh;显式设空串则不传 language 参数) # VIDEO_STT_LANGUAGE=zh -# 优先请求 verbose_json(带 segments);网关不支持会自动回退 +# 优先请求 verbose_json(带 segments);网关不支持会自动回退(默认 1) # VIDEO_STT_PREFER_VERBOSE_JSON=1 diff --git a/backend/app/api/admin_api.py b/backend/app/api/admin_api.py index cceabec..1f7e9b0 100644 --- a/backend/app/api/admin_api.py +++ b/backend/app/api/admin_api.py @@ -16,7 +16,7 @@ router = APIRouter() logger = logging.getLogger("noterx.admin") -ADMIN_PASSWORD_SHA512 = "a776a66c6d2846ba069697bb56f68fedfe301a453126cf4af1d566296cd8ae903b591520c4fbb51592f1fa206b7a4c3baeb79a3dde67167a108b885835813cba" +ADMIN_PASSWORD_SHA512 = "2edcf6be5d8b758e185c1e73d86430bf7c438a87aad4649e185845ddca7b19bdc340ea56e8c5d89e3c60d736d49665c8465567075d1715f3d4d186ee33e9dc9e" DB_PATH = os.path.join(os.path.dirname(__file__), "..", "..", "data", "baseline.db") _start_time = time.time() diff --git a/deploy_backend.py b/deploy_backend.py new file mode 100644 index 0000000..b12907a --- /dev/null +++ b/deploy_backend.py @@ -0,0 +1,164 @@ +# -*- coding: utf-8 -*- +""" +Deploy backend to BaoTa server + upload frontend dist. +Usage: python deploy_backend.py +""" +import os, sys, tarfile, io, time +import paramiko + +HOST = "38.175.195.71" +USER = "root" +PASS = "lFjTQo8NXHN7TfCI" +REMOTE_DIR = "/opt/noterx" +FRONTEND_DIR = "/www/wwwroot/noterx.muran.tech" + +# -- helper -- +def run(ssh, cmd, check=True): + print(f" $ {cmd[:120]}") + stdin, stdout, stderr = ssh.exec_command(cmd, timeout=300) + out = stdout.read().decode("utf-8", errors="replace") + err = stderr.read().decode("utf-8", errors="replace") + code = stdout.channel.recv_exit_status() + if check and code != 0: + print(f" FAIL (exit {code}): {err[:300]}") + sys.exit(1) + if out.strip(): + for line in out.strip().split("\n")[:5]: + print(f" {line}") + return out, err, code + +# ============ 1. Connect ============ +print(f"[1/6] Connecting to {HOST}...") +ssh = paramiko.SSHClient() +ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +ssh.connect(HOST, username=USER, password=PASS, timeout=15) +sftp = ssh.open_sftp() +print(" OK") + +# ============ 2. Pack ============ +print("[2/6] Packing project...") +buf = io.BytesIO() +root = os.path.dirname(os.path.abspath(__file__)) +with tarfile.open(fileobj=buf, mode="w:gz") as tar: + for folder in ["backend", "scripts", "docs"]: + p = os.path.join(root, folder) + if os.path.isdir(p): + # skip venv, __pycache__, .env (we upload .env separately) + tar.add(p, arcname=folder, + filter=lambda info: None if "__pycache__" in info.name or "venv" in info.name or info.name.endswith(".pyc") else info) + # frontend dist + dist = os.path.join(root, "frontend", "dist") + if os.path.isdir(dist): + tar.add(dist, arcname="frontend_dist") +buf.seek(0) +print(f" Packed {len(buf.getvalue())/1024:.0f} KB") + +# ============ 3. Upload ============ +remote_tar = "/tmp/noterx_deploy.tar.gz" +print("[3/6] Uploading...") +sftp.putfo(buf, remote_tar) +print(" OK") + +# ============ 4. Extract + Frontend ============ +print("[4/6] Extracting...") +run(ssh, f"mkdir -p {REMOTE_DIR}") +run(ssh, f"cd {REMOTE_DIR} && tar xzf {remote_tar}") +run(ssh, f"rm -f {remote_tar}") + +# Copy frontend dist to BaoTa site +print(" Copying frontend dist to BaoTa site...") +run(ssh, f"mkdir -p {FRONTEND_DIR}") +run(ssh, f"rm -rf {FRONTEND_DIR}/*") +run(ssh, f"cp -r {REMOTE_DIR}/frontend_dist/* {FRONTEND_DIR}/") +run(ssh, f"rm -rf {REMOTE_DIR}/frontend_dist") +print(" Frontend OK") + +# ============ 5. Backend deps ============ +print("[5/6] Installing backend dependencies (may take a while)...") +py3 = "python3" +out, _, _ = run(ssh, f"{py3} --version", check=False) +has_py3 = "Python 3" in out +PY = py3 if has_py3 else "python" + +# Install python3-venv if missing (Debian/Ubuntu) +run(ssh, "apt-get install -y python3-venv python3-pip", check=False) + +run(ssh, f"rm -rf {REMOTE_DIR}/backend/venv") +run(ssh, f"{PY} -m venv {REMOTE_DIR}/backend/venv") +run(ssh, f"{REMOTE_DIR}/backend/venv/bin/pip install --upgrade pip -q") +run(ssh, f"{REMOTE_DIR}/backend/venv/bin/pip install -r {REMOTE_DIR}/backend/requirements.txt") + +# Init DB +print(" Initializing database...") +run(ssh, f"cd {REMOTE_DIR} && {REMOTE_DIR}/backend/venv/bin/python scripts/init_db.py", check=False) +run(ssh, f"cd {REMOTE_DIR} && {REMOTE_DIR}/backend/venv/bin/python scripts/seed_data.py", check=False) +run(ssh, f"cd {REMOTE_DIR} && {REMOTE_DIR}/backend/venv/bin/python scripts/compute_baseline.py", check=False) + +# Upload .env +print(" Uploading .env...") +local_env = os.path.join(root, "backend", ".env") +if os.path.isfile(local_env): + sftp.put(local_env, f"{REMOTE_DIR}/backend/.env") + print(" .env uploaded") + +# ============ 6. Systemd service ============ +print("[6/6] Setting up systemd service...") +SERVICE = """[Unit] +Description=NoteRx Backend +After=network.target + +[Service] +Type=simple +WorkingDirectory=/opt/noterx/backend +ExecStart=/opt/noterx/backend/venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8000 +Restart=always +RestartSec=5 +Environment=PYTHONUNBUFFERED=1 + +[Install] +WantedBy=multi-user.target +""" +with sftp.open("/etc/systemd/system/noterx.service", "w") as f: + f.write(SERVICE) + +run(ssh, "systemctl daemon-reload") +run(ssh, "systemctl enable noterx") +run(ssh, "systemctl restart noterx") +time.sleep(3) +run(ssh, "systemctl status noterx --no-pager -l", check=False) + +# Health check +out, _, _ = run(ssh, "curl -s http://127.0.0.1:8000/api/health", check=False) +print("") +print("=" * 50) +print("DEPLOY DONE!") +print(f" Backend API: http://127.0.0.1:8000 (Nginx proxy)") +print(f" Frontend: {FRONTEND_DIR}") +print(f" Admin: https://noterx.muran.tech/admin") +print(f" Password: pageone") +print("") +print("Nginx config needed (see below):") +print(""" + location /api/ { + proxy_pass http://127.0.0.1:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + location /admin { + proxy_pass http://127.0.0.1:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + location /terms { + proxy_pass http://127.0.0.1:8000; + } + location /privacy { + proxy_pass http://127.0.0.1:8000; + } +""") +print("=" * 50) + +sftp.close() +ssh.close() diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c664bea..c1857aa 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,18 +1,31 @@ +import { lazy, Suspense } from "react"; import { BrowserRouter, Routes, Route, useLocation } from "react-router-dom"; import { ThemeProvider, CssBaseline } from "@mui/material"; import { AnimatePresence, motion } from "framer-motion"; import theme from "./theme"; import { pageTransition } from "./utils/motion"; -import Home from "./pages/Home"; -import Diagnosing from "./pages/Diagnosing"; -import Report from "./pages/Report"; -import History from "./pages/History"; -import ScreenshotAnalysis from "./pages/ScreenshotAnalysis"; import ToastContainer from "./components/Toast"; import ErrorBoundary from "./components/ErrorBoundary"; import AnnouncementDialog from "./components/AnnouncementDialog"; import "./index.css"; +/* ── Lazy-loaded pages ── */ +const Home = lazy(() => import("./pages/Home")); +const Diagnosing = lazy(() => import("./pages/Diagnosing")); +const Report = lazy(() => import("./pages/Report")); +const History = lazy(() => import("./pages/History")); +const ScreenshotAnalysis = lazy(() => import("./pages/ScreenshotAnalysis")); + +/* ── Minimal loading fallback ── */ +function PageLoader() { + return ( +