From 2581198765ffc1f3acd4402e39f574898cee3b64 Mon Sep 17 00:00:00 2001 From: Chinese-tingfeng <128880206+lzA6@users.noreply.github.com> Date: Mon, 13 Oct 2025 17:39:45 +0800 Subject: [PATCH] =?UTF-8?q?Create=20=E9=A1=B9=E7=9B=AE=E5=AE=8C=E6=95=B4?= =?UTF-8?q?=E7=BB=93=E6=9E=84=E4=BB=A3=E7=A0=81.txt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 项目完整结构代码.txt | 786 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 786 insertions(+) create mode 100644 项目完整结构代码.txt diff --git a/项目完整结构代码.txt b/项目完整结构代码.txt new file mode 100644 index 0000000..5dcd686 --- /dev/null +++ b/项目完整结构代码.txt @@ -0,0 +1,786 @@ +项目 'notion-2api' 的结构树: +📂 notion-2api/ + 📄 .env + 📄 .env.example + 📄 Dockerfile + 📄 docker-compose.yml + 📄 main.py + 📄 nginx.conf + 📄 requirements.txt + 📂 app/ + 📂 core/ + 📄 __init__.py + 📄 config.py + 📂 providers/ + 📄 __init__.py + 📄 base_provider.py + 📄 notion_provider.py + 📂 utils/ + 📄 sse_utils.py +================================================================================ + +--- 文件路径: .env --- + +# [自动填充] notion-2api 生产环境配置 + +# --- 安全配置 --- +API_MASTER_KEY=1 + +# --- 端口配置 --- +NGINX_PORT=8088 + +# --- Notion 凭证 (以下均为必须或强烈建议设置) --- + +# 1. 您的 token_v2 (已从最新日志中提取并更新) +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" + +# 2. 您的 Space ID (保持不变) +NOTION_SPACE_ID="f108eefa-d0dc-8181-8382-0003e15d764e" + +# 3. 您的用户 ID (从日志中提取) +NOTION_USER_ID="200d872b-594c-8153-b674-00028d202a8b" + +# 4. 您的 Notion 用户名 (请确认是否准确) +NOTION_USER_NAME="利仔" + +# 5. 您的 Notion 登录邮箱 (请替换为您的真实邮箱) +NOTION_USER_EMAIL="q13645947407@gmail.com" + +# 6. 可选:页面 Block ID (保持留空以提高兼容性) +NOTION_BLOCK_ID="" + +# 7. 可选:客户端版本 (保持默认即可) +NOTION_CLIENT_VERSION="23.13.20251011.2037" + +--- 文件路径: .env.example --- + +# ==================================================================== +# notion-2api 配置文件模板 (最终版) +# ==================================================================== +# +# 请将此文件重命名为 ".env" 并填入您的凭证。 +# + +# --- 核心安全配置 (可选) --- +API_MASTER_KEY=your_secret_key_here + +# --- 部署配置 (可选) --- +NGINX_PORT=8088 + +# --- Notion 凭证 (以下均为必须或强烈建议设置) --- +# 1) 粘贴 token_v2 的值 或 完整 Cookie +NOTION_COOKIE="在此处粘贴 token_v2 值 或 完整 Cookie" + +# 2) 您的 Space ID +NOTION_SPACE_ID="在此处粘贴您的 Space ID" + +# 3) 您的用户 ID (浏览器开发者工具中 x-notion-active-user-header 的值) +NOTION_USER_ID="在此处粘贴您的 Notion 用户 ID" + +# 4) 您的 Notion 用户名 (显示在左上角的名称) +NOTION_USER_NAME="利仔" + +# 5) 您的 Notion 登录邮箱 +NOTION_USER_EMAIL="q13645947407@gmail.com" + +# 可选:想绑定的页面 blockId。留空则不绑定特定页面上下文。 +NOTION_BLOCK_ID="" + +# 可选:浏览器中看到的客户端版本 +NOTION_CLIENT_VERSION="23.13.20251011.2037" + +--- 文件路径: Dockerfile --- + +# ==================================================================== +# Dockerfile for inception-2api (v4.0 - Cloudscraper Edition) +# ==================================================================== + +FROM python:3.10-slim + +# 设置环境变量 +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 +WORKDIR /app + +# 安装 Python 依赖 +COPY requirements.txt . +RUN pip install --no-cache-dir --upgrade pip && \ + pip install --no-cache-dir -r requirements.txt + +# 复制应用代码 +COPY . . + +# 创建并切换到非 root 用户 +RUN useradd --create-home appuser && \ + chown -R appuser:appuser /app +USER appuser + +# 暴露端口并启动 +EXPOSE 8000 +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4"] + + +--- 文件路径: docker-compose.yml --- + +# docker-compose.yml +services: + nginx: + image: nginx:latest + container_name: notion-2api-nginx + restart: always + ports: + - "${NGINX_PORT:-8088}:80" + volumes: + - ./nginx.conf:/etc/nginx/nginx.conf:ro + depends_on: + - app + networks: + - notion-net + + app: + build: + context: . + dockerfile: Dockerfile + container_name: notion-2api-app + restart: unless-stopped + env_file: + - .env + networks: + - notion-net + +networks: + notion-net: + driver: bridge + + +--- 文件路径: main.py --- + +# main.py +import logging +from contextlib import asynccontextmanager +from typing import Optional + +from fastapi import FastAPI, Request, HTTPException, Depends, Header +from fastapi.responses import JSONResponse, StreamingResponse + +from app.core.config import settings +from app.providers.notion_provider import NotionAIProvider + +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') +logger = logging.getLogger(__name__) + +provider = NotionAIProvider() + +@asynccontextmanager +async def lifespan(app: FastAPI): + logger.info(f"应用启动中... {settings.APP_NAME} v{settings.APP_VERSION}") + logger.info("服务已配置为 Notion AI 代理模式。") + logger.info(f"服务将在 http://localhost:{settings.NGINX_PORT} 上可用") + yield + logger.info("应用关闭。") + +app = FastAPI( + title=settings.APP_NAME, + version=settings.APP_VERSION, + description=settings.DESCRIPTION, + lifespan=lifespan +) + +async def verify_api_key(authorization: Optional[str] = Header(None)): + if settings.API_MASTER_KEY and settings.API_MASTER_KEY != "1": + if not authorization or "bearer" not in authorization.lower(): + raise HTTPException(status_code=401, detail="需要 Bearer Token 认证。") + token = authorization.split(" ")[-1] + if token != settings.API_MASTER_KEY: + raise HTTPException(status_code=403, detail="无效的 API Key。") + +@app.post("/v1/chat/completions", dependencies=[Depends(verify_api_key)]) +async def chat_completions(request: Request) -> StreamingResponse: + try: + request_data = await request.json() + return await provider.chat_completion(request_data) + except Exception as e: + logger.error(f"处理聊天请求时发生顶层错误: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"内部服务器错误: {str(e)}") + +@app.get("/v1/models", dependencies=[Depends(verify_api_key)], response_class=JSONResponse) +async def list_models(): + return await provider.get_models() + +@app.get("/", summary="根路径") +def root(): + return {"message": f"欢迎来到 {settings.APP_NAME} v{settings.APP_VERSION}. 服务运行正常。"} + + +--- 文件路径: nginx.conf --- + +worker_processes auto; + +events { + worker_connections 1024; +} + +http { + upstream notion_backend { + server app:8000; + } + + server { + listen 80; + server_name localhost; + + location / { + proxy_pass http://notion_backend; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # 【核心修正】增加代理超时时间,以应对Cloudflare挑战 + proxy_connect_timeout 600s; + proxy_send_timeout 600s; + proxy_read_timeout 600s; + send_timeout 600s; + + # 流式传输优化 + proxy_buffering off; + proxy_cache off; + proxy_set_header Connection ''; + proxy_http_version 1.1; + chunked_transfer_encoding off; + } + } +} + + +--- 文件路径: requirements.txt --- + +# requirements.txt +fastapi +uvicorn[standard] +httpx +pydantic-settings +python-dotenv +cloudscraper + + +--- 文件路径: app\core\__init__.py --- + + + +--- 文件路径: app\core\config.py --- + +# app/core/config.py +from pydantic_settings import BaseSettings, SettingsConfigDict +from typing import List, Optional + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding='utf-8', + extra="ignore" + ) + + APP_NAME: str = "notion-2api" + APP_VERSION: str = "4.0.0" # 最终稳定版 + DESCRIPTION: str = "一个将 Notion AI 转换为兼容 OpenAI 格式 API 的高性能代理。" + + API_MASTER_KEY: Optional[str] = None + + # --- Notion 凭证 --- + NOTION_COOKIE: Optional[str] = None + NOTION_SPACE_ID: Optional[str] = None + NOTION_USER_ID: Optional[str] = None + NOTION_USER_NAME: Optional[str] = None + NOTION_USER_EMAIL: Optional[str] = None + NOTION_BLOCK_ID: Optional[str] = None + NOTION_CLIENT_VERSION: Optional[str] = "23.13.20251011.2037" + + API_REQUEST_TIMEOUT: int = 180 + NGINX_PORT: int = 8088 + + # 【最终修正】更新所有已知的模型列表 + DEFAULT_MODEL: str = "claude-sonnet-4.5" + + KNOWN_MODELS: List[str] = [ + "claude-sonnet-4.5", + "gpt-5", + "claude-opus-4.1", + "gemini-2.5-flash(未修复,不可用)", + "gemini-2.5-pro(未修复,不可用)", + "gpt-4.1" + ] + + # 【最终修正】根据您提供的信息,填充所有模型的真实后台名称 + MODEL_MAP: dict = { + "claude-sonnet-4.5": "anthropic-sonnet-alt", + "gpt-5": "openai-turbo", + "claude-opus-4.1": "anthropic-opus-4.1", + "gemini-2.5-flash(未修复,不可用)": "vertex-gemini-2.5-flash", + "gemini-2.5-pro(未修复,不可用)": "vertex-gemini-2.5-pro", + "gpt-4.1": "openai-gpt-4.1" + } + +settings = Settings() + +--- 文件路径: app\providers\__init__.py --- + + + +--- 文件路径: app\providers\base_provider.py --- + +from abc import ABC, abstractmethod +from typing import Dict, Any, Union +from fastapi.responses import StreamingResponse, JSONResponse + +class BaseProvider(ABC): + @abstractmethod + async def chat_completion( + self, + request_data: Dict[str, Any] + ) -> Union[StreamingResponse, JSONResponse]: + pass + + @abstractmethod + async def get_models(self) -> JSONResponse: + pass + + +--- 文件路径: app\providers\notion_provider.py --- + +# app/providers/notion_provider.py +import json +import time +import logging +import uuid +import re +import cloudscraper +from typing import Dict, Any, AsyncGenerator, List, Optional, Tuple +from datetime import datetime + +from fastapi import HTTPException +from fastapi.responses import StreamingResponse, JSONResponse +from fastapi.concurrency import run_in_threadpool + +from app.core.config import settings +from app.providers.base_provider import BaseProvider +from app.utils.sse_utils import create_sse_data, create_chat_completion_chunk, DONE_CHUNK + +# 设置日志记录器 +logger = logging.getLogger(__name__) + +class NotionAIProvider(BaseProvider): + def __init__(self): + self.scraper = cloudscraper.create_scraper() + self.api_endpoints = { + "runInference": "https://www.notion.so/api/v3/runInferenceTranscript", + "saveTransactions": "https://www.notion.so/api/v3/saveTransactionsFanout" + } + + if not all([settings.NOTION_COOKIE, settings.NOTION_SPACE_ID, settings.NOTION_USER_ID]): + raise ValueError("配置错误: NOTION_COOKIE, NOTION_SPACE_ID 和 NOTION_USER_ID 必须在 .env 文件中全部设置。") + + self._warmup_session() + + def _warmup_session(self): + try: + logger.info("正在进行会话预热 (Session Warm-up)...") + headers = self._prepare_headers() + headers.pop("Accept", None) + response = self.scraper.get("https://www.notion.so/", headers=headers, timeout=30) + response.raise_for_status() + logger.info("会话预热成功。") + except Exception as e: + logger.error(f"会话预热失败: {e}", exc_info=True) + + async def _create_thread(self, thread_type: str) -> str: + thread_id = str(uuid.uuid4()) + payload = { + "requestId": str(uuid.uuid4()), + "transactions": [{ + "id": str(uuid.uuid4()), + "spaceId": settings.NOTION_SPACE_ID, + "operations": [{ + "pointer": {"table": "thread", "id": thread_id, "spaceId": settings.NOTION_SPACE_ID}, + "path": [], + "command": "set", + "args": { + "id": thread_id, "version": 1, "parent_id": settings.NOTION_SPACE_ID, + "parent_table": "space", "space_id": settings.NOTION_SPACE_ID, + "created_time": int(time.time() * 1000), + "created_by_id": settings.NOTION_USER_ID, "created_by_table": "notion_user", + "messages": [], "data": {}, "alive": True, "type": thread_type + } + }] + }] + } + try: + logger.info(f"正在创建新的对话线程 (type: {thread_type})...") + response = await run_in_threadpool( + lambda: self.scraper.post( + self.api_endpoints["saveTransactions"], + headers=self._prepare_headers(), + json=payload, + timeout=20 + ) + ) + response.raise_for_status() + logger.info(f"对话线程创建成功, Thread ID: {thread_id}") + return thread_id + except Exception as e: + logger.error(f"创建对话线程失败: {e}", exc_info=True) + raise Exception("无法创建新的对话线程。") + + async def chat_completion(self, request_data: Dict[str, Any]): + stream = request_data.get("stream", True) + + async def stream_generator() -> AsyncGenerator[bytes, None]: + request_id = f"chatcmpl-{uuid.uuid4()}" + incremental_fragments: List[str] = [] + final_message: Optional[str] = None + + try: + model_name = request_data.get("model", settings.DEFAULT_MODEL) + mapped_model = settings.MODEL_MAP.get(model_name, "anthropic-sonnet-alt") + + thread_type = "markdown-chat" if mapped_model.startswith("vertex-") else "workflow" + + thread_id = await self._create_thread(thread_type) + payload = self._prepare_payload(request_data, thread_id, mapped_model, thread_type) + headers = self._prepare_headers() + + role_chunk = create_chat_completion_chunk(request_id, model_name, role="assistant") + yield create_sse_data(role_chunk) + + def sync_stream_iterator(): + try: + logger.info(f"请求 Notion AI URL: {self.api_endpoints['runInference']}") + logger.info(f"请求体: {json.dumps(payload, indent=2, ensure_ascii=False)}") + + response = self.scraper.post( + self.api_endpoints['runInference'], headers=headers, json=payload, stream=True, + timeout=settings.API_REQUEST_TIMEOUT + ) + response.raise_for_status() + for line in response.iter_lines(): + if line: + yield line + except Exception as e: + yield e + + sync_gen = sync_stream_iterator() + + while True: + line = await run_in_threadpool(lambda: next(sync_gen, None)) + if line is None: + break + if isinstance(line, Exception): + raise line + + parsed_results = self._parse_ndjson_line_to_texts(line) + for text_type, content in parsed_results: + if text_type == 'final': + final_message = content + elif text_type == 'incremental': + incremental_fragments.append(content) + + full_response = "" + if final_message: + full_response = final_message + logger.info(f"成功从 record-map 或 Gemini patch/event 中提取到最终消息。") + else: + full_response = "".join(incremental_fragments) + logger.info(f"使用拼接所有增量片段的方式获得最终消息。") + + if full_response: + cleaned_response = self._clean_content(full_response) + logger.info(f"清洗后的最终响应: {cleaned_response}") + chunk = create_chat_completion_chunk(request_id, model_name, content=cleaned_response) + yield create_sse_data(chunk) + else: + logger.warning("警告: Notion 返回的数据流中未提取到任何有效文本。请检查您的 .env 配置是否全部正确且凭证有效。") + + final_chunk = create_chat_completion_chunk(request_id, model_name, finish_reason="stop") + yield create_sse_data(final_chunk) + yield DONE_CHUNK + + except Exception as e: + error_message = f"处理 Notion AI 流时发生意外错误: {str(e)}" + logger.error(error_message, exc_info=True) + error_chunk = {"error": {"message": error_message, "type": "internal_server_error"}} + yield create_sse_data(error_chunk) + yield DONE_CHUNK + + if stream: + return StreamingResponse(stream_generator(), media_type="text/event-stream") + else: + raise HTTPException(status_code=400, detail="此端点当前仅支持流式响应 (stream=true)。") + + def _prepare_headers(self) -> Dict[str, str]: + cookie_source = (settings.NOTION_COOKIE or "").strip() + cookie_header = cookie_source if "=" in cookie_source else f"token_v2={cookie_source}" + + return { + "Content-Type": "application/json", + "Accept": "application/x-ndjson", + "Cookie": cookie_header, + "x-notion-space-id": settings.NOTION_SPACE_ID, + "x-notion-active-user-header": settings.NOTION_USER_ID, + "x-notion-client-version": settings.NOTION_CLIENT_VERSION, + "notion-audit-log-platform": "web", + "Origin": "https://www.notion.so", + "Referer": "https://www.notion.so/", + "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", + } + + def _normalize_block_id(self, block_id: str) -> str: + if not block_id: return block_id + b = block_id.replace("-", "").strip() + if len(b) == 32 and re.fullmatch(r"[0-9a-fA-F]{32}", b): + return f"{b[0:8]}-{b[8:12]}-{b[12:16]}-{b[16:20]}-{b[20:]}" + return block_id + + def _prepare_payload(self, request_data: Dict[str, Any], thread_id: str, mapped_model: str, thread_type: str) -> Dict[str, Any]: + req_block_id = request_data.get("notion_block_id") or settings.NOTION_BLOCK_ID + normalized_block_id = self._normalize_block_id(req_block_id) if req_block_id else None + + context_value: Dict[str, Any] = { + "timezone": "Asia/Shanghai", + "spaceId": settings.NOTION_SPACE_ID, + "userId": settings.NOTION_USER_ID, + "userEmail": settings.NOTION_USER_EMAIL, + "currentDatetime": datetime.now().astimezone().isoformat(), + } + if normalized_block_id: + context_value["blockId"] = normalized_block_id + + config_value: Dict[str, Any] + + if mapped_model.startswith("vertex-"): + logger.info(f"检测到 Gemini 模型 ({mapped_model}),应用特定的 config 和 context。") + context_value.update({ + "userName": f" {settings.NOTION_USER_NAME}", + "spaceName": f"{settings.NOTION_USER_NAME}的 Notion", + "spaceViewId": "2008eefa-d0dc-80d5-9e67-000623befd8f", + "surface": "ai_module" + }) + config_value = { + "type": thread_type, + "model": mapped_model, + "useWebSearch": True, + "enableAgentAutomations": False, "enableAgentIntegrations": False, + "enableBackgroundAgents": False, "enableCodegenIntegration": False, + "enableCustomAgents": False, "enableExperimentalIntegrations": False, + "enableLinkedDatabases": False, "enableAgentViewVersionHistoryTool": False, + "searchScopes": [{"type": "everything"}], "enableDatabaseAgents": False, + "enableAgentComments": False, "enableAgentForms": False, + "enableAgentMakesFormulas": False, "enableUserSessionContext": False, + "modelFromUser": True, "isCustomAgent": False + } + else: + context_value.update({ + "userName": settings.NOTION_USER_NAME, + "surface": "workflows" + }) + config_value = { + "type": thread_type, + "model": mapped_model, + "useWebSearch": True, + } + + transcript = [ + {"id": str(uuid.uuid4()), "type": "config", "value": config_value}, + {"id": str(uuid.uuid4()), "type": "context", "value": context_value} + ] + + for msg in request_data.get("messages", []): + if msg.get("role") == "user": + transcript.append({ + "id": str(uuid.uuid4()), + "type": "user", + "value": [[msg.get("content")]], + "userId": settings.NOTION_USER_ID, + "createdAt": datetime.now().astimezone().isoformat() + }) + elif msg.get("role") == "assistant": + transcript.append({"id": str(uuid.uuid4()), "type": "agent-inference", "value": [{"type": "text", "content": msg.get("content")}]}) + + payload = { + "traceId": str(uuid.uuid4()), + "spaceId": settings.NOTION_SPACE_ID, + "transcript": transcript, + "threadId": thread_id, + "createThread": False, + "isPartialTranscript": True, + "asPatchResponse": True, + "generateTitle": True, + "saveAllThreadOperations": True, + "threadType": thread_type + } + + if mapped_model.startswith("vertex-"): + logger.info("为 Gemini 请求添加 debugOverrides。") + payload["debugOverrides"] = { + "emitAgentSearchExtractedResults": True, + "cachedInferences": {}, + "annotationInferences": {}, + "emitInferences": False + } + + return payload + + def _clean_content(self, content: str) -> str: + if not content: + return "" + + content = re.sub(r'\n*', '', content) + content = re.sub(r'[\s\S]*?\s*', '', content, flags=re.IGNORECASE) + content = re.sub(r'[\s\S]*?\s*', '', content, flags=re.IGNORECASE) + + content = re.sub(r'^.*?Chinese whatmodel I am.*?Theyspecifically.*?requested.*?me.*?to.*?reply.*?in.*?Chinese\.\s*', '', content, flags=re.IGNORECASE | re.DOTALL) + content = re.sub(r'^.*?This.*?is.*?a.*?straightforward.*?question.*?about.*?my.*?identity.*?asan.*?AI.*?assistant\.\s*', '', content, flags=re.IGNORECASE | re.DOTALL) + 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) + 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) + 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) + 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) + content = re.sub(r'^.*?I.*?should.*?identify.*?myself.*?as.*?Notion.*?AI.*?as.*?mentioned.*?in.*?the.*?system.*?prompt.*?\s*', '', content, flags=re.IGNORECASE | re.DOTALL) + 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) + + return content.strip() + + def _parse_ndjson_line_to_texts(self, line: bytes) -> List[Tuple[str, str]]: + results: List[Tuple[str, str]] = [] + try: + s = line.decode("utf-8", errors="ignore").strip() + if not s: return results + + data = json.loads(s) + logger.debug(f"原始响应数据: {json.dumps(data, ensure_ascii=False)}") + + # 格式1: Gemini 返回的 markdown-chat 事件 + if data.get("type") == "markdown-chat": + content = data.get("value", "") + if content: + logger.info("从 'markdown-chat' 直接事件中提取到内容。") + results.append(('final', content)) + + # 格式2: Claude 和 GPT 返回的补丁流,以及 Gemini 的 patch 格式 + elif data.get("type") == "patch" and "v" in data: + for operation in data.get("v", []): + if not isinstance(operation, dict): continue + + op_type = operation.get("o") + path = operation.get("p", "") + value = operation.get("v") + + # 【修改】Gemini 的完整内容 patch 格式 + if op_type == "a" and path.endswith("/s/-") and isinstance(value, dict) and value.get("type") == "markdown-chat": + content = value.get("value", "") + if content: + logger.info("从 'patch' (Gemini-style) 中提取到完整内容。") + results.append(('final', content)) + + # 【修改】Gemini 的增量内容 patch 格式 + elif op_type == "x" and "/s/" in path and path.endswith("/value") and isinstance(value, str): + content = value + if content: + logger.info(f"从 'patch' (Gemini增量) 中提取到内容: {content}") + results.append(('incremental', content)) + + # 【修改】Claude 和 GPT 的增量内容 patch 格式 + elif op_type == "x" and "/value/" in path and isinstance(value, str): + content = value + if content: + logger.info(f"从 'patch' (Claude/GPT增量) 中提取到内容: {content}") + results.append(('incremental', content)) + + # 【修改】Claude 和 GPT 的完整内容 patch 格式 + elif op_type == "a" and path.endswith("/value/-") and isinstance(value, dict) and value.get("type") == "text": + content = value.get("content", "") + if content: + logger.info("从 'patch' (Claude/GPT-style) 中提取到完整内容。") + results.append(('final', content)) + + # 格式3: 处理record-map类型的数据 + elif data.get("type") == "record-map" and "recordMap" in data: + record_map = data["recordMap"] + if "thread_message" in record_map: + for msg_id, msg_data in record_map["thread_message"].items(): + value_data = msg_data.get("value", {}).get("value", {}) + step = value_data.get("step", {}) + if not step: continue + + content = "" + step_type = step.get("type") + + if step_type == "markdown-chat": + content = step.get("value", "") + elif step_type == "agent-inference": + agent_values = step.get("value", []) + if isinstance(agent_values, list): + for item in agent_values: + if isinstance(item, dict) and item.get("type") == "text": + content = item.get("content", "") + break + + if content and isinstance(content, str): + logger.info(f"从 record-map (type: {step_type}) 提取到最终内容。") + results.append(('final', content)) + break + + except (json.JSONDecodeError, AttributeError) as e: + logger.warning(f"解析NDJSON行失败: {e} - Line: {line.decode('utf-8', errors='ignore')}") + + return results + + async def get_models(self) -> JSONResponse: + model_data = { + "object": "list", + "data": [ + {"id": name, "object": "model", "created": int(time.time()), "owned_by": "lzA6"} + for name in settings.KNOWN_MODELS + ] + } + return JSONResponse(content=model_data) + + +--- 文件路径: app\utils\sse_utils.py --- + +# app/utils/sse_utils.py +import json +import time +from typing import Dict, Any, Optional + +DONE_CHUNK = b"data: [DONE]\n\n" + +def create_sse_data(data: Dict[str, Any]) -> bytes: + return f"data: {json.dumps(data)}\n\n".encode('utf-8') + +def create_chat_completion_chunk( + request_id: str, + model: str, + content: Optional[str] = None, + finish_reason: Optional[str] = None, + role: Optional[str] = None +) -> Dict[str, Any]: + delta: Dict[str, Any] = {} + if role is not None: + delta["role"] = role + if content is not None: + delta["content"] = content + + return { + "id": request_id, + "object": "chat.completion.chunk", + "created": int(time.time()), + "model": model, + "choices": [ + { + "index": 0, + "delta": delta, + "finish_reason": finish_reason + } + ] + } + +