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 中,完全由您本地控制。您可以随时在浏览器设置中清除这些数据,服务端无法访问您的本地存储。

+ +

匿名数据

+

为优化产品体验,我们会收集以下匿名数据:

+ +
+ 以上数据均以匿名形式记录,无法追溯到具体个人。我们承诺不会将这些数据用于广告投放或个人画像分析。 +
+ +

第三方服务

+

本平台调用小米 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)中。服务端不保存任何个人数据,也不会将您提交的笔记内容用于模型训练或其他用途。

+

您可以随时在浏览器中清除本地存储的诊断历史。

+ +

匿名使用数据

+

为了产品改进和服务优化,我们会记录以下匿名使用数据:

+ +

上述数据仅用于统计分析和产品优化,不会用于识别个人身份。

+ +

知识产权

+

薯医 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 ( + + {/* ───── Hero ───── */} + + + + + + + + + + + + + + + + NoteRx 是公益项目 + + + 完全免费 · 完全开源 · 由团队自费运营 + + + + + {/* ───── Content ───── */} + + {/* Stats row */} + + {STATS.map((s) => ( + + + {s.icon} + + + {s.val} + + + {s.label} + + + ))} + + + {/* Links: GitHub + Homepage */} + + } + label="开源仓库" + sublabel="github.com/jiangmuran/noterx" + href="https://github.com/jiangmuran/noterx" + /> + } + label="开发者主页" + sublabel="jiangmuran.com" + href="https://jiangmuran.com" + /> + + + {/* Sustainability note */} + + + 由于服务器与 AI Token 成本持续增长,项目可能会在赞助资源耗尽后暂停服务。如果您觉得 NoteRx 有价值,欢迎通过赞助或合作帮助我们走得更远。 + + + + {/* Collaboration */} + + + + + 广告位招租 · 有偿合作 + + + 我们开放广告位与商业合作。如有赞助、推广、技术合作意向,欢迎来信并附上联系方式、合作事由与意向报价。 + + + + + {/* Contact email */} + + + + 合作联系{" "} + + jmr@jiangmuran.com + + + + + {/* CTA */} + + + + 此弹窗仅在首次访问时展示 + + + + ); +} 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() { > 历史 + @@ -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": {} +}