Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 31 additions & 16 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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(见平台「视频理解」文档)
Expand All @@ -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
Expand Down Expand Up @@ -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
#
Expand All @@ -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
2 changes: 1 addition & 1 deletion backend/app/api/admin_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
164 changes: 164 additions & 0 deletions deploy_backend.py
Original file line number Diff line number Diff line change
@@ -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()
49 changes: 37 additions & 12 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div style={{ display: "flex", justifyContent: "center", alignItems: "center", minHeight: "60vh" }}>
<div style={{ width: 28, height: 28, border: "3px solid #eee", borderTopColor: "#ff2442", borderRadius: "50%", animation: "spin 0.6s linear infinite" }} />
<style>{`@keyframes spin{to{transform:rotate(360deg)}}`}</style>
</div>
);
}

/**
* Animated route wrapper — gives every page enter/exit transitions
* powered by Framer Motion's AnimatePresence.
Expand All @@ -33,7 +46,9 @@ function AnimatedRoutes() {
exit="exit"
style={{ minHeight: "100vh" }}
>
<Home />
<Suspense fallback={<PageLoader />}>
<Home />
</Suspense>
</motion.div>
}
/>
Expand All @@ -47,7 +62,9 @@ function AnimatedRoutes() {
exit="exit"
style={{ minHeight: "100vh" }}
>
<Home />
<Suspense fallback={<PageLoader />}>
<Home />
</Suspense>
</motion.div>
}
/>
Expand All @@ -61,7 +78,9 @@ function AnimatedRoutes() {
exit="exit"
style={{ minHeight: "100vh" }}
>
<Diagnosing />
<Suspense fallback={<PageLoader />}>
<Diagnosing />
</Suspense>
</motion.div>
}
/>
Expand All @@ -75,7 +94,9 @@ function AnimatedRoutes() {
exit="exit"
style={{ minHeight: "100vh" }}
>
<Report />
<Suspense fallback={<PageLoader />}>
<Report />
</Suspense>
</motion.div>
}
/>
Expand All @@ -89,7 +110,9 @@ function AnimatedRoutes() {
exit="exit"
style={{ minHeight: "100vh" }}
>
<History />
<Suspense fallback={<PageLoader />}>
<History />
</Suspense>
</motion.div>
}
/>
Expand All @@ -103,7 +126,9 @@ function AnimatedRoutes() {
exit="exit"
style={{ minHeight: "100vh" }}
>
<ScreenshotAnalysis />
<Suspense fallback={<PageLoader />}>
<ScreenshotAnalysis />
</Suspense>
</motion.div>
}
/>
Expand All @@ -120,7 +145,7 @@ function App() {
<ThemeProvider theme={theme}>
<CssBaseline />
<ErrorBoundary>
<BrowserRouter>
<BrowserRouter basename="/app">
<AnimatedRoutes />
<ToastContainer />
<AnnouncementDialog />
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/DiagnoseCard.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -15,6 +14,7 @@ export default function DiagnoseCard({ report, title }: Props) {

const generateImage = async (): Promise<Blob | null> => {
if (!cardRef.current) return null;
const { default: html2canvas } = await import("html2canvas");
const canvas = await html2canvas(cardRef.current, {
scale: 3,
backgroundColor: "#ffffff",
Expand Down
Loading
Loading