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 ( +
+
+ +
+ ); +} + /** * Animated route wrapper — gives every page enter/exit transitions * powered by Framer Motion's AnimatePresence. @@ -33,7 +46,9 @@ function AnimatedRoutes() { exit="exit" style={{ minHeight: "100vh" }} > - + }> + + } /> @@ -47,7 +62,9 @@ function AnimatedRoutes() { exit="exit" style={{ minHeight: "100vh" }} > - + }> + + } /> @@ -61,7 +78,9 @@ function AnimatedRoutes() { exit="exit" style={{ minHeight: "100vh" }} > - + }> + + } /> @@ -75,7 +94,9 @@ function AnimatedRoutes() { exit="exit" style={{ minHeight: "100vh" }} > - + }> + + } /> @@ -89,7 +110,9 @@ function AnimatedRoutes() { exit="exit" style={{ minHeight: "100vh" }} > - + }> + + } /> @@ -103,7 +126,9 @@ function AnimatedRoutes() { exit="exit" style={{ minHeight: "100vh" }} > - + }> + + } /> @@ -120,7 +145,7 @@ function App() { - + diff --git a/frontend/src/components/DiagnoseCard.tsx b/frontend/src/components/DiagnoseCard.tsx index 047d382..a319770 100644 --- a/frontend/src/components/DiagnoseCard.tsx +++ b/frontend/src/components/DiagnoseCard.tsx @@ -1,7 +1,6 @@ import { useRef, useState } from "react"; import { Box, Button } from "@mui/material"; import ShareIcon from "@mui/icons-material/Share"; -import html2canvas from "html2canvas"; import type { DiagnoseResult } from "../utils/api"; interface Props { @@ -15,6 +14,7 @@ export default function DiagnoseCard({ report, title }: Props) { const generateImage = async (): Promise => { if (!cardRef.current) return null; + const { default: html2canvas } = await import("html2canvas"); const canvas = await html2canvas(cardRef.current, { scale: 3, backgroundColor: "#ffffff", diff --git a/frontend/src/components/RadarChart.tsx b/frontend/src/components/RadarChart.tsx index 342e877..f8274e3 100644 --- a/frontend/src/components/RadarChart.tsx +++ b/frontend/src/components/RadarChart.tsx @@ -1,4 +1,13 @@ -import ReactECharts from "echarts-for-react"; +import { useEffect, useRef } from "react"; +import * as echarts from "echarts/core"; +import { RadarChart as EChartsRadar } from "echarts/charts"; +import { + TooltipComponent, + RadarComponent, +} from "echarts/components"; +import { CanvasRenderer } from "echarts/renderers"; + +echarts.use([EChartsRadar, TooltipComponent, RadarComponent, CanvasRenderer]); interface Props { data: Record; @@ -13,6 +22,9 @@ const DIMENSION_LABELS: Record = { }; export default function RadarChart({ data }: Props) { + const chartRef = useRef(null); + const instanceRef = useRef(null); + const keys = Object.keys(DIMENSION_LABELS); const indicators = keys.map((key) => ({ name: DIMENSION_LABELS[key], @@ -20,40 +32,55 @@ export default function RadarChart({ data }: Props) { })); const values = keys.map((key) => data[key] ?? 50); - const option = { - animationDuration: 1200, - radar: { - indicator: indicators, - shape: "polygon" as const, - splitNumber: 4, - radius: "65%", - axisName: { color: "#262626", fontSize: 12, fontWeight: 600 }, - splitLine: { lineStyle: { color: "#f0f0f0" } }, - splitArea: { show: false }, - axisLine: { lineStyle: { color: "#e8e8e8" } }, - }, - series: [ - { - type: "radar", - data: [ - { - value: values, - areaStyle: { color: "rgba(255,36,66,0.15)" }, - lineStyle: { color: "#ff2442", width: 2 }, - itemStyle: { color: "#ff2442", borderColor: "#fff", borderWidth: 2 }, - symbol: "circle", - symbolSize: 6, - }, - ], + useEffect(() => { + if (!chartRef.current) return; + if (!instanceRef.current) { + instanceRef.current = echarts.init(chartRef.current); + } + instanceRef.current.setOption({ + animationDuration: 1200, + radar: { + indicator: indicators, + shape: "polygon" as const, + splitNumber: 4, + radius: "65%", + axisName: { color: "#262626", fontSize: 12, fontWeight: 600 }, + splitLine: { lineStyle: { color: "#f0f0f0" } }, + splitArea: { show: false }, + axisLine: { lineStyle: { color: "#e8e8e8" } }, + }, + series: [ + { + type: "radar", + data: [ + { + value: values, + areaStyle: { color: "rgba(255,36,66,0.15)" }, + lineStyle: { color: "#ff2442", width: 2 }, + itemStyle: { color: "#ff2442", borderColor: "#fff", borderWidth: 2 }, + symbol: "circle", + symbolSize: 6, + }, + ], + }, + ], + tooltip: { + trigger: "item", + backgroundColor: "#fff", + borderColor: "#f0f0f0", + textStyle: { color: "#262626", fontSize: 13 }, }, - ], - tooltip: { - trigger: "item", - backgroundColor: "#fff", - borderColor: "#f0f0f0", - textStyle: { color: "#262626", fontSize: 13 }, - }, - }; - - return ; + }); + }, [data]); + + useEffect(() => { + const handleResize = () => instanceRef.current?.resize(); + window.addEventListener("resize", handleResize); + return () => { + window.removeEventListener("resize", handleResize); + instanceRef.current?.dispose(); + }; + }, []); + + return
; } diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index d6ab5ba..738babd 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -8,7 +8,9 @@ import react from '@vitejs/plugin-react' export default defineConfig(({ mode }) => { const env = loadEnv(mode, process.cwd(), '') const target = env.VITE_API_PROXY_TARGET || 'http://localhost:8000' + return { + base: '/app/', plugins: [react()], server: { proxy: { @@ -26,5 +28,22 @@ export default defineConfig(({ mode }) => { }, }, }, + build: { + target: 'es2020', + sourcemap: false, + rollupOptions: { + output: { + manualChunks(id) { + if (id.includes('node_modules')) { + if (id.includes('echarts')) return 'vendor-echarts' + if (id.includes('framer-motion')) return 'vendor-motion' + if (id.includes('@mui')) return 'vendor-mui' + if (id.includes('react-dom') || id.includes('react/') || id.includes('react-router')) return 'vendor-react' + if (id.includes('html2canvas')) return 'vendor-html2canvas' + } + }, + }, + }, + }, } }) diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..711f2c2 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "XHSHD", + "lockfileVersion": 3, + "requires": true, + "packages": {} +}