Ac66 commited on
Commit
4cdaef7
·
verified ·
1 Parent(s): dae0269

Upload folder using huggingface_hub

Browse files
.env ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # [自动填充] notion-2api 生产环境配置
2
+
3
+ # --- 安全配置 ---
4
+ API_MASTER_KEY=1
5
+
6
+ # --- 端口配置 ---
7
+ NGINX_PORT=8088
8
+
9
+ # --- Notion 凭证 (以下均为必须或强烈建议设置) ---
10
+
11
+ # 1. 您的 token_v2 (已从最新日志中提取并更新)
12
+ NOTION_COOKIE="v03%3AeyJhbGciOiJkaXIiLCJraWQiOiJwcm9kdWN0aW9uOnRva2VuLXYzOjIwMjQtMTEtMDciLCJlbmMiOiJBMjU2Q0JDLUhTNTEyIn0..mIRgS9AYZx8rn6OUJ7F9pA.QFex5O4ZzVCLG1JCNOgbbqmYf9IyntouodTfm2wn7LbmY0Zs-akV51n3dwtaC2K3ctm9Jj91PVRsl-6k9phiTUaIO_3FtSYmEYZrmCYEXa1iWJAwdROmySSRcMeSwsswgakVanb-sal9B8IH-YACTq9SLfooARLw65pwljahMdG-jJKi5X2PwfUrENeeRGDTQF0I6SLxp0-VxzOuWn-MDPej-S40hbDQY9kDyDZ9tyaYptOsu3KEP1M6HiwD0kqqQETUdYFPbYqK8ItPdKDyrFr8zIo21zfMAwLMeSvvTda-cBm0OVnBuGvqlLA92dVYON55mts-r_U2Xmjt9g9pAwL_GG8-HW9Qo-IyiaO9oB4.D17Jn2Mp6Y62_lbuZ0Ggz0ugnej-Ue7coltqqYHI-KE"
13
+
14
+ # 2. 您的 Space ID (保持不变)
15
+ NOTION_SPACE_ID="f108eefa-d0dc-8181-8382-0003e15d764e"
16
+
17
+ # 3. 您的用户 ID (从日志中提取)
18
+ NOTION_USER_ID="200d872b-594c-8153-b674-00028d202a8b"
19
+
20
+ # 4. 您的 Notion 用户名 (请确认是否准确)
21
+ NOTION_USER_NAME="利仔"
22
+
23
+ # 5. 您的 Notion 登录邮箱 (请替换为您的真实邮箱)
24
+ NOTION_USER_EMAIL="q13645947407@gmail.com"
25
+
26
+ # 6. 可选:页面 Block ID (保持留空以提高兼容性)
27
+ NOTION_BLOCK_ID=""
28
+
29
+ # 7. 可选:客户端版本 (保持默认即可)
30
+ NOTION_CLIENT_VERSION="23.13.20251011.2037"
.env.example ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ====================================================================
2
+ # notion-2api 配置文件模板 (最终版)
3
+ # ====================================================================
4
+ #
5
+ # 请将此文件重命名为 ".env" 并填入您的凭证。
6
+ #
7
+
8
+ # --- 核心安全配置 (可选) ---
9
+ API_MASTER_KEY=your_secret_key_here
10
+
11
+ # --- 部署配置 (可选) ---
12
+ NGINX_PORT=8088
13
+
14
+ # --- Notion 凭证 (以下均为必须或强烈建议设置) ---
15
+ # 1) 粘贴 token_v2 的值 或 完整 Cookie
16
+ NOTION_COOKIE="在此处粘贴 token_v2 值 或 完整 Cookie"
17
+
18
+ # 2) 您的 Space ID
19
+ NOTION_SPACE_ID="在此处粘贴您的 Space ID"
20
+
21
+ # 3) 您的用户 ID (浏览器开发者工具中 x-notion-active-user-header 的值)
22
+ NOTION_USER_ID="在此处粘贴您的 Notion 用户 ID"
23
+
24
+ # 4) 您的 Notion 用户名 (显示在左上角的名称)
25
+ NOTION_USER_NAME="利仔"
26
+
27
+ # 5) 您的 Notion 登录邮箱
28
+ NOTION_USER_EMAIL="q13645947407@gmail.com"
29
+
30
+ # 可选:想绑定的页面 blockId。留空则不绑定特定页面上下文。
31
+ NOTION_BLOCK_ID=""
32
+
33
+ # 可选:浏览器中看到的客户端版本
34
+ NOTION_CLIENT_VERSION="23.13.20251011.2037"
Dockerfile ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ====================================================================
2
+ # Dockerfile for inception-2api (v4.0 - Cloudscraper Edition)
3
+ # ====================================================================
4
+
5
+ FROM python:3.10-slim
6
+
7
+ # 设置环境变量
8
+ ENV PYTHONDONTWRITEBYTECODE=1
9
+ ENV PYTHONUNBUFFERED=1
10
+ WORKDIR /app
11
+
12
+ # 安装 Python 依赖
13
+ COPY requirements.txt .
14
+ RUN pip install --no-cache-dir --upgrade pip && \
15
+ pip install --no-cache-dir -r requirements.txt
16
+
17
+ # 复制应用代码
18
+ COPY . .
19
+
20
+ # 创建并切换到非 root 用户
21
+ RUN useradd --create-home appuser && \
22
+ chown -R appuser:appuser /app
23
+ USER appuser
24
+
25
+ # 暴露端口并启动
26
+ EXPOSE 8000
27
+ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4"]
LICENSE ADDED
@@ -0,0 +1,201 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright [yyyy] [name of copyright owner]
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
README.md CHANGED
@@ -1,10 +1,15 @@
1
  ---
2
- title: Notion
3
- emoji: 🏃
4
- colorFrom: indigo
5
- colorTo: pink
6
  sdk: docker
7
- pinned: false
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
1
  ---
2
+ title: "notion"
3
+ emoji: "🚀"
4
+ colorFrom: blue
5
+ colorTo: green
6
  sdk: docker
7
+ app_port: 8080
8
  ---
9
 
