nmail-app 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. nmail_app-0.1.0/LICENSE +21 -0
  2. nmail_app-0.1.0/PKG-INFO +105 -0
  3. nmail_app-0.1.0/README.md +83 -0
  4. nmail_app-0.1.0/backend/app/__init__.py +0 -0
  5. nmail_app-0.1.0/backend/app/ai/__init__.py +0 -0
  6. nmail_app-0.1.0/backend/app/ai/digest.py +167 -0
  7. nmail_app-0.1.0/backend/app/ai/llm.py +105 -0
  8. nmail_app-0.1.0/backend/app/ai/profiles.py +84 -0
  9. nmail_app-0.1.0/backend/app/ai/prompts.py +61 -0
  10. nmail_app-0.1.0/backend/app/ai/tasks.py +319 -0
  11. nmail_app-0.1.0/backend/app/api/__init__.py +17 -0
  12. nmail_app-0.1.0/backend/app/api/accounts.py +297 -0
  13. nmail_app-0.1.0/backend/app/api/ai.py +273 -0
  14. nmail_app-0.1.0/backend/app/api/chats.py +158 -0
  15. nmail_app-0.1.0/backend/app/api/digest.py +31 -0
  16. nmail_app-0.1.0/backend/app/api/drafts.py +210 -0
  17. nmail_app-0.1.0/backend/app/api/emails.py +305 -0
  18. nmail_app-0.1.0/backend/app/api/notifications.py +44 -0
  19. nmail_app-0.1.0/backend/app/api/profiles.py +125 -0
  20. nmail_app-0.1.0/backend/app/api/sender_lists.py +68 -0
  21. nmail_app-0.1.0/backend/app/api/settings.py +108 -0
  22. nmail_app-0.1.0/backend/app/api/system.py +16 -0
  23. nmail_app-0.1.0/backend/app/cli.py +55 -0
  24. nmail_app-0.1.0/backend/app/config.py +29 -0
  25. nmail_app-0.1.0/backend/app/core/__init__.py +0 -0
  26. nmail_app-0.1.0/backend/app/core/imap_client.py +291 -0
  27. nmail_app-0.1.0/backend/app/core/mail_html.py +102 -0
  28. nmail_app-0.1.0/backend/app/core/pipeline.py +248 -0
  29. nmail_app-0.1.0/backend/app/core/providers.py +205 -0
  30. nmail_app-0.1.0/backend/app/core/sync.py +239 -0
  31. nmail_app-0.1.0/backend/app/db/__init__.py +0 -0
  32. nmail_app-0.1.0/backend/app/db/database.py +247 -0
  33. nmail_app-0.1.0/backend/app/main.py +75 -0
  34. nmail_app-0.1.0/backend/app/scheduler.py +99 -0
  35. nmail_app-0.1.0/backend/app/security.py +49 -0
  36. nmail_app-0.1.0/backend/app/static/apple-touch-icon.png +0 -0
  37. nmail_app-0.1.0/backend/app/static/assets/index-CYJbPW-N.css +1 -0
  38. nmail_app-0.1.0/backend/app/static/assets/index-CiXbxTmN.js +360 -0
  39. nmail_app-0.1.0/backend/app/static/favicon.ico +0 -0
  40. nmail_app-0.1.0/backend/app/static/icon-192.png +0 -0
  41. nmail_app-0.1.0/backend/app/static/icon-512.png +0 -0
  42. nmail_app-0.1.0/backend/app/static/index.html +18 -0
  43. nmail_app-0.1.0/backend/nmail_app.egg-info/PKG-INFO +105 -0
  44. nmail_app-0.1.0/backend/nmail_app.egg-info/SOURCES.txt +48 -0
  45. nmail_app-0.1.0/backend/nmail_app.egg-info/dependency_links.txt +1 -0
  46. nmail_app-0.1.0/backend/nmail_app.egg-info/entry_points.txt +2 -0
  47. nmail_app-0.1.0/backend/nmail_app.egg-info/requires.txt +11 -0
  48. nmail_app-0.1.0/backend/nmail_app.egg-info/top_level.txt +1 -0
  49. nmail_app-0.1.0/pyproject.toml +40 -0
  50. nmail_app-0.1.0/setup.cfg +4 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nmail contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,105 @@
