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/README.md b/README.md
index 3987786..2e0bbae 100644
--- a/README.md
+++ b/README.md
@@ -1,147 +1,118 @@
-# 💊 薯医 NoteRx
+
-**AI驱动的小红书笔记诊断平台 —— 用数据告诉你,你的笔记为什么没火。**
+# 薯医 NoteRx
-> 你的笔记,值得被看见。
+### Multi-Agent Collaborative Diagnosis Engine for Xiaohongshu
-## 产品简介
+**Topic Star 榜单 #1** | 874 真实笔记训练 | 5 AI 专家三轮辩论 | 在线可用
-薯医(NoteRx)是一个面向小红书创作者的 AI 笔记诊断工具。用户可通过 **文字粘贴**、**截图上传(OCR 自动识别)** 或 **小红书链接导入** 三种方式提交笔记,平台通过 **多 Agent 辩论架构** 和 **真实数据 Baseline 对比**,从内容、视觉、增长策略、用户反应四个维度生成全面的诊断报告。
+
-### 核心特色
+[**立即在线体验**](https://noterx.muran.tech) | [研究白皮书](https://noterx.muran.tech/) | [技术架构](#技术架构)
-- **多 Agent 辩论诊断**:5 个 AI 专家(内容分析师、视觉诊断师、增长策略师、用户模拟器、综合裁判)并行诊断,互相质疑,给出更全面准确的诊断
-- **量化 Baseline 对比**:基于数千条小红书笔记数据建立评价基线,用数据而非玄学给建议
-- **AI 模拟评论区**:预测真实用户看到笔记后的反应
-- **一键优化建议**:AI 生成优化标题、改写正文和封面方向建议,可一键复制
-- **辩论时间线**:可视化展示 Agent 之间的赞同、反驳和补充过程
-- **可分享诊断卡片**:一键导出精美诊断报告卡片,本身就是社交内容
-- **三种输入方式**:文字粘贴 / 截图 OCR / 小红书链接导入
-- **6 大垂类支持**:美食、穿搭、科技、旅行、美妆、健身
+
-### 免责声明
+> 上传你的小红书笔记截图,5 位 AI 专家会像医生会诊一样,三轮辩论后给出量化诊断报告、可执行的优化方案、模拟评论区预测,以及一键生成的高分改写。
-本平台提供的诊断报告由 AI 多 Agent 协作生成,仅供参考,不构成任何运营承诺。
+
-## 技术架构
+---
-```
-前端 (React + TypeScript + Vite + Tailwind CSS + ECharts)
- ↓
-API Gateway (FastAPI + Pydantic)
- ↓
-┌──────────────────────────────────────────────┐
-│ 多模态解析层 │
-│ 文本分析(jieba) | 图像分析(OpenCV) | OCR(LLM) │
-│ 构图分析 | 色彩和谐度 | 视觉复杂度 │
-└──────────────────┬───────────────────────────┘
- ↓
- Baseline 对比引擎 (SQLite)
- ↓
-┌──────────────────────────────────────────────┐
-│ 多 Agent 编排引擎 (GPT-4o / Claude) │
-│ │
-│ Round 1: 四Agent并行诊断 │
-│ 内容Agent | 视觉Agent | 增长Agent | 用户Agent │
-│ ↓ │
-│ Round 2: Agent 辩论 (赞同/反驳/补充) │
-│ ↓ │
-│ Round 3: 综合裁判Agent → 最终报告 + 优化建议 │
-└──────────────────────────────────────────────┘
-```
+## 为什么是薯医
-## 快速开始
+| | 传统工具 | 薯医 NoteRx |
+|---|---|---|
+| **评分依据** | 主观经验 / 单模型打分 | 874 条真实笔记回归分析 → 5 品类差异化权重 |
+| **诊断方式** | 单次 GPT 调用 | 5 Agent 并行诊断 → 交叉质疑辩论 → 裁判综合 |
+| **建议质量** | "提升标题吸引力" | "标题「XX」→改为「5分钟搞定!这道菜我妈做了20年」→加数字+情感+悬念" |
+| **评论预测** | 无 | AI 模拟真实评论区(含吵架/质疑/楼中楼) |
+| **优化闭环** | 给建议,用户自己改 | 自动生成 3 个高分改写方案 + 即时重新评分 |
+| **数据支撑** | 无 | Spearman 相关 · 线性回归 · K-Means 聚类 · LLM 深度分析 |
-### 环境要求
+## 在线体验
-- Node.js >= 18
-- Python >= 3.9
-- OpenAI API Key (GPT-4o) 或 Anthropic API Key (Claude)
+**https://noterx.muran.tech**
-### 安装与运行
+1. 打开链接 → 拖入小红书笔记截图(支持多张拼接)
+2. AI 自动识别标题、正文、分类(< 30s)
+3. 点击"开始诊断" → 观看 5 位 AI 专家实时辩论
+4. 获取完整报告:评分 · 雷达图 · 优化方案 · 模拟评论区 · 分享卡片
-```bash
-# 1. 克隆项目
-git clone https://github.com/your-repo/noterx.git
-cd noterx
+手机电脑均可使用,无需注册。
-# 2. 配置环境变量
-cp .env.example backend/.env
-# 编辑 backend/.env,填入你的 API Key
+## 核心技术
-# 3. 安装依赖 + 初始化数据库(一键)
-make install && make data
+### 三大自训练模型
-# 4. 一键启动
-./start.sh
-```
+| 模型 | 训练数据 | 能力 |
+|---|---|---|
+| **Model A — 量化预测引擎** | 874 条真实笔记 · 回归分析 | 5 品类差异化权重 · 5 维度即时评分 · < 50ms 无 LLM 调用 |
+| **Baseline Knowledge Graph — 基线知识图谱** | 874 笔记 + 2465 评论 · K-Means 聚类 | 品类爆款线 · 互动中位数 · 标签分布 · 发布时段热力图 |
+| **Comment Persona Engine — 评论画像引擎** | 2465 条真实评论 · LLM 分类 | 6 种用户画像(种草型/经验型/质疑型/凑热闹型/求助型/吐槽型)· 情绪分布 · 点赞预估 |
-访问 `http://localhost:5173` 开始使用。
+### 四阶段诊断引擎
-### 手动启动
+```
+Stage 1 Stage 2 Stage 3 Stage 4
+数据驱动基线训练 → Model A 智能初评 → 多智能体深度辩论 → AI 优化闭环
+
+874 笔记 + 2465 评论 5 维度即时打分 4 Agent 并行诊断 自动生成 3 个优化方案
+Spearman / 回归 / 聚类 < 50ms 无 LLM 交叉质疑 · 补充论据 即时重新评分
+5 品类差异化权重 品类差异化基线 裁判 Agent 综合裁定 最高分方案推荐
+```
-```bash
-# 后端
-cd backend
-python3 -m venv venv
-source venv/bin/activate
-pip install -r requirements.txt
-uvicorn app.main:app --reload --port 8000
-
-# 前端(新终端)
-cd frontend
-npm install
-npm run dev
+### Multi-Agent 辩论架构
+
+```
+Round 1: 并行诊断 Round 2: 交叉辩论 Round 3: 综合裁判
+
+[内容分析师] ─┐ 内容 ←→ 视觉 ┌─→ 最终评分
+[视觉诊断师] ─┤→ 独立诊断 + 评分 视觉 ←→ 增长 质疑/反驳 ├─→ 优化标题 + 正文
+[增长策略师] ─┤ 增长 ←→ 用户 赞同/补充 ├─→ 封面方向建议
+[用户模拟器] ─┘ 用户 ←→ 内容 └─→ 模拟评论区
```
-### Makefile 快捷命令
+### 技术栈
-| 命令 | 作用 |
-|------|------|
-| `make install` | 安装前后端所有依赖 |
-| `make data` | 初始化数据库 + 种子数据 + 计算 baseline |
-| `make test` | 运行后端 pytest 测试 |
-| `make ci` | 完整 CI 流程(前端构建 + 后端测试) |
+| 层 | 技术 |
+|---|---|
+| **前端** | React 19 · TypeScript · MUI v9 · Framer Motion · ECharts · Vite |
+| **后端** | FastAPI · asyncio · SSE 流式推送 · SQLite |
+| **AI** | MiMo-v2-Pro(诊断)· MiMo-v2-Omni(多模态视觉)· MiMo-v2-Flash(快速任务) |
+| **分析** | jieba 分词 · OpenCV 图像分析 · OCR 文字提取 · 视频首帧/听写 |
+| **研究** | Spearman 相关 · 线性回归 · K-Means 聚类 · PCA 可视化 |
-## 项目结构
+## 产品功能
-```
-noterx/
-├── frontend/ # React 前端
-│ └── src/
-│ ├── components/ # UI 组件(Toast、ErrorBoundary、RadarChart 等)
-│ ├── pages/ # 页面(首页/诊断动画/报告)
-│ └── utils/ # API 工具、类型和 fallback 数据
-├── backend/ # Python 后端
-│ ├── app/
-│ │ ├── api/ # FastAPI 路由(diagnose、baseline、link 解析)
-│ │ ├── agents/ # 多 Agent 模块 + 编排器
-│ │ │ └── prompts/ # Agent System Prompt + 辩论 Prompt
-│ │ ├── analysis/ # 多模态分析(文本/图像/OCR/构图)
-│ │ ├── baseline/ # Baseline 对比引擎
-│ │ ├── models/ # Pydantic 数据模型
-│ │ └── utils/ # 工具模块(链接解析器等)
-│ ├── data/ # SQLite 数据库(gitignore)
-│ └── tests/ # 后端单元测试
-├── scripts/ # 数据初始化脚本
-├── docs/ # 项目文档
-├── .github/workflows/ # GitHub Actions CI
-├── Makefile # 开发快捷命令
-└── start.sh # 一键启动脚本
-```
+- **多模态输入**:截图拖入 / Ctrl+V 粘贴 / 视频上传,AI 自动识别标题、正文、分类
+- **实时诊断动画**:11 步时间线 + 辩论实况气泡 + Agent 状态跟踪
+- **五维雷达评分**:内容质量 · 视觉表现 · 增长策略 · 互动潜力 · 综合评分
+- **AI 模拟评论区**:真实 XHS 风格(含吵架/质疑/楼中楼),预估点赞数
+- **迭代优化引擎**:一键生成 3 个高分改写方案,自动评分 + 最高分推荐
+- **基线对比**:与同品类数千条笔记对比(标题字数 / 标签数 / 爆款率)
+- **分享卡片**:一键生成带品牌水印的诊断卡片,支持系统分享到微信/小红书
+- **诊断历史**:本地 IndexedDB 存储,隐私安全
+
+## 快速开始
-## API 接口
+```bash
+# 克隆
+git clone https://github.com/jiangmuran/noterx.git && cd noterx
+
+# 配置
+cp .env.example backend/.env # 编辑填入 API Key
-| 方法 | 路径 | 说明 |
-|------|------|------|
-| `POST` | `/api/diagnose` | 笔记诊断(multipart/form-data) |
-| `GET` | `/api/baseline/{category}` | 获取垂类 baseline 数据 |
-| `POST` | `/api/parse-link` | 解析小红书分享链接 |
-| `GET` | `/api/health` | 健康检查(含数据库状态) |
+# 一键安装 + 启动
+make install && make data && ./start.sh
+```
+
+访问 `http://localhost:5173`
## 团队
-**PageOne** — 五个13岁的创作者,用AI解决自己的问题。
+**PageOne** — 全场唯一中学生队伍。四个 13 岁的初中生,从零完成数据采集、模型训练、全栈开发到生产部署,48 小时交付完整产品。
+
+姜睦然 · 杨曦哲 · 陈宇夏 · 吕思彤
## License
@@ -149,4 +120,10 @@ Apache License 2.0
---
-*小红书黑客松巅峰赛作品 #小红书黑客松巅峰赛*
+
+
+**[立即体验 →](https://noterx.muran.tech)**
+
+*小红书黑客松巅峰赛 · Topic Star #1*
+
+
diff --git a/backend/app/agents/orchestrator.py b/backend/app/agents/orchestrator.py
index a141fea..65f8a8a 100644
--- a/backend/app/agents/orchestrator.py
+++ b/backend/app/agents/orchestrator.py
@@ -406,6 +406,10 @@ async def _judge_task():
stable_scores=stable_scores,
)
result["model_a_pre_score"] = model_a_score
+ result["_usage"] = {
+ "total_tokens": round1_tokens + debate_tokens + judge_tokens,
+ "duration_sec": round(total_time, 1),
+ }
return result
async def _run_debate(
diff --git a/backend/app/api/admin_api.py b/backend/app/api/admin_api.py
new file mode 100644
index 0000000..1f7e9b0
--- /dev/null
+++ b/backend/app/api/admin_api.py
@@ -0,0 +1,225 @@
+"""
+管理员统计面板 — 使用追踪 + 系统状态
+"""
+from __future__ import annotations
+
+import hashlib
+import logging
+import os
+import sqlite3
+import time
+from datetime import datetime
+
+from fastapi import APIRouter, HTTPException, Query
+from fastapi.responses import HTMLResponse
+
+router = APIRouter()
+logger = logging.getLogger("noterx.admin")
+
+ADMIN_PASSWORD_SHA512 = "2edcf6be5d8b758e185c1e73d86430bf7c438a87aad4649e185845ddca7b19bdc340ea56e8c5d89e3c60d736d49665c8465567075d1715f3d4d186ee33e9dc9e"
+DB_PATH = os.path.join(os.path.dirname(__file__), "..", "..", "data", "baseline.db")
+_start_time = time.time()
+
+
+def _verify_password(password: str) -> bool:
+ import hmac
+ return hmac.compare_digest(
+ hashlib.sha512(password.encode()).hexdigest(),
+ ADMIN_PASSWORD_SHA512,
+ )
+
+
+def _get_stats() -> dict:
+ stats = {"timestamp": datetime.utcnow().isoformat(), "uptime_seconds": time.time() - _start_time}
+ conn = None
+ try:
+ conn = sqlite3.connect(DB_PATH)
+ cur = conn.cursor()
+
+ # Notes
+ cur.execute("SELECT COUNT(*) FROM notes")
+ stats["total_notes"] = cur.fetchone()[0]
+ cur.execute("SELECT category, COUNT(*) FROM notes GROUP BY category ORDER BY COUNT(*) DESC")
+ stats["notes_by_category"] = {r[0]: r[1] for r in cur.fetchall()}
+
+ # Usage log
+ try:
+ cur.execute("SELECT COUNT(*) FROM usage_log")
+ stats["total_requests"] = cur.fetchone()[0]
+ cur.execute("SELECT COUNT(DISTINCT ip) FROM usage_log")
+ stats["unique_ips"] = cur.fetchone()[0]
+ cur.execute("SELECT SUM(total_tokens) FROM usage_log")
+ stats["total_tokens"] = cur.fetchone()[0] or 0
+ cur.execute("SELECT AVG(duration_sec) FROM usage_log WHERE duration_sec > 0")
+ avg = cur.fetchone()[0]
+ stats["avg_duration_sec"] = round(avg, 1) if avg else 0
+
+ # Today
+ cur.execute("SELECT COUNT(*), COUNT(DISTINCT ip) FROM usage_log WHERE date(created_at)=date('now')")
+ row = cur.fetchone()
+ stats["today_requests"] = row[0]
+ stats["today_ips"] = row[1]
+
+ # By category
+ cur.execute("SELECT category, COUNT(*) FROM usage_log GROUP BY category ORDER BY COUNT(*) DESC")
+ stats["usage_by_category"] = {r[0]: r[1] for r in cur.fetchall()}
+
+ # Top IPs
+ cur.execute("SELECT ip, COUNT(*) as c FROM usage_log GROUP BY ip ORDER BY c DESC LIMIT 15")
+ stats["top_ips"] = [{"ip": r[0], "count": r[1]} for r in cur.fetchall()]
+
+ # Recent 20
+ cur.execute("SELECT ip, action, title, category, total_tokens, duration_sec, status, created_at FROM usage_log ORDER BY created_at DESC LIMIT 20")
+ stats["recent_usage"] = [
+ {"ip": r[0], "action": r[1], "title": (r[2] or "")[:30], "category": r[3], "tokens": r[4], "duration": r[5], "status": r[6], "time": r[7]}
+ for r in cur.fetchall()
+ ]
+
+ # Hourly distribution (last 24h)
+ cur.execute("""
+ SELECT strftime('%H', created_at) as hour, COUNT(*) as c
+ FROM usage_log WHERE created_at > datetime('now', '-24 hours')
+ GROUP BY hour ORDER BY hour
+ """)
+ stats["hourly_24h"] = {r[0]: r[1] for r in cur.fetchall()}
+ except Exception:
+ stats["total_requests"] = 0
+ stats["unique_ips"] = 0
+ stats["recent_usage"] = []
+
+ # Engagement
+ cur.execute("""
+ SELECT category, metric_name, metric_value FROM baseline_stats
+ WHERE metric_name IN ('avg_likes','avg_collects','avg_comments','viral_rate')
+ """)
+ eng = {}
+ for r in cur.fetchall():
+ eng.setdefault(r[0], {})[r[1]] = r[2]
+ stats["engagement_by_category"] = eng
+
+ except Exception as e:
+ stats["db_error"] = str(e)
+ finally:
+ if conn:
+ conn.close()
+
+ try:
+ import psutil
+ mem = psutil.virtual_memory()
+ stats["system"] = {"cpu_percent": psutil.cpu_percent(), "memory_used_mb": round(mem.used/1024/1024), "memory_total_mb": round(mem.total/1024/1024), "memory_percent": mem.percent}
+ except ImportError:
+ stats["system"] = {}
+ return stats
+
+
+ADMIN_HTML = """
+
+
+
+薯医 Admin
+
+
+
+
+"""
+
+
+@router.get("/admin", response_class=HTMLResponse)
+async def admin_page():
+ return ADMIN_HTML
+
+
+@router.get("/admin/api/stats")
+async def admin_stats(password: str = Query(...)):
+ if not _verify_password(password):
+ raise HTTPException(403, "密码错误")
+ return _get_stats()
diff --git a/backend/app/api/diagnose.py b/backend/app/api/diagnose.py
index 03e7ee7..91dfa74 100644
--- a/backend/app/api/diagnose.py
+++ b/backend/app/api/diagnose.py
@@ -439,6 +439,9 @@ async def diagnose_note(
if not title.strip():
raise HTTPException(400, "请输入标题,或上传可识别标题的图片/视频")
+ import time as _time
+ from app.api.usage_tracker import get_client_ip, log_usage
+ _t0 = _time.time()
orchestrator = Orchestrator()
report = await orchestrator.run(
title=title,
@@ -448,6 +451,16 @@ async def diagnose_note(
cover_image=image_bytes,
video_analysis=video_analysis,
)
+ # Log usage
+ _usage = report.pop("_usage", {})
+ log_usage(
+ ip=get_client_ip(request),
+ action="diagnose",
+ title=title[:100],
+ category=category,
+ total_tokens=_usage.get("total_tokens", 0),
+ duration_sec=_usage.get("duration_sec", round(_time.time() - _t0, 1)),
+ )
return report
@@ -580,6 +593,17 @@ async def _run_job():
video_analysis=video_analysis,
progress_cb=_progress,
)
+ # Log usage from stream endpoint
+ from app.api.usage_tracker import get_client_ip, log_usage
+ _usage = report.pop("_usage", {})
+ log_usage(
+ ip=get_client_ip(request),
+ action="diagnose-stream",
+ title=title[:100],
+ category=category,
+ total_tokens=_usage.get("total_tokens", 0),
+ duration_sec=_usage.get("duration_sec", 0),
+ )
await queue.put(("result", report))
except Exception as e:
logger.error("Stream diagnose error: %s", e)
diff --git a/backend/app/api/routes.py b/backend/app/api/routes.py
index 8ffcff8..f2479a9 100644
--- a/backend/app/api/routes.py
+++ b/backend/app/api/routes.py
@@ -22,6 +22,7 @@ async def api_health():
router.include_router(diagnose_router, tags=["diagnose"])
router.include_router(baseline_router, tags=["baseline"])
router.include_router(comments_router, tags=["comments"])
-router.include_router(history_router, tags=["history"])
+# history_router disabled — #58 fix: history is local-only (IndexedDB), server endpoints were a data leak
+# router.include_router(history_router, tags=["history"])
router.include_router(screenshot_router, tags=["screenshot"])
router.include_router(optimize_router, tags=["optimize"])
diff --git a/backend/app/api/screenshot_api.py b/backend/app/api/screenshot_api.py
index 60d3ba6..f3810a6 100644
--- a/backend/app/api/screenshot_api.py
+++ b/backend/app/api/screenshot_api.py
@@ -71,52 +71,26 @@ def _quick_ocr_max_tokens() -> int:
"comments": "评论区截图",
}
-_QUICK_PROMPT = """你是小红书截图分类与文字提取工具。
+_QUICK_PROMPT = """分析这张小红书截图,判断类型并提取文字。
-## 铁律
-- 只提取图中实际可见的文字,严禁编造。看不清就留空""。
-- confidence 诚实反映把握程度。
-
-## 如何判断 slot_type(最关键!先判类型再提取)
-
-### cover(封面)的视觉特征:
-- 一张大图占满屏幕(照片/美图/产品图)
-- 可能叠加少量装饰文字(大号字、艺术字)
-- **没有**段落式正文,**没有**标签列表,**没有**评论列表
-- 底部可能有用户头像+昵称+点赞数
+## slot_type 判断规则
+- cover:一张大图占满屏幕,没有段落正文,没有标签列表
+- content:有笔记标题(粗体)+ 段落正文 + #标签。长图续页(只有正文没标题)也算content
+- comments:多条评论列表(头像+昵称+评论文字)
+- profile:大头像+昵称+粉丝数+笔记网格
+- other:以上都不是
-### content(笔记详情页)的视觉特征:
-- 顶部有**笔记标题**(一行粗体文字)
-- 下面有**段落式正文**(多行文字,可能有emoji分段)
-- 底部通常有 **#标签** 列表
-- 可能有"编辑""发布"按钮(编辑态)
-
-### comments(评论区)的视觉特征:
-- 多条评论排列,每条有:头像圆形 + 昵称 + 评论文字 + 点赞数
-- 可能有"共XX条评论"标题
-- **不是**正文,不要把评论内容当成 content_text
+## 提取规则
+- title:仅content类型提取笔记标题。其他类型留空""
+- content_text:仅content类型提取正文+标签。其他类型留空""
+- category:美食/穿搭/科技/旅行/生活
+- summary:1句概括
+- likes:图中可见的点赞数(整数,看不到则0)
-### profile(主页)的视觉特征:
-- 顶部有大头像 + 昵称 + 粉丝数/关注数/获赞数
-- 下面是笔记网格缩略图
+严禁编造!看不清就留空。
-## 提取规则
-- title:**仅 content 类型**提取(页面顶部的笔记标题)。cover/comments/profile 留空 ""
-- content_text:**仅 content 类型**提取(段落正文+标签)。注意:如果截图是长图的一部分(只有正文没有标题),也归为 content 类型,提取可见的正文。其他类型留空 ""
-- category:根据图片内容判断垂类(美食/穿搭/科技/旅行/生活)
-- summary:1-2句概括
-- extra_slots:同屏含评论区时 ["comments"],否则 []
-- publisher:发布者信息(如能看到)
- - name:发布者昵称(看不到则 "")
- - follower_count:粉丝数文本如"1.2万"(看不到则 "")
-- engagement_signal:截图中可见的流量数据
- - likes_visible:点赞数(整数,看不到则 0)
- - collects_visible:收藏数(整数,看不到则 0)
- - comments_visible:评论数(整数,看不到则 0)
- - is_high_engagement:点赞>1000 或 收藏>500 时 true
-
-仅输出 JSON:
-{"slot_type": "cover|content|profile|comments|other", "extra_slots": [], "category": "", "title": "", "content_text": "", "summary": "", "confidence": 0.0, "publisher": {"name": "", "follower_count": ""}, "engagement_signal": {"likes_visible": 0, "collects_visible": 0, "comments_visible": 0, "is_high_engagement": false}}"""
+输出JSON(不要嵌套,全部平铺):
+{"slot_type":"","category":"","title":"","content_text":"","summary":"","confidence":0.0,"likes":0}"""
_VIDEO_QUICK_PROMPT = """你是小红书内容理解助手。用户上传了一段**视频**(录屏、Vlog、步骤演示等)。
@@ -350,13 +324,32 @@ async def _vision_call(
except asyncio.TimeoutError:
return {"error": "视觉识别超时(60s)", "slot_type": "other"}
raw = resp.choices[0].message.content or ""
+ # Try multiple JSON extraction strategies
clean = raw.strip()
+ # 1) Remove markdown code fence
if clean.startswith("```"):
clean = clean.split("\n", 1)[-1].rsplit("```", 1)[0].strip()
+ # 2) Direct parse
try:
return json.loads(clean)
except json.JSONDecodeError:
- return {"raw_text": raw, "error": "JSON解析失败"}
+ pass
+ # 3) Use enhanced parser from base_agent (handles thinking tags, raw_decode)
+ try:
+ from app.agents.base_agent import _parse_json_from_llm_text
+ return _parse_json_from_llm_text(raw)
+ except Exception:
+ pass
+ # 4) Last resort: find first { ... } manually
+ left = raw.find("{")
+ right = raw.rfind("}")
+ if left != -1 and right > left:
+ try:
+ return json.loads(raw[left:right + 1])
+ except json.JSONDecodeError:
+ pass
+ logger.warning("快识视觉JSON解析全部失败, 原始输出前300字: %s", raw[:300])
+ return {"raw_text": raw[:200], "error": "JSON解析失败"}
def _sanitize_video_derived_title(result: dict) -> None:
@@ -455,6 +448,12 @@ def _normalize_quick_recognition_fields(
slot_type = _normalize_slot_type(result.get("slot_type", ""))
result["slot_type"] = slot_type
result["extra_slots"] = _normalize_extra_slots(result.get("extra_slots"))
+ # Normalize flat likes/publisher into engagement_signal/publisher for frontend
+ if "likes" in result and "engagement_signal" not in result:
+ likes = int(result.pop("likes", 0) or 0)
+ result["engagement_signal"] = {"likes_visible": likes, "collects_visible": 0, "comments_visible": 0, "is_high_engagement": likes > 1000}
+ if "name" in result and "publisher" not in result:
+ result["publisher"] = {"name": result.pop("name", ""), "follower_count": result.pop("follower_count", "")}
if is_video_frame_fallback and str(result.get("content_text", "")).strip():
result["slot_type"] = "content"
slot_type = "content"
diff --git a/backend/app/api/usage_tracker.py b/backend/app/api/usage_tracker.py
new file mode 100644
index 0000000..72343f7
--- /dev/null
+++ b/backend/app/api/usage_tracker.py
@@ -0,0 +1,47 @@
+"""
+轻量使用追踪 — 无需登录,基于 IP 匿名追踪
+"""
+from __future__ import annotations
+
+import logging
+import os
+import sqlite3
+from typing import Optional
+
+from fastapi import Request
+
+logger = logging.getLogger("noterx.usage")
+DB_PATH = os.path.join(os.path.dirname(__file__), "..", "..", "data", "baseline.db")
+
+
+def get_client_ip(request: Request) -> str:
+ """Extract real client IP from proxy headers."""
+ forwarded = request.headers.get("x-forwarded-for", "")
+ if forwarded:
+ return forwarded.split(",")[0].strip()
+ real_ip = request.headers.get("x-real-ip", "")
+ if real_ip:
+ return real_ip.strip()
+ return request.client.host if request.client else "unknown"
+
+
+def log_usage(
+ ip: str,
+ action: str = "diagnose",
+ title: str = "",
+ category: str = "",
+ total_tokens: int = 0,
+ duration_sec: float = 0,
+ status: str = "ok",
+) -> None:
+ """Write a usage log entry to SQLite."""
+ try:
+ conn = sqlite3.connect(DB_PATH)
+ conn.execute(
+ "INSERT INTO usage_log (ip, action, title, category, total_tokens, duration_sec, status) VALUES (?, ?, ?, ?, ?, ?, ?)",
+ (ip, action, title[:100], category, total_tokens, round(duration_sec, 1), status),
+ )
+ conn.commit()
+ conn.close()
+ except Exception as e:
+ logger.warning("Failed to log usage: %s", e)
diff --git a/backend/app/main.py b/backend/app/main.py
index f68e8a0..77cef06 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -38,6 +38,22 @@ def _ensure_history_table():
CREATE INDEX IF NOT EXISTS idx_history_created
ON diagnosis_history(created_at DESC)
""")
+ # Usage tracking table
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS usage_log (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ ip TEXT NOT NULL,
+ action TEXT NOT NULL DEFAULT 'diagnose',
+ title TEXT DEFAULT '',
+ category TEXT DEFAULT '',
+ total_tokens INTEGER DEFAULT 0,
+ duration_sec REAL DEFAULT 0,
+ status TEXT DEFAULT 'ok',
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ )
+ """)
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_usage_created ON usage_log(created_at DESC)")
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_usage_ip ON usage_log(ip)")
conn.commit()
conn.close()
local_memory.ensure_memory_md()
@@ -63,14 +79,22 @@ async def lifespan(_app: FastAPI):
app.add_middleware(
CORSMiddleware,
- allow_origins=["*"],
- allow_credentials=True,
+ allow_origins=[
+ "https://noterx.muran.tech",
+ "http://localhost:5173",
+ "http://localhost:5174",
+ ],
+ allow_credentials=False,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(api_router, prefix="/api")
+# Admin panel at /admin (no /api prefix)
+from app.api.admin_api import router as admin_router
+app.include_router(admin_router)
+
# ── Landing page: research whitepaper at / ──
RESEARCH_HTML = os.path.join(os.path.dirname(__file__), "..", "..", "docs", "research_whitepaper.html")
@@ -91,6 +115,24 @@ async def serve_research():
return FileResponse(RESEARCH_HTML, media_type="text/html")
return {"error": "Research page not found"}
+# ── Legal pages ──
+TERMS_HTML = os.path.join(os.path.dirname(__file__), "..", "..", "docs", "terms.html")
+PRIVACY_HTML = os.path.join(os.path.dirname(__file__), "..", "..", "docs", "privacy.html")
+
+@app.get("/terms")
+async def serve_terms():
+ """服务条款"""
+ if os.path.isfile(TERMS_HTML):
+ return FileResponse(TERMS_HTML, media_type="text/html")
+ return {"error": "Terms page not found"}
+
+@app.get("/privacy")
+async def serve_privacy():
+ """隐私政策"""
+ if os.path.isfile(PRIVACY_HTML):
+ return FileResponse(PRIVACY_HTML, media_type="text/html")
+ return {"error": "Privacy page not found"}
+
# ── SPA: product app at /app and sub-routes ──
SPA_ROUTES = {"/app", "/diagnosing", "/report", "/history", "/screenshot"}
@@ -105,7 +147,8 @@ async def dispatch(self, request, call_next):
if (response.status_code == 404
and not path.startswith("/api")
and not path.startswith("/assets")
- and path not in ("/", "/research")):
+ and path not in ("/", "/research", "/terms", "/privacy")
+ and not path.startswith("/admin")):
return FileResponse(os.path.join(FRONTEND_DIST, "index.html"))
return response
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/docs/privacy.html b/docs/privacy.html
new file mode 100644
index 0000000..d611096
--- /dev/null
+++ b/docs/privacy.html
@@ -0,0 +1,75 @@
+
+
+
+
+
+隐私政策 — 薯医 NoteRx
+
+
+
+
+
+
+
+
返回首页
+
+
隐私政策
+
薯医 NoteRx (noterx.muran.tech) — 最后更新:2026年4月
+
+
信息收集
+
您提交的笔记截图和文字内容仅用于实时AI分析,分析完成后不会保存在服务端。我们不会将您的笔记内容用于模型训练或向第三方出售。
+
+
本地存储
+
诊断历史存储在您浏览器的 IndexedDB 中,完全由您本地控制。您可以随时在浏览器设置中清除这些数据,服务端无法访问您的本地存储。
+
+
匿名数据
+
为优化产品体验,我们会收集以下匿名数据:
+
+ - IP地址 — 用于区分访问来源,不关联个人身份
+ - 诊断品类 — 了解用户需求分布
+ - Token消耗和诊断耗时 — 用于性能监控和成本优化
+
+
+ 以上数据均以匿名形式记录,无法追溯到具体个人。我们承诺不会将这些数据用于广告投放或个人画像分析。
+
+
+
第三方服务
+
本平台调用小米 MiMo API 进行AI分析。在诊断过程中,您的笔记内容会被传输至 MiMo API 进行处理,相关数据受小米隐私政策约束。除此之外,我们不会将您的数据分享给其他第三方。
+
+
Cookie
+
本平台不使用 Cookie。我们不追踪您的浏览行为,也不使用任何第三方追踪工具。
+
+
未成年人
+
薯医 NoteRx 由未成年人团队(PageOne)开发,我们欢迎所有年龄段的用户使用本平台。本平台不要求注册账号,不收集个人身份信息。
+
+
联系我们
+
如果您对隐私政策有任何疑问或建议,欢迎通过以下方式联系我们:
+
GitHub Issues (github.com/jiangmuran/noterx/issues)
+
+
+
+
+
+
diff --git a/docs/research_whitepaper.html b/docs/research_whitepaper.html
index 132de21..0a0d052 100644
--- a/docs/research_whitepaper.html
+++ b/docs/research_whitepaper.html
@@ -615,24 +615,18 @@ 开源与论文
return false;
}
-// Reset launch overlay on page load — slide it away smoothly if stuck
-(function(){
+// Reset launch overlay — uses pageshow to catch bfcache (browser back button)
+window.addEventListener('pageshow', function(e){
var o=document.getElementById('launch-overlay');
if(!o)return;
- // If overlay was fullscreen (user pressed back), slide it out
- if(parseInt(o.style.width)>100||o.classList.contains('go')){
- o.style.transition='transform 0.4s cubic-bezier(0.4,0,1,1), opacity 0.3s ease';
- o.style.transform='translateY(-100vh)';
- o.style.opacity='0';
- setTimeout(function(){
- o.style.transition='none';o.style.width='0';o.style.height='0';
- o.style.transform='none';o.style.opacity='1';o.style.pointerEvents='none';
- o.classList.remove('go');
- },500);
- } else {
- o.style.transition='none';o.style.width='0';o.style.height='0';o.style.pointerEvents='none';o.classList.remove('go');
- }
-})();
+ // Instant reset — no animation, just kill it
+ o.style.transition='none';
+ o.style.width='0';o.style.height='0';
+ o.style.top='0';o.style.left='0';
+ o.style.pointerEvents='none';
+ o.style.transform='none';o.style.opacity='1';
+ o.classList.remove('go');
+});
// Counter animation
var counted=false;
diff --git a/docs/terms.html b/docs/terms.html
new file mode 100644
index 0000000..b6402b1
--- /dev/null
+++ b/docs/terms.html
@@ -0,0 +1,80 @@
+
+
+
+
+
+服务条款 — 薯医 NoteRx
+
+
+
+
+
+
+
+
返回首页
+
+
服务条款
+
薯医 NoteRx (noterx.muran.tech) — 最后更新:2026年4月
+
+
服务说明
+
薯医 NoteRx 是一个基于多Agent辩论架构的AI笔记诊断平台。用户提交小红书笔记(文字、截图或链接),由多个AI智能体从内容质量、视觉表现、增长潜力、用户感知等维度进行分析,并通过多轮辩论得出综合诊断报告。
+
本平台旨在为内容创作者提供数据驱动的参考建议,帮助优化笔记质量。
+
+
用户责任
+
+ - 用户上传的笔记内容应当合法合规,不得包含违反法律法规的信息。
+ - 用户不得上传侵犯他人知识产权、隐私权或其他合法权益的内容。
+ - 用户应对自己提交的内容承担全部责任。
+
+
+
免责声明
+
+ 本平台提供的所有诊断报告、评分及建议均由AI自动生成,仅供参考,不构成任何运营承诺或效果保证。实际笔记表现受平台算法、时效性、用户群体等多种因素影响,请结合自身判断合理使用。
+
+
+
数据说明
+
诊断历史仅存储在您的本地浏览器(IndexedDB)中。服务端不保存任何个人数据,也不会将您提交的笔记内容用于模型训练或其他用途。
+
您可以随时在浏览器中清除本地存储的诊断历史。
+
+
匿名使用数据
+
为了产品改进和服务优化,我们会记录以下匿名使用数据:
+
+ - IP地址(不与个人身份关联)
+ - 诊断品类及Token消耗
+ - 诊断耗时
+
+
上述数据仅用于统计分析和产品优化,不会用于识别个人身份。
+
+
知识产权
+
薯医 NoteRx 平台的技术架构、多Agent辩论系统、基准数据分析方法及相关代码归 PageOne 团队所有。用户生成的诊断报告内容可由用户自由使用。
+
+
适用法律
+
本服务条款的解释及争议解决适用中华人民共和国法律。
+
+
+
+
+
+
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 4815273..c1857aa 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,17 +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.
@@ -32,7 +46,9 @@ function AnimatedRoutes() {
exit="exit"
style={{ minHeight: "100vh" }}
>
-
+ }>
+
+
}
/>
@@ -46,7 +62,9 @@ function AnimatedRoutes() {
exit="exit"
style={{ minHeight: "100vh" }}
>
-
+ }>
+
+
}
/>
@@ -60,7 +78,9 @@ function AnimatedRoutes() {
exit="exit"
style={{ minHeight: "100vh" }}
>
-
+ }>
+
+
}
/>
@@ -74,7 +94,9 @@ function AnimatedRoutes() {
exit="exit"
style={{ minHeight: "100vh" }}
>
-
+ }>
+
+
}
/>
@@ -88,7 +110,9 @@ function AnimatedRoutes() {
exit="exit"
style={{ minHeight: "100vh" }}
>
-
+ }>
+
+
}
/>
@@ -102,7 +126,9 @@ function AnimatedRoutes() {
exit="exit"
style={{ minHeight: "100vh" }}
>
-
+ }>
+
+
}
/>
@@ -119,9 +145,10 @@ function App() {
-
+
+
diff --git a/frontend/src/components/AgentDebate.tsx b/frontend/src/components/AgentDebate.tsx
index dea77f6..8ae4d96 100644
--- a/frontend/src/components/AgentDebate.tsx
+++ b/frontend/src/components/AgentDebate.tsx
@@ -30,6 +30,7 @@ function agentInitial(name: string): string {
export default function AgentDebate({ opinions, summary, timeline }: Props) {
const [expandedIdx, setExpandedIdx] = useState(null);
+ const [showAllTimeline, setShowAllTimeline] = useState(false);
return (
@@ -102,7 +103,7 @@ export default function AgentDebate({ opinions, summary, timeline }: Props) {
辩论过程 · {timeline.length} 条
- {timeline.map((entry, i) => {
+ {(showAllTimeline ? timeline : timeline.slice(0, 3)).map((entry, i) => {
const kind = KIND_STYLE[entry.kind] || KIND_STYLE.add;
const colors = AGENT_COLORS[entry.agent_name] || { accent: "#666", bg: "#f9f9f9", text: "#333" };
return (
@@ -130,6 +131,12 @@ export default function AgentDebate({ opinions, summary, timeline }: Props) {
);
})}
+ {timeline.length > 3 && (
+ setShowAllTimeline(!showAllTimeline)}
+ sx={{ fontSize: 12, color: "#999", mt: 1, cursor: "pointer", "&:hover": { color: "#262626" } }}>
+ {showAllTimeline ? "收起" : `展开全部 ${timeline.length} 条`}
+
+ )}
)}
diff --git a/frontend/src/components/AnnouncementDialog.tsx b/frontend/src/components/AnnouncementDialog.tsx
new file mode 100644
index 0000000..2fb4af9
--- /dev/null
+++ b/frontend/src/components/AnnouncementDialog.tsx
@@ -0,0 +1,402 @@
+import { useState, useEffect } from "react";
+import {
+ Dialog,
+ DialogContent,
+ Box,
+ Typography,
+ Button,
+ IconButton,
+ useMediaQuery,
+ useTheme,
+} from "@mui/material";
+import CloseIcon from "@mui/icons-material/Close";
+import FavoriteIcon from "@mui/icons-material/Favorite";
+import GitHubIcon from "@mui/icons-material/GitHub";
+import LanguageIcon from "@mui/icons-material/Language";
+import EmailOutlinedIcon from "@mui/icons-material/EmailOutlined";
+import HandshakeOutlinedIcon from "@mui/icons-material/HandshakeOutlined";
+import OpenInNewIcon from "@mui/icons-material/OpenInNew";
+import WhatshotIcon from "@mui/icons-material/Whatshot";
+import TrendingUpIcon from "@mui/icons-material/TrendingUp";
+import CodeIcon from "@mui/icons-material/Code";
+
+const STORAGE_KEY = "noterx_announcement_seen_v1";
+
+const WaveSvg = () => (
+
+);
+
+function LinkCard({
+ icon,
+ label,
+ sublabel,
+ href,
+}: {
+ icon: React.ReactNode;
+ label: string;
+ sublabel: string;
+ href: string;
+}) {
+ return (
+
+
+
+ {icon}
+
+
+
+ {label}
+
+
+ {sublabel}
+
+
+
+
+
+ );
+}
+
+const STATS = [
+ {
+ icon: ,
+ val: "100万+",
+ label: "全网曝光",
+ },
+ {
+ icon: ,
+ val: "10万+",
+ label: "日均流量",
+ },
+ {
+ icon: ,
+ val: "全开源",
+ label: "MIT License",
+ },
+];
+
+export default function AnnouncementDialog() {
+ const [open, setOpen] = useState(false);
+ const theme = useTheme();
+ const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
+
+ useEffect(() => {
+ try {
+ if (!localStorage.getItem(STORAGE_KEY)) {
+ const timer = setTimeout(() => setOpen(true), 800);
+ return () => clearTimeout(timer);
+ }
+ } catch {
+ /* localStorage unavailable */
+ }
+ }, []);
+
+ const handleClose = () => {
+ setOpen(false);
+ try {
+ localStorage.setItem(STORAGE_KEY, Date.now().toString());
+ } catch {
+ /* ignore */
+ }
+ };
+
+ return (
+
+ );
+}
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/src/components/SimulatedComments.tsx b/frontend/src/components/SimulatedComments.tsx
index 0f879a2..19f15f3 100644
--- a/frontend/src/components/SimulatedComments.tsx
+++ b/frontend/src/components/SimulatedComments.tsx
@@ -51,7 +51,7 @@ function toCommentState(c: SimulatedComment | CommentWithReplies): CommentState
}
export default function SimulatedComments({ comments: initial, noteTitle = "", noteContent = "", noteCategory = "food" }: Props) {
- const [comments, setComments] = useState(() => initial.map(toCommentState));
+ const [comments, setComments] = useState(() => (initial || []).map(toCommentState));
const [loading, setLoading] = useState(false);
const toggleLike = useCallback((idx: number) => {
diff --git a/frontend/src/index.css b/frontend/src/index.css
index 1d249d2..662f7da 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -57,6 +57,18 @@ img {
display: block;
}
+/* Remove tap highlight and focus outline on mobile */
+* {
+ -webkit-tap-highlight-color: transparent;
+}
+*:focus {
+ outline: none;
+}
+*:focus-visible {
+ outline: 2px solid #ff2442;
+ outline-offset: 2px;
+}
+
/* ── Shimmer animation for progress bars ── */
@keyframes shimmer {
0% { transform: translateX(-100%); }
diff --git a/frontend/src/pages/Diagnosing.tsx b/frontend/src/pages/Diagnosing.tsx
index a24d5ac..a35aceb 100644
--- a/frontend/src/pages/Diagnosing.tsx
+++ b/frontend/src/pages/Diagnosing.tsx
@@ -3,7 +3,7 @@ import { useLocation, useNavigate } from "react-router-dom";
import { motion, AnimatePresence } from "framer-motion";
import axios from "axios";
import { Box, Typography, useTheme, useMediaQuery, Alert, Button } from "@mui/material";
-import CheckCircleOutlinedIcon from "@mui/icons-material/CheckCircleOutlined";
+
import { preScore, diagnoseStream, diagnoseNote, DIAGNOSE_CLIENT_MAX_MS } from "../utils/api";
import type { PreScoreResult, StreamEvent } from "../utils/api";
@@ -116,14 +116,6 @@ const FUN_FACTS = [
/* (CATEGORY_LABEL removed — category shown via preScoreData.category_cn) */
-/* ── Agent status config ── */
-const AGENTS = [
- { name: "content", label: "内容分析师", activeStep: 4, doneStep: 5 },
- { name: "visual", label: "视觉诊断师", activeStep: 4, doneStep: 6 },
- { name: "growth", label: "增长策略师", activeStep: 4, doneStep: 7 },
- { name: "user", label: "用户模拟器", activeStep: 4, doneStep: 8 },
- { name: "judge", label: "综合裁判", activeStep: 9, doneStep: 10 },
-];
/* ── Score ring component ── */
function ScoreRing({ score, size = 80 }: { score: number; size?: number }) {
@@ -394,7 +386,6 @@ export default function Diagnosing() {
const progress = ((step + 1) / STEPS.length) * 100;
- const currentStep = STEPS[Math.min(step, STEPS.length - 1)];
return (
@@ -518,223 +509,154 @@ export default function Diagnosing() {
)}
- {/* ═══ Right column: Progress + Engagement ═══ */}
-
- {/* Note preview — 让用户等待时有东西看 */}
- {(params.title || params.content) && (
-
-
- {params.title || "无标题"}
-
- {params.content && (
-
- {params.content}
-
- )}
-
- )}
-
- {/* Current step */}
-
-
-
-
- {currentStep.label}
-
-
- {currentStep.desc}
-
-
-
-
-
- {/* Agent strip */}
-
- {AGENTS.map((agent, i) => {
- const isDone = step >= agent.doneStep;
- const isActive = !isDone && step >= agent.activeStep;
- return (
-
-
- {isDone ? (
-
- ) : isActive ? (
-
-
-
- ) : (
-
- )}
-
- {agent.label}
-
-
-
- );
- })}
-
+ {/* ═══ Right column: Step Timeline ═══ */}
+
- {/* Progress bar */}
-
-
+ {/* Progress bar at top */}
+
+
-
- {step + 1} / {STEPS.length}
-
+
+ {step + 1}/{STEPS.length}
+ {elapsed}s
+
- {/* ── Divider ── */}
-
-
- {/* ══ 辩论阶段: 辩论实况占据主要空间 ══ */}
- {step >= 8 ? (
-
-
-
-
-
- 专家辩论实况
-
-
- {debateMsgs.length}/4 位专家已发言
-
+ {/* Vertical step timeline */}
+ {STEPS.map((s, i) => {
+ const isDone = i < step;
+ const isActive = i === step;
+
+ const isDebatePhase = i === 8;
+ const isJudgePhase = i === 9;
+
+ return (
+
+ {/* Timeline line + dot */}
+
+
+ {isDone && (
+
+ )}
+ {isActive && (
+
+ )}
+
+ {i < STEPS.length - 1 && (
+
+ )}
- {debateMsgs.length === 0 ? (
-
- 等待专家发言...
-
- ) : (
-
- {debateMsgs.map((msg, i) => {
- const names = ["内容专家", "视觉专家", "增长顾问", "用户模拟"];
- const colors = ["#ff2442", "#8b5cf6", "#f59e0b", "#3b82f6"];
- const bgColors = ["#fff5f6", "#faf5ff", "#fffbeb", "#eff6ff"];
- return (
-
-
-
-
- {(names[i] || "?").charAt(0)}
-
-
-
-
- {msg}
-
-
+ {/* Step content */}
+
+
+ {s.label}
+
+ {isActive && (
+
+ {s.desc}
+
+ )}
+
+ {/* Debate phase: show live messages */}
+ {isDebatePhase && (isDone || isActive) && debateMsgs.length > 0 && (
+
+ {debateMsgs.map((msg, j) => {
+ const colors = ["#ff2442", "#8b5cf6", "#f59e0b", "#3b82f6"];
+ const bgColors = ["#fff5f6", "#faf5ff", "#fffbeb", "#eff6ff"];
+ return (
+
+
+ {msg}
+
-
- );
- })}
-
- )}
-
- {step >= 9 && debateMsgs.length >= 4 && (
-
-
-
-
- 辩论完成,综合裁判正在评定最终报告...
+ );
+ })}
+
+ )}
+
+ {/* Judge phase: show status */}
+ {isJudgePhase && isActive && (
+
+
+
+
+
+ 综合裁判正在评定最终报告...
-
- )}
+ )}
+
-
- ) : (
- <>
- {/* ══ 非辩论阶段: Tips + Quiz ══ */}
-
-
- 数据洞察
+ );
+ })}
+
+ {/* Tips / Quiz below timeline */}
+
+
+ 数据洞察
+
+
+
+
+ {tips[tipIdx]}
-
-
-
- {tips[tipIdx]}
-
-
-
-
+
+
+
- setShowAnswer(true)}
- sx={{
- p: 2, borderRadius: "14px", cursor: "pointer",
- bgcolor: showAnswer ? "#fff5f6" : "#f9f9f9",
- border: showAnswer ? "1px solid #fecaca" : "1px solid transparent",
- transition: "all 0.3s",
- "&:hover": { bgcolor: showAnswer ? "#fff5f6" : "#f5f5f5" },
- }}
- >
-
- {showAnswer ? "答案揭晓" : "猜一猜"}
+ setShowAnswer(true)}
+ sx={{
+ mt: 1.5, p: 1.5, borderRadius: "10px", cursor: "pointer",
+ bgcolor: showAnswer ? "#fff5f6" : "#f9f9f9",
+ border: showAnswer ? "1px solid #fecaca" : "1px solid transparent",
+ transition: "all 0.3s",
+ }}
+ >
+
+ {showAnswer ? "答案" : "猜一猜"}
+
+
+
+
+ {showAnswer ? FUN_FACTS[factIdx].a : FUN_FACTS[factIdx].q}
-
-
-
- {showAnswer ? FUN_FACTS[factIdx].a : FUN_FACTS[factIdx].q}
-
-
-
- {!showAnswer && (
- 点击揭晓
- )}
-
- >
- )}
+
+
+
diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx
index 282816f..d0ca2c3 100644
--- a/frontend/src/pages/Home.tsx
+++ b/frontend/src/pages/Home.tsx
@@ -8,6 +8,7 @@ import {
useMediaQuery, Alert,
} from "@mui/material";
import HistoryOutlined from "@mui/icons-material/HistoryOutlined";
+import EmailOutlinedIcon from "@mui/icons-material/EmailOutlined";
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
import CategoryPicker from "../components/CategoryPicker";
import UploadZone from "../components/UploadZone";
@@ -527,6 +528,8 @@ export default function Home() {
const isFormBlocked = files.length > 0 && !allRecognitionDone;
const [submitError, setSubmitError] = useState("");
+ // Auto-clear error when user fixes the condition
+ useEffect(() => { if (submitError) setSubmitError(""); }, [files.length, title]); // eslint-disable-line react-hooks/exhaustive-deps
const handleSubmit = () => {
if (files.length === 0) { setSubmitError("请先上传笔记截图"); return; }
@@ -630,6 +633,14 @@ export default function Home() {
>
历史
+ }
+ component="a" href="mailto:jmr@jiangmuran.com" size="small"
+ sx={{ color: "#999", fontSize: 12, fontWeight: 600, minWidth: "auto", px: 1, borderRadius: "8px",
+ textDecoration: "none",
+ "&:hover": { color: "#ff2442", bgcolor: "#fff0f2" } }}
+ >
+ 联系
+
@@ -865,6 +876,42 @@ export default function Home() {
)}
+ {/* ═══ Footer — legal links ═══ */}
+
+
+ 服务条款
+
+ |
+
+ 隐私政策
+
+ |
+
+ GitHub
+
+ |
+
+ 合作联系 jmr@jiangmuran.com
+
+
+
);
}
diff --git a/frontend/src/pages/Report.tsx b/frontend/src/pages/Report.tsx
index eb9e6a7..241151e 100644
--- a/frontend/src/pages/Report.tsx
+++ b/frontend/src/pages/Report.tsx
@@ -217,7 +217,7 @@ export default function Report() {
优化建议
-
+
@@ -399,12 +399,12 @@ export default function Report() {
Agent 诊断详情
-
+
模拟评论区
本报告由 AI 多 Agent 协作生成,仅供参考
+
+ NoteRx 是公益开源项目 · 合作联系{" "}
+
+ jmr@jiangmuran.com
+
+ {" · "}
+
+ GitHub
+
+
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index fc8eee4..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: {
@@ -16,6 +18,31 @@ export default defineConfig(({ mode }) => {
target,
changeOrigin: true,
},
+ '/terms': {
+ target,
+ changeOrigin: true,
+ },
+ '/privacy': {
+ target,
+ changeOrigin: true,
+ },
+ },
+ },
+ 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": {}
+}