10
+ ### 🚀 一键部署
11
+ [![Deploy with HFSpaceDeploy](https://img.shields.io/badge/Deploy_with-HFSpaceDeploy-green?style=social&logo=rocket)](https://github.com/kfcx/HFSpaceDeploy)
12
+
13
+ 本项目由[HFSpaceDeploy](https://github.com/kfcx/HFSpaceDeploy)一键部署
14
+
15
+
app/core/__init__.py ADDED
File without changes
app/core/config.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app/core/config.py
2
+ from pydantic_settings import BaseSettings, SettingsConfigDict
3
+ from typing import List, Optional
4
+
5
+ class Settings(BaseSettings):
6
+ model_config = SettingsConfigDict(
7
+ env_file=".env",
8
+ env_file_encoding='utf-8',
9
+ extra="ignore"
10
+ )
11
+
12
+ APP_NAME: str = "notion-2api"
13
+ APP_VERSION: str = "4.0.0" # 最终稳定版
14
+ DESCRIPTION: str = "一个将 Notion AI 转换为兼容 OpenAI 格式 API 的高性能代理。"
15
+
16
+ API_MASTER_KEY: Optional[str] = None
17
+
18
+ # --- Notion 凭证 ---
19
+ NOTION_COOKIE: Optional[str] = None
20
+ NOTION_SPACE_ID: Optional[str] = None
21
+ NOTION_USER_ID: Optional[str] = None
22
+ NOTION_USER_NAME: Optional[str] = None
23
+ NOTION_USER_EMAIL: Optional[str] = None
24
+ NOTION_BLOCK_ID: Optional[str] = None
25
+ NOTION_CLIENT_VERSION: Optional[str] = "23.13.20251011.2037"
26
+
27
+ API_REQUEST_TIMEOUT: int = 180
28
+ NGINX_PORT: int = 8088
29
+
30
+ # 【最终修正】更新所有已知的模型列表
31
+ DEFAULT_MODEL: str = "claude-sonnet-4.5"
32
+
33
+ KNOWN_MODELS: List[str] = [
34
+ "claude-sonnet-4.5",
35
+ "gpt-5",
36
+ "claude-opus-4.1",
37
+ "gemini-2.5-flash(未修复,不可用)",
38
+ "gemini-2.5-pro(未修复,不可用)",
39
+ "gpt-4.1"
40
+ ]
41
+
42
+ # 【最终修正】根据您提供的信息,填充所有模型的真实后台名称
43
+ MODEL_MAP: dict = {
44
+ "claude-sonnet-4.5": "anthropic-sonnet-alt",
45
+ "gpt-5": "openai-turbo",
46
+ "claude-opus-4.1": "anthropic-opus-4.1",
47
+ "gemini-2.5-flash(未修复,不可用)": "vertex-gemini-2.5-flash",
48
+ "gemini-2.5-pro(未修复,不可用)": "vertex-gemini-2.5-pro",
49
+ "gpt-4.1": "openai-gpt-4.1"
50
+ }
51
+
52
+ settings = Settings()
app/providers/__init__.py ADDED
File without changes
app/providers/base_provider.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from abc import ABC, abstractmethod
2
+ from typing import Dict, Any, Union
3
+ from fastapi.responses import StreamingResponse, JSONResponse
4
+
5
+ class BaseProvider(ABC):
6
+ @abstractmethod
7
+ async def chat_completion(
8
+ self,
9
+ request_data: Dict[str, Any]
10
+ ) -> Union[StreamingResponse, JSONResponse]:
11
+ pass
12
+
13
+ @abstractmethod
14
+ async def get_models(self) -> JSONResponse:
15
+ pass
app/providers/notion_provider.py ADDED
@@ -0,0 +1,394 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app/providers/notion_provider.py
2
+ import json
3
+ import time
4
+ import logging
5
+ import uuid
6
+ import re
7
+ import cloudscraper
8
+ from typing import Dict, Any, AsyncGenerator, List, Optional, Tuple
9
+ from datetime import datetime
10
+
11
+ from fastapi import HTTPException
12
+ from fastapi.responses import StreamingResponse, JSONResponse
13
+ from fastapi.concurrency import run_in_threadpool
14
+
15
+ from app.core.config import settings
16
+ from app.providers.base_provider import BaseProvider
17
+ from app.utils.sse_utils import create_sse_data, create_chat_completion_chunk, DONE_CHUNK
18
+
19
+ # 设置日志记录器
20
+ logger = logging.getLogger(__name__)
21
+
22
+ class NotionAIProvider(BaseProvider):
23
+ def __init__(self):
24
+ self.scraper = cloudscraper.create_scraper()
25
+ self.api_endpoints = {
26
+ "runInference": "https://www.notion.so/api/v3/runInferenceTranscript",
27
+ "saveTransactions": "https://www.notion.so/api/v3/saveTransactionsFanout"
28
+ }
29
+
30
+ if not all([settings.NOTION_COOKIE, settings.NOTION_SPACE_ID, settings.NOTION_USER_ID]):
31
+ raise ValueError("配置错误: NOTION_COOKIE, NOTION_SPACE_ID 和 NOTION_USER_ID 必须在 .env 文件中全部设置。")
32
+
33
+ self._warmup_session()
34
+
35
+ def _warmup_session(self):
36
+ try:
37
+ logger.info("正在进行会话预热 (Session Warm-up)...")
38
+ headers = self._prepare_headers()
39
+ headers.pop("Accept", None)
40
+ response = self.scraper.get("https://www.notion.so/", headers=headers, timeout=30)
41
+ response.raise_for_status()
42
+ logger.info("会话预热成功。")
43
+ except Exception as e:
44
+ logger.error(f"会话预热失败: {e}", exc_info=True)
45
+
46
+ async def _create_thread(self, thread_type: str) -> str:
47
+ thread_id = str(uuid.uuid4())
48
+ payload = {
49
+ "requestId": str(uuid.uuid4()),
50
+ "transactions": [{
51
+ "id": str(uuid.uuid4()),
52
+ "spaceId": settings.NOTION_SPACE_ID,
53
+ "operations": [{
54
+ "pointer": {"table": "thread", "id": thread_id, "spaceId": settings.NOTION_SPACE_ID},
55
+ "path": [],
56
+ "command": "set",
57
+ "args": {
58
+ "id": thread_id, "version": 1, "parent_id": settings.NOTION_SPACE_ID,
59
+ "parent_table": "space", "space_id": settings.NOTION_SPACE_ID,
60
+ "created_time": int(time.time() * 1000),
61
+ "created_by_id": settings.NOTION_USER_ID, "created_by_table": "notion_user",
62
+ "messages": [], "data": {}, "alive": True, "type": thread_type
63
+ }
64
+ }]
65
+ }]
66
+ }
67
+ try:
68
+ logger.info(f"正在创建新的对话线程 (type: {thread_type})...")
69
+ response = await run_in_threadpool(
70
+ lambda: self.scraper.post(
71
+ self.api_endpoints["saveTransactions"],
72
+ headers=self._prepare_headers(),
73
+ json=payload,
74
+ timeout=20
75
+ )
76
+ )
77
+ response.raise_for_status()
78
+ logger.info(f"对话线程创建成功, Thread ID: {thread_id}")
79
+ return thread_id
80
+ except Exception as e:
81
+ logger.error(f"创建对话线程失败: {e}", exc_info=True)
82
+ raise Exception("无法创建新的对话线程。")
83
+
84
+ async def chat_completion(self, request_data: Dict[str, Any]):
85
+ stream = request_data.get("stream", True)
86
+
87
+ async def stream_generator() -> AsyncGenerator[bytes, None]:
88
+ request_id = f"chatcmpl-{uuid.uuid4()}"
89
+ incremental_fragments: List[str] = []
90
+ final_message: Optional[str] = None
91
+
92
+ try:
93
+ model_name = request_data.get("model", settings.DEFAULT_MODEL)
94
+ mapped_model = settings.MODEL_MAP.get(model_name, "anthropic-sonnet-alt")
95
+
96
+ thread_type = "markdown-chat" if mapped_model.startswith("vertex-") else "workflow"
97
+
98
+ thread_id = await self._create_thread(thread_type)
99
+ payload = self._prepare_payload(request_data, thread_id, mapped_model, thread_type)
100
+ headers = self._prepare_headers()
101
+
102
+ role_chunk = create_chat_completion_chunk(request_id, model_name, role="assistant")
103
+ yield create_sse_data(role_chunk)
104
+
105
+ def sync_stream_iterator():
106
+ try:
107
+ logger.info(f"请求 Notion AI URL: {self.api_endpoints['runInference']}")
108
+ logger.info(f"请求体: {json.dumps(payload, indent=2, ensure_ascii=False)}")
109
+
110
+ response = self.scraper.post(
111
+ self.api_endpoints['runInference'], headers=headers, json=payload, stream=True,
112
+ timeout=settings.API_REQUEST_TIMEOUT
113
+ )
114
+ response.raise_for_status()
115
+ for line in response.iter_lines():
116
+ if line:
117
+ yield line
118
+ except Exception as e:
119
+ yield e
120
+
121
+ sync_gen = sync_stream_iterator()
122
+
123
+ while True:
124
+ line = await run_in_threadpool(lambda: next(sync_gen, None))
125
+ if line is None:
126
+ break
127
+ if isinstance(line, Exception):
128
+ raise line
129
+
130
+ parsed_results = self._parse_ndjson_line_to_texts(line)
131
+ for text_type, content in parsed_results:
132
+ if text_type == 'final':
133
+ final_message = content
134
+ elif text_type == 'incremental':
135
+ incremental_fragments.append(content)
136
+
137
+ full_response = ""
138
+ if final_message:
139
+ full_response = final_message
140
+ logger.info(f"成功从 record-map 或 Gemini patch/event 中提取到最终消息。")
141
+ else:
142
+ full_response = "".join(incremental_fragments)
143
+ logger.info(f"使用拼接所有增量片段的方式获得最终消息。")
144
+
145
+ if full_response:
146
+ cleaned_response = self._clean_content(full_response)
147
+ logger.info(f"清洗后的最终响应: {cleaned_response}")
148
+ chunk = create_chat_completion_chunk(request_id, model_name, content=cleaned_response)
149
+ yield create_sse_data(chunk)
150
+ else:
151
+ logger.warning("警告: Notion 返回的数据流中未提取到任何有效文本。请检查您的 .env 配置是否全部正确且凭证有效。")
152
+
153
+ final_chunk = create_chat_completion_chunk(request_id, model_name, finish_reason="stop")
154
+ yield create_sse_data(final_chunk)
155
+ yield DONE_CHUNK
156
+
157
+ except Exception as e:
158
+ error_message = f"处理 Notion AI 流时发生意外错误: {str(e)}"
159
+ logger.error(error_message, exc_info=True)
160
+ error_chunk = {"error": {"message": error_message, "type": "internal_server_error"}}
161
+ yield create_sse_data(error_chunk)
162
+ yield DONE_CHUNK
163
+
164
+ if stream:
165
+ return StreamingResponse(stream_generator(), media_type="text/event-stream")
166
+ else:
167
+ raise HTTPException(status_code=400, detail="此端点当前仅支持流式响应 (stream=true)。")
168
+
169
+ def _prepare_headers(self) -> Dict[str, str]:
170
+ cookie_source = (settings.NOTION_COOKIE or "").strip()
171
+ cookie_header = cookie_source if "=" in cookie_source else f"token_v2={cookie_source}"
172
+
173
+ return {
174
+ "Content-Type": "application/json",
175
+ "Accept": "application/x-ndjson",
176
+ "Cookie": cookie_header,
177
+ "x-notion-space-id": settings.NOTION_SPACE_ID,
178
+ "x-notion-active-user-header": settings.NOTION_USER_ID,
179
+ "x-notion-client-version": settings.NOTION_CLIENT_VERSION,
180
+ "notion-audit-log-platform": "web",
181
+ "Origin": "https://www.notion.so",
182
+ "Referer": "https://www.notion.so/",
183
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
184
+ }
185
+
186
+ def _normalize_block_id(self, block_id: str) -> str:
187
+ if not block_id: return block_id
188
+ b = block_id.replace("-", "").strip()
189
+ if len(b) == 32 and re.fullmatch(r"[0-9a-fA-F]{32}", b):
190
+ return f"{b[0:8]}-{b[8:12]}-{b[12:16]}-{b[16:20]}-{b[20:]}"
191
+ return block_id
192
+
193
+ def _prepare_payload(self, request_data: Dict[str, Any], thread_id: str, mapped_model: str, thread_type: str) -> Dict[str, Any]:
194
+ req_block_id = request_data.get("notion_block_id") or settings.NOTION_BLOCK_ID
195
+ normalized_block_id = self._normalize_block_id(req_block_id) if req_block_id else None
196
+
197
+ context_value: Dict[str, Any] = {
198
+ "timezone": "Asia/Shanghai",
199
+ "spaceId": settings.NOTION_SPACE_ID,
200
+ "userId": settings.NOTION_USER_ID,
201
+ "userEmail": settings.NOTION_USER_EMAIL,
202
+ "currentDatetime": datetime.now().astimezone().isoformat(),
203
+ }
204
+ if normalized_block_id:
205
+ context_value["blockId"] = normalized_block_id
206
+
207
+ config_value: Dict[str, Any]
208
+
209
+ if mapped_model.startswith("vertex-"):
210
+ logger.info(f"检测到 Gemini 模型 ({mapped_model}),应用特定的 config 和 context。")
211
+ context_value.update({
212
+ "userName": f" {settings.NOTION_USER_NAME}",
213
+ "spaceName": f"{settings.NOTION_USER_NAME}的 Notion",
214
+ "spaceViewId": "2008eefa-d0dc-80d5-9e67-000623befd8f",
215
+ "surface": "ai_module"
216
+ })
217
+ config_value = {
218
+ "type": thread_type,
219
+ "model": mapped_model,
220
+ "useWebSearch": True,
221
+ "enableAgentAutomations": False, "enableAgentIntegrations": False,
222
+ "enableBackgroundAgents": False, "enableCodegenIntegration": False,
223
+ "enableCustomAgents": False, "enableExperimentalIntegrations": False,
224
+ "enableLinkedDatabases": False, "enableAgentViewVersionHistoryTool": False,
225
+ "searchScopes": [{"type": "everything"}], "enableDatabaseAgents": False,
226
+ "enableAgentComments": False, "enableAgentForms": False,
227
+ "enableAgentMakesFormulas": False, "enableUserSessionContext": False,
228
+ "modelFromUser": True, "isCustomAgent": False
229
+ }
230
+ else:
231
+ context_value.update({
232
+ "userName": settings.NOTION_USER_NAME,
233
+ "surface": "workflows"
234
+ })
235
+ config_value = {
236
+ "type": thread_type,
237
+ "model": mapped_model,
238
+ "useWebSearch": True,
239
+ }
240
+
241
+ transcript = [
242
+ {"id": str(uuid.uuid4()), "type": "config", "value": config_value},
243
+ {"id": str(uuid.uuid4()), "type": "context", "value": context_value}
244
+ ]
245
+
246
+ for msg in request_data.get("messages", []):
247
+ if msg.get("role") == "user":
248
+ transcript.append({
249
+ "id": str(uuid.uuid4()),
250
+ "type": "user",
251
+ "value": [[msg.get("content")]],
252
+ "userId": settings.NOTION_USER_ID,
253
+ "createdAt": datetime.now().astimezone().isoformat()
254
+ })
255
+ elif msg.get("role") == "assistant":
256
+ transcript.append({"id": str(uuid.uuid4()), "type": "agent-inference", "value": [{"type": "text", "content": msg.get("content")}]})
257
+
258
+ payload = {
259
+ "traceId": str(uuid.uuid4()),
260
+ "spaceId": settings.NOTION_SPACE_ID,
261
+ "transcript": transcript,
262
+ "threadId": thread_id,
263
+ "createThread": False,
264
+ "isPartialTranscript": True,
265
+ "asPatchResponse": True,
266
+ "generateTitle": True,
267
+ "saveAllThreadOperations": True,
268
+ "threadType": thread_type
269
+ }
270
+
271
+ if mapped_model.startswith("vertex-"):
272
+ logger.info("为 Gemini 请求添加 debugOverrides。")
273
+ payload["debugOverrides"] = {
274
+ "emitAgentSearchExtractedResults": True,
275
+ "cachedInferences": {},
276
+ "annotationInferences": {},
277
+ "emitInferences": False
278
+ }
279
+
280
+ return payload
281
+
282
+ def _clean_content(self, content: str) -> str:
283
+ if not content:
284
+ return ""
285
+
286
+ content = re.sub(r'<lang primary="[^"]*"\s*/>\n*', '', content)
287
+ content = re.sub(r'<thinking>[\s\S]*?</thinking>\s*', '', content, flags=re.IGNORECASE)
288
+ content = re.sub(r'<thought>[\s\S]*?</thought>\s*', '', content, flags=re.IGNORECASE)
289
+
290
+ content = re.sub(r'^.*?Chinese whatmodel I am.*?Theyspecifically.*?requested.*?me.*?to.*?reply.*?in.*?Chinese\.\s*', '', content, flags=re.IGNORECASE | re.DOTALL)
291
+ content = re.sub(r'^.*?This.*?is.*?a.*?straightforward.*?question.*?about.*?my.*?identity.*?asan.*?AI.*?assistant\.\s*', '', content, flags=re.IGNORECASE | re.DOTALL)
292
+ content = re.sub(r'^.*?Idon\'t.*?need.*?to.*?use.*?any.*?tools.*?for.*?this.*?-\s*it\'s.*?asimple.*?informational.*?response.*?aboutwhat.*?I.*?am\.\s*', '', content, flags=re.IGNORECASE | re.DOTALL)
293
+ content = re.sub(r'^.*?Sincethe.*?user.*?asked.*?in.*?Chinese.*?and.*?specifically.*?requested.*?a.*?Chinese.*?response.*?I.*?should.*?respond.*?in.*?Chinese\.\s*', '', content, flags=re.IGNORECASE | re.DOTALL)
294
+ content = re.sub(r'^.*?What model are you.*?in Chinese and specifically requesting.*?me.*?to.*?reply.*?in.*?Chinese\.\s*', '', content, flags=re.IGNORECASE | re.DOTALL)
295
+ content = re.sub(r'^.*?This.*?is.*?a.*?question.*?about.*?my.*?identity.*?not requiring.*?any.*?tool.*?use.*?I.*?should.*?respond.*?directly.*?to.*?the.*?user.*?in.*?Chinese.*?as.*?requested\.\s*', '', content, flags=re.IGNORECASE | re.DOTALL)
296
+ content = re.sub(r'^.*?I.*?should.*?identify.*?myself.*?as.*?Notion.*?AI.*?as.*?mentioned.*?in.*?the.*?system.*?prompt.*?\s*', '', content, flags=re.IGNORECASE | re.DOTALL)
297
+ content = re.sub(r'^.*?I.*?should.*?not.*?make.*?specific.*?claims.*?about.*?the.*?underlying.*?model.*?architecture.*?since.*?that.*?information.*?is.*?not.*?provided.*?in.*?my.*?context\.\s*', '', content, flags=re.IGNORECASE | re.DOTALL)
298
+
299
+ return content.strip()
300
+
301
+ def _parse_ndjson_line_to_texts(self, line: bytes) -> List[Tuple[str, str]]:
302
+ results: List[Tuple[str, str]] = []
303
+ try:
304
+ s = line.decode("utf-8", errors="ignore").strip()
305
+ if not s: return results
306
+
307
+ data = json.loads(s)
308
+ logger.debug(f"原始响应数据: {json.dumps(data, ensure_ascii=False)}")
309
+
310
+ # 格式1: Gemini 返回的 markdown-chat 事件
311
+ if data.get("type") == "markdown-chat":
312
+ content = data.get("value", "")
313
+ if content:
314
+ logger.info("从 'markdown-chat' 直接事件中提取到内容。")
315
+ results.append(('final', content))
316
+
317
+ # 格式2: Claude 和 GPT 返回的补丁流,以及 Gemini 的 patch 格式
318
+ elif data.get("type") == "patch" and "v" in data:
319
+ for operation in data.get("v", []):
320
+ if not isinstance(operation, dict): continue
321
+
322
+ op_type = operation.get("o")
323
+ path = operation.get("p", "")
324
+ value = operation.get("v")
325
+
326
+ # 【修改】Gemini 的完整内容 patch 格式
327
+ if op_type == "a" and path.endswith("/s/-") and isinstance(value, dict) and value.get("type") == "markdown-chat":
328
+ content = value.get("value", "")
329
+ if content:
330
+ logger.info("从 'patch' (Gemini-style) 中提取到完整内容。")
331
+ results.append(('final', content))
332
+
333
+ # 【修改】Gemini 的增量内容 patch 格式
334
+ elif op_type == "x" and "/s/" in path and path.endswith("/value") and isinstance(value, str):
335
+ content = value
336
+ if content:
337
+ logger.info(f"从 'patch' (Gemini增量) 中提取到内容: {content}")
338
+ results.append(('incremental', content))
339
+
340
+ # 【修改】Claude 和 GPT 的增量内容 patch 格式
341
+ elif op_type == "x" and "/value/" in path and isinstance(value, str):
342
+ content = value
343
+ if content:
344
+ logger.info(f"从 'patch' (Claude/GPT增量) 中提取到内容: {content}")
345
+ results.append(('incremental', content))
346
+
347
+ # 【修改】Claude 和 GPT 的完整内容 patch 格式
348
+ elif op_type == "a" and path.endswith("/value/-") and isinstance(value, dict) and value.get("type") == "text":
349
+ content = value.get("content", "")
350
+ if content:
351
+ logger.info("从 'patch' (Claude/GPT-style) 中提取到完整内容。")
352
+ results.append(('final', content))
353
+
354
+ # 格式3: 处理record-map类型的数据
355
+ elif data.get("type") == "record-map" and "recordMap" in data:
356
+ record_map = data["recordMap"]
357
+ if "thread_message" in record_map:
358
+ for msg_id, msg_data in record_map["thread_message"].items():
359
+ value_data = msg_data.get("value", {}).get("value", {})
360
+ step = value_data.get("step", {})
361
+ if not step: continue
362
+
363
+ content = ""
364
+ step_type = step.get("type")
365
+
366
+ if step_type == "markdown-chat":
367
+ content = step.get("value", "")
368
+ elif step_type == "agent-inference":
369
+ agent_values = step.get("value", [])
370
+ if isinstance(agent_values, list):
371
+ for item in agent_values:
372
+ if isinstance(item, dict) and item.get("type") == "text":
373
+ content = item.get("content", "")
374
+ break
375
+
376
+ if content and isinstance(content, str):
377
+ logger.info(f"从 record-map (type: {step_type}) 提取到最终内容。")
378
+ results.append(('final', content))
379
+ break
380
+
381
+ except (json.JSONDecodeError, AttributeError) as e:
382
+ logger.warning(f"解析NDJSON行失败: {e} - Line: {line.decode('utf-8', errors='ignore')}")
383
+
384
+ return results
385
+
386
+ async def get_models(self) -> JSONResponse:
387
+ model_data = {
388
+ "object": "list",
389
+ "data": [
390
+ {"id": name, "object": "model", "created": int(time.time()), "owned_by": "lzA6"}
391
+ for name in settings.KNOWN_MODELS
392
+ ]
393
+ }
394
+ return JSONResponse(content=model_data)
app/utils/sse_utils.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app/utils/sse_utils.py
2
+ import json
3
+ import time
4
+ from typing import Dict, Any, Optional
5
+
6
+ DONE_CHUNK = b"data: [DONE]\n\n"
7
+
8
+ def create_sse_data(data: Dict[str, Any]) -> bytes:
9
+ return f"data: {json.dumps(data)}\n\n".encode('utf-8')
10
+
11
+ def create_chat_completion_chunk(
12
+ request_id: str,
13
+ model: str,
14
+ content: Optional[str] = None,
15
+ finish_reason: Optional[str] = None,
16
+ role: Optional[str] = None
17
+ ) -> Dict[str, Any]:
18
+ delta: Dict[str, Any] = {}
19
+ if role is not None:
20
+ delta["role"] = role
21
+ if content is not None:
22
+ delta["content"] = content
23
+
24
+ return {
25
+ "id": request_id,
26
+ "object": "chat.completion.chunk",
27
+ "created": int(time.time()),
28
+ "model": model,
29
+ "choices": [
30
+ {
31
+ "index": 0,
32
+ "delta": delta,
33
+ "finish_reason": finish_reason
34
+ }
35
+ ]
36
+ }
docker-compose.yml ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # docker-compose.yml
2
+ services:
3
+ nginx:
4
+ image: nginx:latest
5
+ container_name: notion-2api-nginx
6
+ restart: always
7
+ ports:
8
+ - "${NGINX_PORT:-8088}:80"
9
+ volumes:
10
+ - ./nginx.conf:/etc/nginx/nginx.conf:ro
11
+ depends_on:
12
+ - app
13
+ networks:
14
+ - notion-net
15
+
16
+ app:
17
+ build:
18
+ context: .
19
+ dockerfile: Dockerfile
20
+ container_name: notion-2api-app
21
+ restart: unless-stopped
22
+ env_file:
23
+ - .env
24
+ networks:
25
+ - notion-net
26
+
27
+ networks:
28
+ notion-net:
29
+ driver: bridge
main.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # main.py
2
+ import logging
3
+ from contextlib import asynccontextmanager
4
+ from typing import Optional
5
+
6
+ from fastapi import FastAPI, Request, HTTPException, Depends, Header
7
+ from fastapi.responses import JSONResponse, StreamingResponse
8
+
9
+ from app.core.config import settings
10
+ from app.providers.notion_provider import NotionAIProvider
11
+
12
+ logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
13
+ logger = logging.getLogger(__name__)
14
+
15
+ provider = NotionAIProvider()
16
+
17
+ @asynccontextmanager
18
+ async def lifespan(app: FastAPI):
19
+ logger.info(f"应用启动中... {settings.APP_NAME} v{settings.APP_VERSION}")
20
+ logger.info("服务已配置为 Notion AI 代理模式。")
21
+ logger.info(f"服务将在 http://localhost:{settings.NGINX_PORT} 上可用")
22
+ yield
23
+ logger.info("应用关闭。")
24
+
25
+ app = FastAPI(
26
+ title=settings.APP_NAME,
27
+ version=settings.APP_VERSION,
28
+ description=settings.DESCRIPTION,
29
+ lifespan=lifespan
30
+ )
31
+
32
+ async def verify_api_key(authorization: Optional[str] = Header(None)):
33
+ if settings.API_MASTER_KEY and settings.API_MASTER_KEY != "1":
34
+ if not authorization or "bearer" not in authorization.lower():
35
+ raise HTTPException(status_code=401, detail="需要 Bearer Token 认证。")
36
+ token = authorization.split(" ")[-1]
37
+ if token != settings.API_MASTER_KEY:
38
+ raise HTTPException(status_code=403, detail="无效的 API Key。")
39
+
40
+ @app.post("/v1/chat/completions", dependencies=[Depends(verify_api_key)])
41
+ async def chat_completions(request: Request) -> StreamingResponse:
42
+ try:
43
+ request_data = await request.json()
44
+ return await provider.chat_completion(request_data)
45
+ except Exception as e:
46
+ logger.error(f"处理聊天请求时发生顶层错误: {e}", exc_info=True)
47
+ raise HTTPException(status_code=500, detail=f"内部服务器错误: {str(e)}")
48
+
49
+ @app.get("/v1/models", dependencies=[Depends(verify_api_key)], response_class=JSONResponse)
50
+ async def list_models():
51
+ return await provider.get_models()
52
+
53
+ @app.get("/", summary="根路径")
54
+ def root():
55
+ return {"message": f"欢迎来到 {settings.APP_NAME} v{settings.APP_VERSION}. 服务运行正常。"}
nginx.conf ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ worker_processes auto;
2
+
3
+ events {
4
+ worker_connections 1024;
5
+ }
6
+
7
+ http {
8
+ upstream notion_backend {
9
+ server app:8000;
10
+ }
11
+
12
+ server {
13
+ listen 80;
14
+ server_name localhost;
15
+
16
+ location / {
17
+ proxy_pass http://notion_backend;
18
+ proxy_set_header Host $host;
19
+ proxy_set_header X-Real-IP $remote_addr;
20
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
21
+ proxy_set_header X-Forwarded-Proto $scheme;
22
+
23
+ # 【核心修正】增加代理超时时间,以应对Cloudflare挑战
24
+ proxy_connect_timeout 600s;
25
+ proxy_send_timeout 600s;
26
+ proxy_read_timeout 600s;
27
+ send_timeout 600s;
28
+
29
+ # 流式传输优化
30
+ proxy_buffering off;
31
+ proxy_cache off;
32
+ proxy_set_header Connection '';
33
+ proxy_http_version 1.1;
34
+ chunked_transfer_encoding off;
35
+ }
36
+ }
37
+ }
requirements.txt ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ # requirements.txt
2
+ fastapi
3
+ uvicorn[standard]
4
+ httpx
5
+ pydantic-settings
6
+ python-dotenv
7
+ cloudscraper
项目完整结构代码.txt ADDED
@@ -0,0 +1,786 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 项目 'notion-2api' 的结构树:
2
+ 📂 notion-2api/
3
+ 📄 .env
4
+ 📄 .env.example
5
+ 📄 Dockerfile
6
+ 📄 docker-compose.yml
7
+ 📄 main.py
8
+ 📄 nginx.conf
9
+ 📄 requirements.txt
10
+ 📂 app/
11
+ 📂 core/
12
+ 📄 __init__.py
13
+ 📄 config.py
14
+ 📂 providers/
15
+ 📄 __init__.py
16
+ 📄 base_provider.py
17
+ 📄 notion_provider.py
18
+ 📂 utils/
19
+ 📄 sse_utils.py
20
+ ================================================================================
21
+
22
+ --- 文件路径: .env ---
23
+
24
+ # [自动填充] notion-2api 生产环境配置
25
+
26
+ # --- 安全配置 ---
27
+ API_MASTER_KEY=1
28
+
29
+ # --- 端口配置 ---
30
+ NGINX_PORT=8088
31
+
32
+ # --- Notion 凭证 (以下均为必须或强烈建议设置) ---
33
+
34
+ # 1. 您的 token_v2 (已从最新日志中提取并更新)
35
+ NOTION_COOKIE="v03%3AeyJhbGciOiJkaXIiLCJraWQiOiJwcm9kdWN0aW9uOnRva2VuLXYzOjIwMjQtMTEtMDciLCJlbmMiOiJBMjU2Q0JDLUhTNTEyIn0..mIRgS9AYZx8rn6OUJ7F9pA.QFex5O4ZzVCLG1JCNOgbbqmYf9IyntouodTfm2wn7LbmY0Zs-akV51n3dwtaC2K3ctm9Jj91PVRsl-6k9phiTUaIO_3FtSYmEYZrmCYEXa1iWJAwdROmySSRcMeSwsswgakVanb-sal9B8IH-YACTq9SLfooARLw65pwljahMdG-jJKi5X2PwfUrENeeRGDTQF0I6SLxp0-VxzOuWn-MDPej-S40hbDQY9kDyDZ9tyaYptOsu3KEP1M6HiwD0kqqQETUdYFPbYqK8ItPdKDyrFr8zIo21zfMAwLMeSvvTda-cBm0OVnBuGvqlLA92dVYON55mts-r_U2Xmjt9g9pAwL_GG8-HW9Qo-IyiaO9oB4.D17Jn2Mp6Y62_lbuZ0Ggz0ugnej-Ue7coltqqYHI-KE"
36
+
37
+ # 2. 您的 Space ID (保持不变)
38
+ NOTION_SPACE_ID="f108eefa-d0dc-8181-8382-0003e15d764e"
39
+
40
+ # 3. 您的用户 ID (从日志中提取)
41
+ NOTION_USER_ID="200d872b-594c-8153-b674-00028d202a8b"
42
+
43
+ # 4. 您的 Notion 用户名 (请确认是否准确)
44
+ NOTION_USER_NAME="利仔"
45
+
46
+ # 5. 您的 Notion 登录邮箱 (请替换为您的真实邮箱)
47
+ NOTION_USER_EMAIL="q13645947407@gmail.com"
48
+
49
+ # 6. 可选:页面 Block ID (保持留空以提高兼容性)
50
+ NOTION_BLOCK_ID=""
51
+
52
+ # 7. 可选:客户端版本 (保持默认即可)
53
+ NOTION_CLIENT_VERSION="23.13.20251011.2037"
54
+
55
+ --- 文件路径: .env.example ---
56
+
57
+ # ====================================================================
58
+ # notion-2api 配置文件模板 (最终版)
59
+ # ====================================================================
60
+ #
61
+ # 请将此文件重命名为 ".env" 并填入您的凭证。
62
+ #
63
+
64
+ # --- 核心安全配置 (可选) ---
65
+ API_MASTER_KEY=your_secret_key_here
66
+
67
+ # --- 部署配置 (可选) ---
68
+ NGINX_PORT=8088
69
+
70
+ # --- Notion 凭证 (以下均为必须或强烈建议设置) ---
71
+ # 1) 粘贴 token_v2 的值 或 完整 Cookie
72
+ NOTION_COOKIE="在此处粘贴 token_v2 值 或 完整 Cookie"
73
+
74
+ # 2) 您的 Space ID
75
+ NOTION_SPACE_ID="在此处粘贴您的 Space ID"
76
+
77
+ # 3) 您的用户 ID (浏览器开发者工具中 x-notion-active-user-header 的值)
78
+ NOTION_USER_ID="在此处粘贴您的 Notion 用户 ID"
79
+
80
+ # 4) 您的 Notion 用户名 (显示在左上角的名称)
81
+ NOTION_USER_NAME="利仔"
82
+
83
+ # 5) 您的 Notion 登录邮箱
84
+ NOTION_USER_EMAIL="q13645947407@gmail.com"
85
+
86
+ # 可选:想绑定的页面 blockId。留空则不绑定特定页面上下文。
87
+ NOTION_BLOCK_ID=""
88
+
89
+ # 可选:浏览器中看到的客户端版本
90
+ NOTION_CLIENT_VERSION="23.13.20251011.2037"
91
+
92
+ --- 文件路径: Dockerfile ---
93
+
94
+ # ====================================================================
95
+ # Dockerfile for inception-2api (v4.0 - Cloudscraper Edition)
96
+ # ====================================================================
97
+
98
+ FROM python:3.10-slim
99
+
100
+ # 设置环境变量
101
+ ENV PYTHONDONTWRITEBYTECODE=1
102
+ ENV PYTHONUNBUFFERED=1
103
+ WORKDIR /app
104
+
105
+ # 安装 Python 依赖
106
+ COPY requirements.txt .
107
+ RUN pip install --no-cache-dir --upgrade pip && \
108
+ pip install --no-cache-dir -r requirements.txt
109
+
110
+ # 复制应用代码
111
+ COPY . .
112
+
113
+ # 创建并切换到非 root 用户
114
+ RUN useradd --create-home appuser && \
115
+ chown -R appuser:appuser /app
116
+ USER appuser
117
+
118
+ # 暴露端口并启动
119
+ EXPOSE 8000
120
+ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4"]
121
+
122
+
123
+ --- 文件路径: docker-compose.yml ---
124
+
125
+ # docker-compose.yml
126
+ services:
127
+ nginx:
128
+ image: nginx:latest
129
+ container_name: notion-2api-nginx
130
+ restart: always
131
+ ports:
132
+ - "${NGINX_PORT:-8088}:80"
133
+ volumes:
134
+ - ./nginx.conf:/etc/nginx/nginx.conf:ro
135
+ depends_on:
136
+ - app
137
+ networks:
138
+ - notion-net
139
+
140
+ app:
141
+ build:
142
+ context: .
143
+ dockerfile: Dockerfile
144
+ container_name: notion-2api-app
145
+ restart: unless-stopped
146
+ env_file:
147
+ - .env
148
+ networks:
149
+ - notion-net
150
+
151
+ networks:
152
+ notion-net:
153
+ driver: bridge
154
+
155
+
156
+ --- 文件路径: main.py ---
157
+
158
+ # main.py
159
+ import logging
160
+ from contextlib import asynccontextmanager
161
+ from typing import Optional
162
+
163
+ from fastapi import FastAPI, Request, HTTPException, Depends, Header
164
+ from fastapi.responses import JSONResponse, StreamingResponse
165
+
166
+ from app.core.config import settings
167
+ from app.providers.notion_provider import NotionAIProvider
168
+
169
+ logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
170
+ logger = logging.getLogger(__name__)
171
+
172
+ provider = NotionAIProvider()
173
+
174
+ @asynccontextmanager
175
+ async def lifespan(app: FastAPI):
176
+ logger.info(f"应用启动中... {settings.APP_NAME} v{settings.APP_VERSION}")
177
+ logger.info("服务已配置为 Notion AI 代理模式。")
178
+ logger.info(f"服务将在 http://localhost:{settings.NGINX_PORT} 上可用")
179
+ yield
180
+ logger.info("应用关闭。")
181
+
182
+ app = FastAPI(
183
+ title=settings.APP_NAME,
184
+ version=settings.APP_VERSION,
185
+ description=settings.DESCRIPTION,
186
+ lifespan=lifespan
187
+ )
188
+
189
+ async def verify_api_key(authorization: Optional[str] = Header(None)):
190
+ if settings.API_MASTER_KEY and settings.API_MASTER_KEY != "1":
191
+ if not authorization or "bearer" not in authorization.lower():
192
+ raise HTTPException(status_code=401, detail="需要 Bearer Token 认证。")
193
+ token = authorization.split(" ")[-1]
194
+ if token != settings.API_MASTER_KEY:
195
+ raise HTTPException(status_code=403, detail="无效的 API Key。")
196
+
197
+ @app.post("/v1/chat/completions", dependencies=[Depends(verify_api_key)])
198
+ async def chat_completions(request: Request) -> StreamingResponse:
199
+ try:
200
+ request_data = await request.json()
201
+ return await provider.chat_completion(request_data)
202
+ except Exception as e:
203
+ logger.error(f"处理聊天请求时发生顶层错误: {e}", exc_info=True)
204
+ raise HTTPException(status_code=500, detail=f"内部服务器错误: {str(e)}")
205
+
206
+ @app.get("/v1/models", dependencies=[Depends(verify_api_key)], response_class=JSONResponse)
207
+ async def list_models():
208
+ return await provider.get_models()
209
+
210
+ @app.get("/", summary="根路径")
211
+ def root():
212
+ return {"message": f"欢迎来到 {settings.APP_NAME} v{settings.APP_VERSION}. 服务运行正常。"}
213
+
214
+
215
+ --- 文件路径: nginx.conf ---
216
+
217
+ worker_processes auto;
218
+
219
+ events {
220
+ worker_connections 1024;
221
+ }
222
+
223
+ http {
224
+ upstream notion_backend {
225
+ server app:8000;
226
+ }
227
+
228
+ server {
229
+ listen 80;
230
+ server_name localhost;
231
+
232
+ location / {
233
+ proxy_pass http://notion_backend;
234
+ proxy_set_header Host $host;
235
+ proxy_set_header X-Real-IP $remote_addr;
236
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
237
+ proxy_set_header X-Forwarded-Proto $scheme;
238
+
239
+ # 【核心修正】增加代理超时时间,以应对Cloudflare挑战
240
+ proxy_connect_timeout 600s;
241
+ proxy_send_timeout 600s;
242
+ proxy_read_timeout 600s;
243
+ send_timeout 600s;
244
+
245
+ # 流式传输优化
246
+ proxy_buffering off;
247
+ proxy_cache off;
248
+ proxy_set_header Connection '';
249
+ proxy_http_version 1.1;
250
+ chunked_transfer_encoding off;
251
+ }
252
+ }
253
+ }
254
+
255
+
256
+ --- 文件路径: requirements.txt ---
257
+
258
+ # requirements.txt
259
+ fastapi
260
+ uvicorn[standard]
261
+ httpx
262
+ pydantic-settings
263
+ python-dotenv
264
+ cloudscraper
265
+
266
+
267
+ --- 文件路径: app\core\__init__.py ---
268
+
269
+
270
+
271
+ --- 文件路径: app\core\config.py ---
272
+
273
+ # app/core/config.py
274
+ from pydantic_settings import BaseSettings, SettingsConfigDict
275
+ from typing import List, Optional
276
+
277
+ class Settings(BaseSettings):
278
+ model_config = SettingsConfigDict(
279
+ env_file=".env",
280
+ env_file_encoding='utf-8',
281
+ extra="ignore"
282
+ )
283
+
284
+ APP_NAME: str = "notion-2api"
285
+ APP_VERSION: str = "4.0.0" # 最终稳定版
286
+ DESCRIPTION: str = "一个将 Notion AI 转换为兼容 OpenAI 格式 API 的高性能代理。"
287
+
288
+ API_MASTER_KEY: Optional[str] = None
289
+
290
+ # --- Notion 凭证 ---
291
+ NOTION_COOKIE: Optional[str] = None
292
+ NOTION_SPACE_ID: Optional[str] = None
293
+ NOTION_USER_ID: Optional[str] = None
294
+ NOTION_USER_NAME: Optional[str] = None
295
+ NOTION_USER_EMAIL: Optional[str] = None
296
+ NOTION_BLOCK_ID: Optional[str] = None
297
+ NOTION_CLIENT_VERSION: Optional[str] = "23.13.20251011.2037"
298
+
299
+ API_REQUEST_TIMEOUT: int = 180
300
+ NGINX_PORT: int = 8088
301
+
302
+ # 【最终修正】更新所有已知的模型列表
303
+ DEFAULT_MODEL: str = "claude-sonnet-4.5"
304
+
305
+ KNOWN_MODELS: List[str] = [
306
+ "claude-sonnet-4.5",
307
+ "gpt-5",
308
+ "claude-opus-4.1",
309
+ "gemini-2.5-flash(未修复,不可用)",
310
+ "gemini-2.5-pro(未修复,不可用)",
311
+ "gpt-4.1"
312
+ ]
313
+
314
+ # 【最终修正】根据您提供的信息,填充所有模型的真实后台名称
315
+ MODEL_MAP: dict = {
316
+ "claude-sonnet-4.5": "anthropic-sonnet-alt",
317
+ "gpt-5": "openai-turbo",
318
+ "claude-opus-4.1": "anthropic-opus-4.1",
319
+ "gemini-2.5-flash(未修复,不可用)": "vertex-gemini-2.5-flash",
320
+ "gemini-2.5-pro(未修复,不可用)": "vertex-gemini-2.5-pro",
321
+ "gpt-4.1": "openai-gpt-4.1"
322
+ }
323
+
324
+ settings = Settings()
325
+
326
+ --- 文件路径: app\providers\__init__.py ---
327
+
328
+
329
+
330
+ --- 文件路径: app\providers\base_provider.py ---
331
+
332
+ from abc import ABC, abstractmethod
333
+ from typing import Dict, Any, Union
334
+ from fastapi.responses import StreamingResponse, JSONResponse
335
+
336
+ class BaseProvider(ABC):
337
+ @abstractmethod
338
+ async def chat_completion(
339
+ self,
340
+ request_data: Dict[str, Any]
341
+ ) -> Union[StreamingResponse, JSONResponse]:
342
+ pass
343
+
344
+ @abstractmethod
345
+ async def get_models(self) -> JSONResponse:
346
+ pass
347
+
348
+
349
+ --- 文件路径: app\providers\notion_provider.py ---
350
+
351
+ # app/providers/notion_provider.py
352
+ import json
353
+ import time
354
+ import logging
355
+ import uuid
356
+ import re
357
+ import cloudscraper
358
+ from typing import Dict, Any, AsyncGenerator, List, Optional, Tuple
359
+ from datetime import datetime
360
+
361
+ from fastapi import HTTPException
362
+ from fastapi.responses import StreamingResponse, JSONResponse
363
+ from fastapi.concurrency import run_in_threadpool
364
+
365
+ from app.core.config import settings
366
+ from app.providers.base_provider import BaseProvider
367
+ from app.utils.sse_utils import create_sse_data, create_chat_completion_chunk, DONE_CHUNK
368
+
369
+ # 设置日志记录器
370
+ logger = logging.getLogger(__name__)
371
+
372
+ class NotionAIProvider(BaseProvider):
373
+ def __init__(self):
374
+ self.scraper = cloudscraper.create_scraper()
375
+ self.api_endpoints = {
376
+ "runInference": "https://www.notion.so/api/v3/runInferenceTranscript",
377
+ "saveTransactions": "https://www.notion.so/api/v3/saveTransactionsFanout"
378
+ }
379
+
380
+ if not all([settings.NOTION_COOKIE, settings.NOTION_SPACE_ID, settings.NOTION_USER_ID]):
381
+ raise ValueError("配置错误: NOTION_COOKIE, NOTION_SPACE_ID 和 NOTION_USER_ID 必须在 .env 文件中全部设置。")
382
+
383
+ self._warmup_session()
384
+
385
+ def _warmup_session(self):
386
+ try:
387
+ logger.info("正在进行会话预热 (Session Warm-up)...")
388
+ headers = self._prepare_headers()
389
+ headers.pop("Accept", None)
390
+ response = self.scraper.get("https://www.notion.so/", headers=headers, timeout=30)
391
+ response.raise_for_status()
392
+ logger.info("会话预热成功。")
393
+ except Exception as e:
394
+ logger.error(f"会话预热失败: {e}", exc_info=True)
395
+
396
+ async def _create_thread(self, thread_type: str) -> str:
397
+ thread_id = str(uuid.uuid4())
398
+ payload = {
399
+ "requestId": str(uuid.uuid4()),
400
+ "transactions": [{
401
+ "id": str(uuid.uuid4()),
402
+ "spaceId": settings.NOTION_SPACE_ID,
403
+ "operations": [{
404
+ "pointer": {"table": "thread", "id": thread_id, "spaceId": settings.NOTION_SPACE_ID},
405
+ "path": [],
406
+ "command": "set",
407
+ "args": {
408
+ "id": thread_id, "version": 1, "parent_id": settings.NOTION_SPACE_ID,
409
+ "parent_table": "space", "space_id": settings.NOTION_SPACE_ID,
410
+ "created_time": int(time.time() * 1000),
411
+ "created_by_id": settings.NOTION_USER_ID, "created_by_table": "notion_user",
412
+ "messages": [], "data": {}, "alive": True, "type": thread_type
413
+ }
414
+ }]
415
+ }]
416
+ }
417
+ try:
418
+ logger.info(f"正在创建新的对话线程 (type: {thread_type})...")
419
+ response = await run_in_threadpool(
420
+ lambda: self.scraper.post(
421
+ self.api_endpoints["saveTransactions"],
422
+ headers=self._prepare_headers(),
423
+ json=payload,
424
+ timeout=20
425
+ )
426
+ )
427
+ response.raise_for_status()
428
+ logger.info(f"对话线程创建成功, Thread ID: {thread_id}")
429
+ return thread_id
430
+ except Exception as e:
431
+ logger.error(f"创建对话线程失败: {e}", exc_info=True)
432
+ raise Exception("无法创建新的对话线程。")
433
+
434
+ async def chat_completion(self, request_data: Dict[str, Any]):
435
+ stream = request_data.get("stream", True)
436
+
437
+ async def stream_generator() -> AsyncGenerator[bytes, None]:
438
+ request_id = f"chatcmpl-{uuid.uuid4()}"
439
+ incremental_fragments: List[str] = []
440
+ final_message: Optional[str] = None
441
+
442
+ try:
443
+ model_name = request_data.get("model", settings.DEFAULT_MODEL)
444
+ mapped_model = settings.MODEL_MAP.get(model_name, "anthropic-sonnet-alt")
445
+
446
+ thread_type = "markdown-chat" if mapped_model.startswith("vertex-") else "workflow"
447
+
448
+ thread_id = await self._create_thread(thread_type)
449
+ payload = self._prepare_payload(request_data, thread_id, mapped_model, thread_type)
450
+ headers = self._prepare_headers()
451
+
452
+ role_chunk = create_chat_completion_chunk(request_id, model_name, role="assistant")
453
+ yield create_sse_data(role_chunk)
454
+
455
+ def sync_stream_iterator():
456
+ try:
457
+ logger.info(f"请求 Notion AI URL: {self.api_endpoints['runInference']}")
458
+ logger.info(f"请求体: {json.dumps(payload, indent=2, ensure_ascii=False)}")
459
+
460
+ response = self.scraper.post(
461
+ self.api_endpoints['runInference'], headers=headers, json=payload, stream=True,
462
+ timeout=settings.API_REQUEST_TIMEOUT
463
+ )
464
+ response.raise_for_status()
465
+ for line in response.iter_lines():
466
+ if line:
467
+ yield line
468
+ except Exception as e:
469
+ yield e
470
+
471
+ sync_gen = sync_stream_iterator()
472
+
473
+ while True:
474
+ line = await run_in_threadpool(lambda: next(sync_gen, None))
475
+ if line is None:
476
+ break
477
+ if isinstance(line, Exception):
478
+ raise line
479
+
480
+ parsed_results = self._parse_ndjson_line_to_texts(line)
481
+ for text_type, content in parsed_results:
482
+ if text_type == 'final':
483
+ final_message = content
484
+ elif text_type == 'incremental':
485
+ incremental_fragments.append(content)
486
+
487
+ full_response = ""
488
+ if final_message:
489
+ full_response = final_message
490
+ logger.info(f"成功从 record-map 或 Gemini patch/event 中提取到最终消息。")
491
+ else:
492
+ full_response = "".join(incremental_fragments)
493
+ logger.info(f"使用拼接所有增量片段的方式获得最终消息。")
494
+
495
+ if full_response:
496
+ cleaned_response = self._clean_content(full_response)
497
+ logger.info(f"清洗后的最终响应: {cleaned_response}")
498
+ chunk = create_chat_completion_chunk(request_id, model_name, content=cleaned_response)
499
+ yield create_sse_data(chunk)
500
+ else:
501
+ logger.warning("警告: Notion 返回的数据流中未提取到任何有效文本。请检查您的 .env 配置是否全部正确且凭证有效。")
502
+
503
+ final_chunk = create_chat_completion_chunk(request_id, model_name, finish_reason="stop")
504
+ yield create_sse_data(final_chunk)
505
+ yield DONE_CHUNK
506
+
507
+ except Exception as e:
508
+ error_message = f"处理 Notion AI 流时发生意外错误: {str(e)}"
509
+ logger.error(error_message, exc_info=True)
510
+ error_chunk = {"error": {"message": error_message, "type": "internal_server_error"}}
511
+ yield create_sse_data(error_chunk)
512
+ yield DONE_CHUNK
513
+
514
+ if stream:
515
+ return StreamingResponse(stream_generator(), media_type="text/event-stream")
516
+ else:
517
+ raise HTTPException(status_code=400, detail="此端点当前仅支持流式响应 (stream=true)。")
518
+
519
+ def _prepare_headers(self) -> Dict[str, str]:
520
+ cookie_source = (settings.NOTION_COOKIE or "").strip()
521
+ cookie_header = cookie_source if "=" in cookie_source else f"token_v2={cookie_source}"
522
+
523
+ return {
524
+ "Content-Type": "application/json",
525
+ "Accept": "application/x-ndjson",
526
+ "Cookie": cookie_header,
527
+ "x-notion-space-id": settings.NOTION_SPACE_ID,
528
+ "x-notion-active-user-header": settings.NOTION_USER_ID,
529
+ "x-notion-client-version": settings.NOTION_CLIENT_VERSION,
530
+ "notion-audit-log-platform": "web",
531
+ "Origin": "https://www.notion.so",
532
+ "Referer": "https://www.notion.so/",
533
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
534
+ }
535
+
536
+ def _normalize_block_id(self, block_id: str) -> str:
537
+ if not block_id: return block_id
538
+ b = block_id.replace("-", "").strip()
539
+ if len(b) == 32 and re.fullmatch(r"[0-9a-fA-F]{32}", b):
540
+ return f"{b[0:8]}-{b[8:12]}-{b[12:16]}-{b[16:20]}-{b[20:]}"
541
+ return block_id
542
+
543
+ def _prepare_payload(self, request_data: Dict[str, Any], thread_id: str, mapped_model: str, thread_type: str) -> Dict[str, Any]:
544
+ req_block_id = request_data.get("notion_block_id") or settings.NOTION_BLOCK_ID
545
+ normalized_block_id = self._normalize_block_id(req_block_id) if req_block_id else None
546
+
547
+ context_value: Dict[str, Any] = {
548
+ "timezone": "Asia/Shanghai",
549
+ "spaceId": settings.NOTION_SPACE_ID,
550
+ "userId": settings.NOTION_USER_ID,
551
+ "userEmail": settings.NOTION_USER_EMAIL,
552
+ "currentDatetime": datetime.now().astimezone().isoformat(),
553
+ }
554
+ if normalized_block_id:
555
+ context_value["blockId"] = normalized_block_id
556
+
557
+ config_value: Dict[str, Any]
558
+
559
+ if mapped_model.startswith("vertex-"):
560
+ logger.info(f"检测到 Gemini 模型 ({mapped_model}),应用特定的 config 和 context。")
561
+ context_value.update({
562
+ "userName": f" {settings.NOTION_USER_NAME}",
563
+ "spaceName": f"{settings.NOTION_USER_NAME}的 Notion",
564
+ "spaceViewId": "2008eefa-d0dc-80d5-9e67-000623befd8f",
565
+ "surface": "ai_module"
566
+ })
567
+ config_value = {
568
+ "type": thread_type,
569
+ "model": mapped_model,
570
+ "useWebSearch": True,
571
+ "enableAgentAutomations": False, "enableAgentIntegrations": False,
572
+ "enableBackgroundAgents": False, "enableCodegenIntegration": False,
573
+ "enableCustomAgents": False, "enableExperimentalIntegrations": False,
574
+ "enableLinkedDatabases": False, "enableAgentViewVersionHistoryTool": False,
575
+ "searchScopes": [{"type": "everything"}], "enableDatabaseAgents": False,
576
+ "enableAgentComments": False, "enableAgentForms": False,
577
+ "enableAgentMakesFormulas": False, "enableUserSessionContext": False,
578
+ "modelFromUser": True, "isCustomAgent": False
579
+ }
580
+ else:
581
+ context_value.update({
582
+ "userName": settings.NOTION_USER_NAME,
583
+ "surface": "workflows"
584
+ })
585
+ config_value = {
586
+ "type": thread_type,
587
+ "model": mapped_model,
588
+ "useWebSearch": True,
589
+ }
590
+
591
+ transcript = [
592
+ {"id": str(uuid.uuid4()), "type": "config", "value": config_value},
593
+ {"id": str(uuid.uuid4()), "type": "context", "value": context_value}
594
+ ]
595
+
596
+ for msg in request_data.get("messages", []):
597
+ if msg.get("role") == "user":
598
+ transcript.append({
599
+ "id": str(uuid.uuid4()),
600
+ "type": "user",
601
+ "value": [[msg.get("content")]],
602
+ "userId": settings.NOTION_USER_ID,
603
+ "createdAt": datetime.now().astimezone().isoformat()
604
+ })
605
+ elif msg.get("role") == "assistant":
606
+ transcript.append({"id": str(uuid.uuid4()), "type": "agent-inference", "value": [{"type": "text", "content": msg.get("content")}]})
607
+
608
+ payload = {
609
+ "traceId": str(uuid.uuid4()),
610
+ "spaceId": settings.NOTION_SPACE_ID,
611
+ "transcript": transcript,
612
+ "threadId": thread_id,
613
+ "createThread": False,
614
+ "isPartialTranscript": True,
615
+ "asPatchResponse": True,
616
+ "generateTitle": True,
617
+ "saveAllThreadOperations": True,
618
+ "threadType": thread_type
619
+ }
620
+
621
+ if mapped_model.startswith("vertex-"):
622
+ logger.info("为 Gemini 请求添加 debugOverrides。")
623
+ payload["debugOverrides"] = {
624
+ "emitAgentSearchExtractedResults": True,
625
+ "cachedInferences": {},
626
+ "annotationInferences": {},
627
+ "emitInferences": False
628
+ }
629
+
630
+ return payload
631
+
632
+ def _clean_content(self, content: str) -> str:
633
+ if not content:
634
+ return ""
635
+
636
+ content = re.sub(r'<lang primary="[^"]*"\s*/>\n*', '', content)
637
+ content = re.sub(r'<thinking>[\s\S]*?</thinking>\s*', '', content, flags=re.IGNORECASE)
638
+ content = re.sub(r'<thought>[\s\S]*?</thought>\s*', '', content, flags=re.IGNORECASE)
639
+
640
+ content = re.sub(r'^.*?Chinese whatmodel I am.*?Theyspecifically.*?requested.*?me.*?to.*?reply.*?in.*?Chinese\.\s*', '', content, flags=re.IGNORECASE | re.DOTALL)
641
+ content = re.sub(r'^.*?This.*?is.*?a.*?straightforward.*?question.*?about.*?my.*?identity.*?asan.*?AI.*?assistant\.\s*', '', content, flags=re.IGNORECASE | re.DOTALL)
642
+ content = re.sub(r'^.*?Idon\'t.*?need.*?to.*?use.*?any.*?tools.*?for.*?this.*?-\s*it\'s.*?asimple.*?informational.*?response.*?aboutwhat.*?I.*?am\.\s*', '', content, flags=re.IGNORECASE | re.DOTALL)
643
+ content = re.sub(r'^.*?Sincethe.*?user.*?asked.*?in.*?Chinese.*?and.*?specifically.*?requested.*?a.*?Chinese.*?response.*?I.*?should.*?respond.*?in.*?Chinese\.\s*', '', content, flags=re.IGNORECASE | re.DOTALL)
644
+ content = re.sub(r'^.*?What model are you.*?in Chinese and specifically requesting.*?me.*?to.*?reply.*?in.*?Chinese\.\s*', '', content, flags=re.IGNORECASE | re.DOTALL)
645
+ content = re.sub(r'^.*?This.*?is.*?a.*?question.*?about.*?my.*?identity.*?not requiring.*?any.*?tool.*?use.*?I.*?should.*?respond.*?directly.*?to.*?the.*?user.*?in.*?Chinese.*?as.*?requested\.\s*', '', content, flags=re.IGNORECASE | re.DOTALL)
646
+ content = re.sub(r'^.*?I.*?should.*?identify.*?myself.*?as.*?Notion.*?AI.*?as.*?mentioned.*?in.*?the.*?system.*?prompt.*?\s*', '', content, flags=re.IGNORECASE | re.DOTALL)
647
+ content = re.sub(r'^.*?I.*?should.*?not.*?make.*?specific.*?claims.*?about.*?the.*?underlying.*?model.*?architecture.*?since.*?that.*?information.*?is.*?not.*?provided.*?in.*?my.*?context\.\s*', '', content, flags=re.IGNORECASE | re.DOTALL)
648
+
649
+ return content.strip()
650
+
651
+ def _parse_ndjson_line_to_texts(self, line: bytes) -> List[Tuple[str, str]]:
652
+ results: List[Tuple[str, str]] = []
653
+ try:
654
+ s = line.decode("utf-8", errors="ignore").strip()
655
+ if not s: return results
656
+
657
+ data = json.loads(s)
658
+ logger.debug(f"原始响应数据: {json.dumps(data, ensure_ascii=False)}")
659
+
660
+ # 格式1: Gemini 返回的 markdown-chat 事件
661
+ if data.get("type") == "markdown-chat":
662
+ content = data.get("value", "")
663
+ if content:
664
+ logger.info("从 'markdown-chat' 直接事件中提取到内容。")
665
+ results.append(('final', content))
666
+
667
+ # 格式2: Claude 和 GPT 返回的补丁流,以及 Gemini 的 patch 格式
668
+ elif data.get("type") == "patch" and "v" in data:
669
+ for operation in data.get("v", []):
670
+ if not isinstance(operation, dict): continue
671
+
672
+ op_type = operation.get("o")
673
+ path = operation.get("p", "")
674
+ value = operation.get("v")
675
+
676
+ # 【修改】Gemini 的完整内容 patch 格式
677
+ if op_type == "a" and path.endswith("/s/-") and isinstance(value, dict) and value.get("type") == "markdown-chat":
678
+ content = value.get("value", "")
679
+ if content:
680
+ logger.info("从 'patch' (Gemini-style) 中提取到完整内容。")
681
+ results.append(('final', content))
682
+
683
+ # 【修改】Gemini 的增量内容 patch 格式
684
+ elif op_type == "x" and "/s/" in path and path.endswith("/value") and isinstance(value, str):
685
+ content = value
686
+ if content:
687
+ logger.info(f"从 'patch' (Gemini增量) 中提取到内容: {content}")
688
+ results.append(('incremental', content))
689
+
690
+ # 【修改】Claude 和 GPT 的增量内容 patch 格式
691
+ elif op_type == "x" and "/value/" in path and isinstance(value, str):
692
+ content = value
693
+ if content:
694
+ logger.info(f"从 'patch' (Claude/GPT增量) 中提取到内容: {content}")
695
+ results.append(('incremental', content))
696
+
697
+ # 【修改】Claude 和 GPT 的完整内容 patch 格式
698
+ elif op_type == "a" and path.endswith("/value/-") and isinstance(value, dict) and value.get("type") == "text":
699
+ content = value.get("content", "")
700
+ if content:
701
+ logger.info("从 'patch' (Claude/GPT-style) 中提取到完整内容。")
702
+ results.append(('final', content))
703
+
704
+ # 格式3: 处理record-map类型的数据
705
+ elif data.get("type") == "record-map" and "recordMap" in data:
706
+ record_map = data["recordMap"]
707
+ if "thread_message" in record_map:
708
+ for msg_id, msg_data in record_map["thread_message"].items():
709
+ value_data = msg_data.get("value", {}).get("value", {})
710
+ step = value_data.get("step", {})
711
+ if not step: continue
712
+
713
+ content = ""
714
+ step_type = step.get("type")
715
+
716
+ if step_type == "markdown-chat":
717
+ content = step.get("value", "")
718
+ elif step_type == "agent-inference":
719
+ agent_values = step.get("value", [])
720
+ if isinstance(agent_values, list):
721
+ for item in agent_values:
722
+ if isinstance(item, dict) and item.get("type") == "text":
723
+ content = item.get("content", "")
724
+ break
725
+
726
+ if content and isinstance(content, str):
727
+ logger.info(f"从 record-map (type: {step_type}) 提取到最终内容。")
728
+ results.append(('final', content))
729
+ break
730
+
731
+ except (json.JSONDecodeError, AttributeError) as e:
732
+ logger.warning(f"解析NDJSON行失败: {e} - Line: {line.decode('utf-8', errors='ignore')}")
733
+
734
+ return results
735
+
736
+ async def get_models(self) -> JSONResponse:
737
+ model_data = {
738
+ "object": "list",
739
+ "data": [
740
+ {"id": name, "object": "model", "created": int(time.time()), "owned_by": "lzA6"}
741
+ for name in settings.KNOWN_MODELS
742
+ ]
743
+ }
744
+ return JSONResponse(content=model_data)
745
+
746
+
747
+ --- 文件路径: app\utils\sse_utils.py ---
748
+
749
+ # app/utils/sse_utils.py
750
+ import json
751
+ import time
752
+ from typing import Dict, Any, Optional
753
+
754
+ DONE_CHUNK = b"data: [DONE]\n\n"
755
+
756
+ def create_sse_data(data: Dict[str, Any]) -> bytes:
757
+ return f"data: {json.dumps(data)}\n\n".encode('utf-8')
758
+
759
+ def create_chat_completion_chunk(
760
+ request_id: str,
761
+ model: str,
762
+ content: Optional[str] = None,
763
+ finish_reason: Optional[str] = None,
764
+ role: Optional[str] = None
765
+ ) -> Dict[str, Any]:
766
+ delta: Dict[str, Any] = {}
767
+ if role is not None:
768
+ delta["role"] = role
769
+ if content is not None:
770
+ delta["content"] = content
771
+
772
+ return {
773
+ "id": request_id,
774
+ "object": "chat.completion.chunk",
775
+ "created": int(time.time()),
776
+ "model": model,
777
+ "choices": [
778
+ {
779
+ "index": 0,
780
+ "delta": delta,
781
+ "finish_reason": finish_reason
782
+ }
783
+ ]
784
+ }
785
+
786
+