1
+ Metadata-Version: 2.4
2
+ Name: nmail-app
3
+ Version: 0.1.0
4
+ Summary: AI 驱动的本地聚合邮箱客户端 · 本地优先 · 隐私自持
5
+ License: MIT
6
+ Keywords: email,imap,smtp,ai,local-first
7
+ Requires-Python: >=3.11
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Requires-Dist: fastapi>=0.115
11
+ Requires-Dist: uvicorn>=0.30
12
+ Requires-Dist: platformdirs>=4.0
13
+ Requires-Dist: openai>=1.50
14
+ Requires-Dist: httpx>=0.27
15
+ Requires-Dist: imap-tools>=1.5
16
+ Requires-Dist: beautifulsoup4>=4.12
17
+ Requires-Dist: markdown>=3.6
18
+ Requires-Dist: nh3>=0.2
19
+ Requires-Dist: apscheduler>=3.10
20
+ Requires-Dist: python-multipart>=0.0.9
21
+ Dynamic: license-file
22
+
23
+ # Nmail
24
+
25
+ AI 驱动的本地聚合邮箱客户端 · 本地优先 · 隐私自持 · MIT 开源。
26
+
27
+ 你只管看信、写信、拍板;AI 负责分类、过滤噪音、预先写好草稿、每天给你汇报。数据全部留在本机,AI 用你自己的 OpenAI 兼容 API key(也可指向 Ollama / LM Studio 实现 100% 本地推理),纯 IMAP/SMTP 客户端,不自建任何邮件服务。
28
+
29
+ > 产品方案与路线图见 [docs/PRODUCT_PLAN.md](docs/PRODUCT_PLAN.md)。当前进度:**P4 打磨**。
30
+
31
+ ## 快速开始
32
+
33
+ 三种方式任选:
34
+
35
+ **① 单文件可执行(零依赖,双击即用)**
36
+
37
+ 到 [Releases](../../releases) 下载对应平台文件(Windows: `nmail-windows-x64.exe`)双击运行,自动打开浏览器。
38
+
39
+ - Windows 可能弹 SmartScreen 提示(未签名):点「更多信息 → 仍要运行」
40
+ - macOS 首次运行需右键 → 打开(未公证);Linux:`chmod +x nmail-linux-x64` 后直接运行
41
+
42
+ **② 一条命令(PyPI + uv,推荐日常使用)**
43
+
44
+ 安装 [uv](https://docs.astral.sh/uv/getting-started/installation/) 后执行:
45
+
46
+ ```bash
47
+ uvx --from nmail-app nmail
48
+ ```
49
+
50
+ uv 自动准备 Python 运行时,无需手动安装 Python / Node。也可 `pip install nmail-app` 后直接运行 `nmail`(PyPI 发行名为 `nmail-app`——`nmail` 已被第三方占用;命令名与产品名不变)。
51
+
52
+ **③ 源码开发**
53
+
54
+ 要求:Python 3.11+、Node.js 18+。
55
+
56
+ ```bash
57
+ # 1. 后端依赖
58
+ python -m venv .venv
59
+ # Windows:
60
+ .venv\Scripts\pip install -r backend/requirements.txt
61
+ # macOS / Linux:
62
+ # source .venv/bin/activate && pip install -r backend/requirements.txt
63
+
64
+ # 2. 前端构建
65
+ cd frontend && npm install && npm run build && cd ..
66
+
67
+ # 3. 启动(自动打开浏览器)
68
+ python run.py
69
+ ```
70
+
71
+ 浏览器访问 http://127.0.0.1:8720 —— 首次使用请到「设置 → AI 配置」新增配置档案(Base URL + API Key + 模型名,可保存多套随时切换),点「测试连接」验证。
72
+
73
+ ## 自行打包
74
+
75
+ ```bash
76
+ bash scripts/sync_frontend.sh # 构建前端并同步进 Python 包
77
+ .venv/Scripts/pip install pyinstaller # 依赖已在 requirements 内
78
+ .venv/Scripts/pyinstaller nmail.spec # 产出 dist/nmail 单文件(Windows 为 nmail.exe)
79
+ ```
80
+
81
+ 或只构建 wheel:`pip wheel . -w dist`。发布 PyPI 后用户即可 `uvx nmail`。打 `v*` tag 时 CI(`.github/workflows/release.yml`)自动完成 wheel 发布与三平台二进制。
82
+
83
+ ## 开发模式
84
+
85
+ ```bash
86
+ # 后端热重载
87
+ cd backend && ../.venv/Scripts/python -m uvicorn app.main:app --reload --port 8720
88
+
89
+ # 前端 dev server(/api 已代理到 8720)
90
+ cd frontend && npm run dev # http://localhost:5173
91
+ ```
92
+
93
+ ## 数据与隐私
94
+
95
+ - 数据目录遵循各平台标准位置(Windows: `%LOCALAPPDATA%\Nmail`),可用环境变量 `NMAIL_DATA_DIR` 覆盖。
96
+ - `nmail.db`:邮件与索引(SQLite,后续版本);`secrets.json`:AI API key 等密钥。
97
+ - 服务仅绑定 `127.0.0.1`;API key 不回传前端;使用云端 AI 端点时邮件正文会发送到该端点,指向本地端点(Ollama 等)则 0 外发。
98
+
99
+ ## 技术栈
100
+
101
+ Python 3.11+ · FastAPI · SQLite (WAL + FTS5) · APScheduler | React 18 · Vite · TypeScript · Tailwind CSS · ECharts(摘要可视化)
102
+
103
+ ## 许可证
104
+
105
+ [MIT](LICENSE)
@@ -0,0 +1,83 @@
1
+ # Nmail
2
+
3
+ AI 驱动的本地聚合邮箱客户端 · 本地优先 · 隐私自持 · MIT 开源。
4
+
5
+ 你只管看信、写信、拍板;AI 负责分类、过滤噪音、预先写好草稿、每天给你汇报。数据全部留在本机,AI 用你自己的 OpenAI 兼容 API key(也可指向 Ollama / LM Studio 实现 100% 本地推理),纯 IMAP/SMTP 客户端,不自建任何邮件服务。
6
+
7
+ > 产品方案与路线图见 [docs/PRODUCT_PLAN.md](docs/PRODUCT_PLAN.md)。当前进度:**P4 打磨**。
8
+
9
+ ## 快速开始
10
+
11
+ 三种方式任选:
12
+
13
+ **① 单文件可执行(零依赖,双击即用)**
14
+
15
+ 到 [Releases](../../releases) 下载对应平台文件(Windows: `nmail-windows-x64.exe`)双击运行,自动打开浏览器。
16
+
17
+ - Windows 可能弹 SmartScreen 提示(未签名):点「更多信息 → 仍要运行」
18
+ - macOS 首次运行需右键 → 打开(未公证);Linux:`chmod +x nmail-linux-x64` 后直接运行
19
+
20
+ **② 一条命令(PyPI + uv,推荐日常使用)**
21
+
22
+ 安装 [uv](https://docs.astral.sh/uv/getting-started/installation/) 后执行:
23
+
24
+ ```bash
25
+ uvx --from nmail-app nmail
26
+ ```
27
+
28
+ uv 自动准备 Python 运行时,无需手动安装 Python / Node。也可 `pip install nmail-app` 后直接运行 `nmail`(PyPI 发行名为 `nmail-app`——`nmail` 已被第三方占用;命令名与产品名不变)。
29
+
30
+ **③ 源码开发**
31
+
32
+ 要求:Python 3.11+、Node.js 18+。
33
+
34
+ ```bash
35
+ # 1. 后端依赖
36
+ python -m venv .venv
37
+ # Windows:
38
+ .venv\Scripts\pip install -r backend/requirements.txt
39
+ # macOS / Linux:
40
+ # source .venv/bin/activate && pip install -r backend/requirements.txt
41
+
42
+ # 2. 前端构建
43
+ cd frontend && npm install && npm run build && cd ..
44
+
45
+ # 3. 启动(自动打开浏览器)
46
+ python run.py
47
+ ```
48
+
49
+ 浏览器访问 http://127.0.0.1:8720 —— 首次使用请到「设置 → AI 配置」新增配置档案(Base URL + API Key + 模型名,可保存多套随时切换),点「测试连接」验证。
50
+
51
+ ## 自行打包
52
+
53
+ ```bash
54
+ bash scripts/sync_frontend.sh # 构建前端并同步进 Python 包
55
+ .venv/Scripts/pip install pyinstaller # 依赖已在 requirements 内
56
+ .venv/Scripts/pyinstaller nmail.spec # 产出 dist/nmail 单文件(Windows 为 nmail.exe)
57
+ ```
58
+
59
+ 或只构建 wheel:`pip wheel . -w dist`。发布 PyPI 后用户即可 `uvx nmail`。打 `v*` tag 时 CI(`.github/workflows/release.yml`)自动完成 wheel 发布与三平台二进制。
60
+
61
+ ## 开发模式
62
+
63
+ ```bash
64
+ # 后端热重载
65
+ cd backend && ../.venv/Scripts/python -m uvicorn app.main:app --reload --port 8720
66
+
67
+ # 前端 dev server(/api 已代理到 8720)
68
+ cd frontend && npm run dev # http://localhost:5173
69
+ ```
70
+
71
+ ## 数据与隐私
72
+
73
+ - 数据目录遵循各平台标准位置(Windows: `%LOCALAPPDATA%\Nmail`),可用环境变量 `NMAIL_DATA_DIR` 覆盖。
74
+ - `nmail.db`:邮件与索引(SQLite,后续版本);`secrets.json`:AI API key 等密钥。
75
+ - 服务仅绑定 `127.0.0.1`;API key 不回传前端;使用云端 AI 端点时邮件正文会发送到该端点,指向本地端点(Ollama 等)则 0 外发。
76
+
77
+ ## 技术栈
78
+
79
+ Python 3.11+ · FastAPI · SQLite (WAL + FTS5) · APScheduler | React 18 · Vite · TypeScript · Tailwind CSS · ECharts(摘要可视化)
80
+
81
+ ## 许可证
82
+
83
+ [MIT](LICENSE)
File without changes
File without changes
@@ -0,0 +1,167 @@
1
+ """每日摘要生成。
2
+
3
+ 统计部分零成本(本地 SQL + Python 分桶);AI 只写一段综述,未配置 AI 时摘要依然可用。
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import logging
9
+ from collections import Counter
10
+ from datetime import date, datetime, timedelta, timezone
11
+
12
+ from app.ai import tasks
13
+ from app.core.sync import add_notification
14
+ from app.db.database import get_conn
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ CATEGORIES = ("work", "personal", "notification", "verification", "promo", "social")
19
+
20
+
21
+ def _to_local_dt(iso: str | None) -> datetime | None:
22
+ if not iso:
23
+ return None
24
+ try:
25
+ dt = datetime.fromisoformat(iso)
26
+ if dt.tzinfo is None:
27
+ dt = dt.replace(tzinfo=timezone.utc)
28
+ return dt.astimezone()
29
+ except ValueError:
30
+ return None
31
+
32
+
33
+ def _collect_stats() -> dict:
34
+ conn = get_conn()
35
+ now_local = datetime.now().astimezone()
36
+ today = now_local.date()
37
+ week_ago = today - timedelta(days=6)
38
+
39
+ rows = conn.execute(
40
+ "SELECT e.id, e.account_id, e.subject, e.sender_name, e.sender_email, e.date,"
41
+ " e.is_read, e.archived_local, e.category, e.importance, e.needs_reply, e.reply_reason,"
42
+ " a.email AS account_email, a.color AS account_color"
43
+ " FROM emails e JOIN accounts a ON a.id = e.account_id"
44
+ " WHERE e.date >= ? OR e.date IS NULL"
45
+ " ORDER BY e.date DESC",
46
+ ((week_ago - timedelta(days=1)).isoformat(),),
47
+ ).fetchall()
48
+
49
+ new_today = unread = auto_archived_today = 0
50
+ by_category: Counter = Counter()
51
+ trend: Counter = Counter()
52
+ by_account: dict[int, dict] = {}
53
+ for row in rows:
54
+ dt = _to_local_dt(row["date"])
55
+ account = by_account.setdefault(row["account_id"], {
56
+ "email": row["account_email"],
57
+ "color": row["account_color"],
58
+ "count": 0,
59
+ "unread": 0,
60
+ })
61
+ if not row["is_read"] and not row["archived_local"]:
62
+ unread += 1
63
+ account["unread"] += 1
64
+ if row["archived_local"] and row["category"] == "promo":
65
+ auto_archived_today += 1
66
+ if dt and dt.date() == today:
67
+ new_today += 1
68
+ account["count"] += 1
69
+ if row["category"]:
70
+ by_category[row["category"]] += 1
71
+ if dt and week_ago <= dt.date() <= today and not row["archived_local"]:
72
+ trend[dt.date().isoformat()] += 1
73
+
74
+ # 需要回复(未归档、未回复过):已发送草稿视为已回复
75
+ sent_ids = {
76
+ r["email_id"] for r in conn.execute(
77
+ "SELECT DISTINCT email_id FROM drafts WHERE status = 'sent'"
78
+ ).fetchall()
79
+ }
80
+ need_reply = []
81
+ important = []
82
+ for row in rows:
83
+ if row["archived_local"]:
84
+ continue
85
+ item = {
86
+ "email_id": row["id"],
87
+ "subject": row["subject"],
88
+ "sender": row["sender_name"] or row["sender_email"],
89
+ "date": row["date"],
90
+ }
91
+ if row["needs_reply"] and row["id"] not in sent_ids:
92
+ need_reply.append({**item, "reason": row["reply_reason"] or "", "has_draft": _has_draft(row["id"])})
93
+ if row["importance"] in ("critical", "high") and row["category"] != "promo":
94
+ important.append({**item, "category": row["category"], "importance": row["importance"],
95
+ "reason": row["reply_reason"] or ""})
96
+
97
+ important.sort(key=lambda x: (x["importance"] != "critical", x["date"] or ""), reverse=False)
98
+
99
+ return {
100
+ "date": today.isoformat(),
101
+ "overview": {
102
+ "new_today": new_today,
103
+ "unread": unread,
104
+ "auto_archived": auto_archived_today,
105
+ "need_reply": len(need_reply),
106
+ },
107
+ "by_category": {c: by_category.get(c, 0) for c in CATEGORIES},
108
+ "trend": [
109
+ {"day": (week_ago + timedelta(days=i)).isoformat(),
110
+ "count": trend.get((week_ago + timedelta(days=i)).isoformat(), 0)}
111
+ for i in range(7)
112
+ ],
113
+ "by_account": list(by_account.values()),
114
+ "need_reply": need_reply[:20],
115
+ "important": important[:10],
116
+ }
117
+
118
+
119
+ def _has_draft(email_id: int) -> bool:
120
+ row = get_conn().execute(
121
+ "SELECT 1 FROM drafts WHERE email_id = ? AND status IN ('pending','sent') LIMIT 1",
122
+ (email_id,),
123
+ ).fetchone()
124
+ return bool(row)
125
+
126
+
127
+ def _ai_overview(stats: dict) -> str:
128
+ lines = [f"今日新邮件 {stats['overview']['new_today']} 封,未读 {stats['overview']['unread']} 封,"
129
+ f"自动归档营销 {stats['overview']['auto_archived']} 封。"]
130
+ if stats["by_category"]:
131
+ cats = "、".join(f"{k} {v} 封" for k, v in stats["by_category"].items() if v)
132
+ lines.append(f"分类分布:{cats}。")
133
+ if stats["need_reply"]:
134
+ lines.append("需要回复:" + ";".join(
135
+ f"{i['sender']}的「{i['subject'][:30]}」({i['reason'][:20]})" for i in stats["need_reply"][:5]))
136
+ if stats["important"]:
137
+ lines.append("重要邮件:" + ";".join(
138
+ f"「{i['subject'][:30]}」" for i in stats["important"][:5]))
139
+ user = "以下是今日邮箱统计数据,请写一段 3-5 句的中文每日综述,突出最需要用户注意的事(验证码、账单、截止日期、重要来信)。只输出综述本身。\n\n" + "\n".join(lines)
140
+ try:
141
+ return tasks.digest_overview(user)
142
+ except Exception as exc: # noqa: BLE001 — 综述失败不影响结构化摘要
143
+ logger.warning("digest ai overview failed: %s", exc)
144
+ return ""
145
+
146
+
147
+ def build_digest(force: bool = False) -> dict:
148
+ today = date.today().isoformat()
149
+ conn = get_conn()
150
+ if not force:
151
+ existing = conn.execute(
152
+ "SELECT content_json FROM digest_history WHERE date = ?", (today,)
153
+ ).fetchone()
154
+ if existing:
155
+ return json.loads(existing["content_json"])
156
+
157
+ stats = _collect_stats()
158
+ stats["ai_overview"] = _ai_overview(stats)
159
+ conn.execute(
160
+ "INSERT INTO digest_history (date, content_json) VALUES (?, ?)"
161
+ " ON CONFLICT(date) DO UPDATE SET content_json = excluded.content_json,"
162
+ " created_at = datetime('now')",
163
+ (today, json.dumps(stats, ensure_ascii=False)),
164
+ )
165
+ conn.commit()
166
+ add_notification("digest", "今日邮件摘要已生成", "到「每日摘要」页查看", today)
167
+ return stats
@@ -0,0 +1,105 @@
1
+ """OpenAI 兼容客户端。
2
+
3
+ base_url 可指向任意 OpenAI 兼容端点(OpenAI / DeepSeek / Ollama / LM Studio 等);
4
+ api_key 为空时使用占位符,以兼容 Ollama 等不校验密钥的本地服务。
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import time
9
+
10
+ import httpx
11
+ from openai import OpenAI
12
+
13
+ # 按阶段收紧超时:connect 5s、读写 15s,坏端点/断网时快速失败而非长时间挂起
14
+ AI_TIMEOUT = httpx.Timeout(15.0, connect=5.0)
15
+
16
+
17
+ def build_client(base_url: str, api_key: str | None, timeout: httpx.Timeout = AI_TIMEOUT) -> OpenAI:
18
+ return OpenAI(
19
+ base_url=base_url or None,
20
+ api_key=api_key or "EMPTY",
21
+ timeout=timeout,
22
+ max_retries=0,
23
+ )
24
+
25
+
26
+ def chat(base_url: str, model: str, api_key: str | None,
27
+ system: str, user: str, **kwargs) -> tuple[str, dict]:
28
+ """单轮对话,返回 (回复文本, 用量 dict)。"""
29
+ messages = [
30
+ {"role": "system", "content": system},
31
+ {"role": "user", "content": user},
32
+ ]
33
+ return chat_messages(base_url, model, api_key, messages, **kwargs)
34
+
35
+
36
+ def chat_messages(base_url: str, model: str, api_key: str | None,
37
+ messages: list[dict], max_tokens: int = 2000,
38
+ temperature: float = 0.3) -> tuple[str, dict]:
39
+ """多轮对话,返回 (回复文本, {prompt_tokens, completion_tokens})。"""
40
+ client = build_client(base_url, api_key)
41
+ resp = client.chat.completions.create(
42
+ model=model,
43
+ messages=messages, # type: ignore[arg-type]
44
+ max_tokens=max_tokens,
45
+ temperature=temperature,
46
+ )
47
+ usage = {
48
+ "prompt_tokens": getattr(resp.usage, "prompt_tokens", 0) or 0,
49
+ "completion_tokens": getattr(resp.usage, "completion_tokens", 0) or 0,
50
+ }
51
+ content = resp.choices[0].message.content or "" if resp.choices else ""
52
+ return content, usage
53
+
54
+
55
+ def iter_deltas(base_url: str, model: str, api_key: str | None,
56
+ messages: list[dict], usage_out: dict | None = None,
57
+ max_tokens: int = 2000, temperature: float = 0.3):
58
+ """流式对话:逐段 yield 文本增量;支持时在最后一个 chunk 回填 token 用量。"""
59
+ client = build_client(base_url, api_key)
60
+ stream = client.chat.completions.create(
61
+ model=model,
62
+ messages=messages, # type: ignore[arg-type]
63
+ max_tokens=max_tokens,
64
+ temperature=temperature,
65
+ stream=True,
66
+ stream_options={"include_usage": True},
67
+ )
68
+ for chunk in stream:
69
+ if usage_out is not None and getattr(chunk, "usage", None) is not None:
70
+ usage_out["prompt_tokens"] = getattr(chunk.usage, "prompt_tokens", 0) or 0
71
+ usage_out["completion_tokens"] = getattr(chunk.usage, "completion_tokens", 0) or 0
72
+ if chunk.choices:
73
+ delta = chunk.choices[0].delta
74
+ content = getattr(delta, "content", None)
75
+ if content:
76
+ yield content
77
+
78
+
79
+ def test_connection(base_url: str, model: str, api_key: str | None) -> dict:
80
+ """发送一个极小请求,验证端点 / 密钥 / 模型名是否可用。"""
81
+ started = time.perf_counter()
82
+ try:
83
+ client = build_client(base_url, api_key)
84
+ resp = client.chat.completions.create(
85
+ model=model,
86
+ messages=[{"role": "user", "content": "Reply with the single word: pong"}],
87
+ max_tokens=10,
88
+ temperature=0,
89
+ )
90
+ latency_ms = int((time.perf_counter() - started) * 1000)
91
+ return {
92
+ "ok": True,
93
+ "model": model,
94
+ "reply": (resp.choices[0].message.content or "").strip(),
95
+ "latency_ms": latency_ms,
96
+ "error": None,
97
+ }
98
+ except Exception as exc: # 网络 / 鉴权 / 模型名错误统一转为友好结果
99
+ return {
100
+ "ok": False,
101
+ "model": model,
102
+ "reply": None,
103
+ "latency_ms": int((time.perf_counter() - started) * 1000),
104
+ "error": str(exc),
105
+ }
@@ -0,0 +1,84 @@
1
+ """AI 配置档案(多模型 / 多 API Key)。
2
+
3
+ 档案存 settings 表(JSON 数组),密钥存 secrets.json(ai_profile_key:{id});
4
+ 密钥永不回传前端,接口只返回 api_key_set 布尔值。
5
+ 旧版单配置(ai_base_url / ai_model / ai_api_key)在首次读取时自动迁移为「默认」档案,
6
+ 历史密钥原样搬运,老用户无感。
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from app.db.database import get_setting, set_setting
11
+ from app.security import get_secret, has_secret, set_secret
12
+
13
+ PROFILES_KEY = "ai_profiles"
14
+ ACTIVE_KEY = "active_profile_id"
15
+ LEGACY_SECRET_KEY = "ai_api_key"
16
+
17
+ DEFAULT_PROFILE_ID = "default"
18
+
19
+
20
+ class ProfileNotConfigured(Exception):
21
+ """无可用档案,或档案不完整(缺 Base URL / 模型名)。"""
22
+
23
+
24
+ def secret_key(profile_id: str) -> str:
25
+ return f"ai_profile_key:{profile_id}"
26
+
27
+
28
+ def ensure_migrated() -> None:
29
+ """旧单配置 → 「默认」档案;幂等,只在档案数据缺失时执行一次。"""
30
+ if get_setting(PROFILES_KEY) is not None:
31
+ return
32
+ profiles = [
33
+ {
34
+ "id": DEFAULT_PROFILE_ID,
35
+ "name": "默认",
36
+ "base_url": (get_setting("ai_base_url", "") or "").strip(),
37
+ "model": (get_setting("ai_model", "") or "").strip(),
38
+ }
39
+ ]
40
+ set_setting(PROFILES_KEY, profiles)
41
+ set_setting(ACTIVE_KEY, DEFAULT_PROFILE_ID)
42
+ legacy = get_secret(LEGACY_SECRET_KEY)
43
+ if legacy and not has_secret(secret_key(DEFAULT_PROFILE_ID)):
44
+ set_secret(secret_key(DEFAULT_PROFILE_ID), legacy)
45
+
46
+
47
+ def list_profiles() -> list[dict]:
48
+ ensure_migrated()
49
+ return get_setting(PROFILES_KEY, [])
50
+
51
+
52
+ def save_profiles(profiles: list[dict]) -> None:
53
+ set_setting(PROFILES_KEY, profiles)
54
+
55
+
56
+ def get_active_id() -> str | None:
57
+ ensure_migrated()
58
+ return get_setting(ACTIVE_KEY, None)
59
+
60
+
61
+ def set_active_id(profile_id: str | None) -> None:
62
+ set_setting(ACTIVE_KEY, profile_id)
63
+
64
+
65
+ def resolve(profile_id: str | None = None) -> dict:
66
+ """取目标档案:显式指定 > 激活档案 > 第一个;无档案时抛 ProfileNotConfigured。"""
67
+ profiles = list_profiles()
68
+ if not profiles:
69
+ raise ProfileNotConfigured("未配置 AI 端点,请在 设置-AI 配置 中添加")
70
+ wanted = profile_id or get_active_id()
71
+ for p in profiles:
72
+ if p["id"] == wanted:
73
+ return p
74
+ return profiles[0]
75
+
76
+
77
+ def resolve_config(profile_id: str | None = None) -> tuple[str, str, str | None]:
78
+ """返回 (base_url, model, api_key);档案不完整时抛 ProfileNotConfigured。"""
79
+ p = resolve(profile_id)
80
+ base_url = (p.get("base_url") or "").strip()
81
+ model = (p.get("model") or "").strip()
82
+ if not base_url or not model:
83
+ raise ProfileNotConfigured(f"AI 配置「{p.get('name')}」不完整,请补全 Base URL 和模型名")
84
+ return base_url, model, get_secret(secret_key(p["id"]))
@@ -0,0 +1,61 @@
1
+ """AI 任务的提示词模板。
2
+
3
+ 约定:所有需要结构化输出的任务都要求模型只输出纯 JSON,不包裹代码块。
4
+ """
5
+
6
+ CLASSIFY_SYSTEM = """你是邮件分类助手。对给定的每封邮件输出分类结果,只输出纯 JSON 数组,不要任何其他文字。
7
+
8
+ 每封邮件输出一个对象:
9
+ {"id": <原样返回的邮件 id>, "category": "<六选一>", "importance": "<四选一>", "needs_reply": <布尔>, "reason": "<不超过20字的中文理由>"}
10
+
11
+ category 六选一:
12
+ - work:工作/业务往来,需要人处理的正式通信
13
+ - personal:亲友等个人来信
14
+ - notification:系统通知(订单、账单、部署、安全提醒等自动发送)
15
+ - verification:验证码/一次性密码
16
+ - promo:营销/推广/订阅通讯/广告
17
+ - social:社交网络通知(点赞、关注、评论)
18
+
19
+ importance 四选一:critical(验证码、紧急、账单扣款、安全)、high(明确需要行动或有截止日期)、normal、low(营销、可忽略)。
20
+
21
+ needs_reply 判断:对方明确提问/请求/等待答复才为 true;通知、验证码、营销一律 false。
22
+ reason 用中文,说明关键依据。"""
23
+
24
+ CLASSIFY_USER_TEMPLATE = """请分类以下 {count} 封邮件:
25
+
26
+ {emails}"""
27
+
28
+ DRAFT_SYSTEM = """你是邮件回复助手,替用户起草回复。要求:
29
+ 1. 语言:跟随来信的语言(中文来信用中文回复,英文来信用英文回复)
30
+ 2. 风格:简洁、专业、礼貌,像真实的工作邮件,不啰嗦不堆砌
31
+ 3. 只输出邮件正文本身(Markdown 格式),不要主题行、不要称呼"亲爱的用户"式套话之外的任何解释
32
+ 4. 开头有合适的称呼(如来信人名字),结尾不签名(系统会自动处理)
33
+ 5. 直接回应来信中的问题/请求;如果来信信息不足,礼貌地提出需要澄清的问题"""
34
+
35
+ DRAFT_USER_TEMPLATE = """请以 {my_email} 的身份,给以下来信写一封回复草稿。
36
+
37
+ 来信信息:
38
+ - 发件人:{sender}({sender_email})
39
+ - 主题:{subject}
40
+ - 日期:{date}
41
+
42
+ 来信正文:
43
+ {body}"""
44
+
45
+ CHAT_SYSTEM = """你是 Nmail 的邮件助手,基于用户提供的邮件上下文回答问题。
46
+ 要求:用中文回答(用户明确要求其他语言时除外);简洁准确;引用邮件内容时注明发件人;
47
+ 不知道就说不知道,不要编造邮件里不存在的内容。"""
48
+
49
+ WRITE_OPS = {
50
+ "polish": "润色这段文字,保持原意和语言,修正语病,让它更通顺专业",
51
+ "formal": "把这段文字改写得更正式、更礼貌,保持语言不变",
52
+ "casual": "把这段文字改写得更轻松随意一些,保持语言不变",
53
+ "shorten": "把这段文字压缩到一半左右的长度,保留核心信息,保持语言不变",
54
+ "expand": "把这段文字适当扩充,补充合理的细节和过渡,保持语言不变",
55
+ "translate_zh": "把这段文字翻译成中文",
56
+ "translate_en": "把这段文字翻译成英文",
57
+ }
58
+
59
+ WRITE_USER_TEMPLATE = """{instruction}:
60
+
61
+ {text}"""