XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet
公开
关注 0 Fork 0 Star 0
UTF-8
from __future__ import annotations

import os
import uuid
import json
from typing import AsyncIterator

from ..typing import AsyncResult, Messages, Cookies
from ..requests import StreamSession, raise_for_status, sse_stream
from ..cookies import get_cookies, get_cookies_dir
from ..providers.response import (
    ProviderInfo, JsonConversation, JsonRequest, JsonResponse, 
    Reasoning, Sources, SuggestedFollowups, ImageResponse, 
    VariantResponse, YouTubeResponse, TitleGeneration
)
from ..providers.base_provider import AsyncGeneratorProvider, ProviderModelMixin
from .. import debug

# Perplexity API endpoints
PERPLEXITY_URL = "https://www.perplexity.ai"
PERPLEXITY_DOMAIN = ".perplexity.ai"
AUTH_ENDPOINT = f"{PERPLEXITY_URL}/api/auth/session"
QUERY_ENDPOINT = f"{PERPLEXITY_URL}/rest/sse/perplexity_ask"

class Perplexity(AsyncGeneratorProvider, ProviderModelMixin):
    """
    Perplexity provider using browser emulation with HAR file support.
    
    This provider extends the base Perplexity implementation with HAR file support
    for easier authentication management. It uses curl_cffi's Chrome impersonation
    for realistic browser-like requests.
    """
    
    label = "Perplexity"
    url = PERPLEXITY_URL
    cookie_domain = PERPLEXITY_DOMAIN
    working = True
    active_by_default = True
    
    default_model = "auto"
    models = [
        default_model,
        "turbo",
        "gpt41",
        "gpt5",
        "gpt5_thinking",
        "o3",
        "o3pro",
        "claude2",
        "claude37sonnetthinking",
        "claude40opus",
        "claude40opusthinking",
        "claude41opusthinking",
        "claude45sonnet",
        "claude45sonnetthinking",
        "experimental",
        "grok",
        "grok4",
        "gemini2flash",
        "pplx_pro",
        "pplx_pro_upgraded",
        "pplx_alpha",
        "pplx_beta",
        "comet_max_assistant",
        "o3_research",
        "o3pro_research",
        "claude40sonnet_research",
        "claude40sonnetthinking_research",
        "claude40opus_research",
        "claude40opusthinking_research",
        "o3_labs",
        "o3pro_labs",
        "claude40sonnetthinking_labs",
        "claude40opusthinking_labs",
        "o4mini",
        "o1",
        "gpt4o",
        "gpt45",
        "gpt4",
        "o3mini",
        "claude35haiku",
        "llama_x_large",
        "mistral",
        "claude3opus",
        "gemini",
        "pplx_reasoning",
        "r1"
    ]
    fallback_models = ["perplexity", "pplx_pro"]
    model_aliases = {
        "gpt-5": "gpt5",
        "gpt-5-thinking": "gpt5_thinking",
        "r1-1776": "r1",
    }

    @classmethod
    async def get_quota(cls, **kwargs):
        chunks = []
        async for chunk in cls.create_async_generator(
            cls.default_model,
            [{"role": "user", "content": "say only okay"}]
        ):
            if isinstance(chunk, str):
                return {"content": chunk}
        raise RuntimeError("No response")
    
    @classmethod
    async def create_async_generator(
        cls,
        model: str,
        messages: Messages,
        cookies: Cookies = None,
        proxy: str = None,
        conversation: JsonConversation = None,
        prompt: str = None,
        **kwargs
    ) -> AsyncResult:
        """
        Create async generator for Perplexity requests with HAR file support.
        
        Authentication priority:
        1. HAR file cookies (har_and_cookies/perplexity*.har)
        2. Cookie jar from get_cookies()
        """
        if not model:
            model = cls.default_model
        
        # Try to get cookies from HAR file first
        if cookies is None:
            cookies = get_cookies(cls.cookie_domain, False)
            if cookies:
                debug.log(f"Perplexity: Using {len(cookies)} cookies from cookie jar")

        # Initialize conversation if needed
        if conversation is None:
            conversation = JsonConversation(
                frontend_uid=str(uuid.uuid4()),
                frontend_context_uuid=str(uuid.uuid4()),
                visitor_id=str(uuid.uuid4()),
                user_id=None,
                thread_url_slug=None,  # For conversation continuity via Referer header
            )
        
        request_id = str(uuid.uuid4())
        
        # Set referer based on thread_url_slug for conversation continuity
        referer = f"{cls.url}/"
        if hasattr(conversation, 'thread_url_slug') and conversation.thread_url_slug:
            referer = f"{cls.url}/search/{conversation.thread_url_slug}"
            # debug.log(f"Perplexity: Using conversation referer: {referer}")
        
        headers = {
            "accept": "text/event-stream",
            "accept-language": "en-US,en;q=0.9",
            "cache-control": "no-cache",
            "content-type": "application/json",
            "origin": cls.url,
            "referer": referer,
            "user-agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36",
            "x-perplexity-request-reason": "perplexity-query-state-provider",
            "x-request-id": request_id,
        }
        
        # Extract query from messages
        for message in reversed(messages):
            if message["role"] == "user":
                prompt = message["content"]
                if isinstance(prompt, list):
                    prompt = "\n".join([item.get("text", "") for item in prompt]) if prompt else ""
                break
        
        # Use StreamSession with Chrome impersonation
        async with StreamSession(headers=headers, cookies=cookies, proxy=proxy, impersonate="chrome") as session:
            # Get user info if needed
            if conversation.user_id is None:
                try:
                    async with session.get(f"{cls.url}/api/auth/session") as response:
                        await raise_for_status(response)
                        user = await response.json()
                        conversation.user_id = user.get("user", {}).get("id")
                        debug.log(f"Perplexity: User ID: {conversation.user_id}")
                except Exception as e:
                    debug.error(f"Perplexity: Failed to get user info: {e}")
            
            yield conversation
            
            # Determine model
            if model == "auto" or model == "perplexity":
                model = "pplx_pro" if conversation.user_id else "turbo"
            
            yield ProviderInfo(**cls.get_dict(), model=model)
            
            if model in cls.model_aliases:
                model = cls.model_aliases[model]
            
            # Build request data (same as original Perplexity)
            # Check if this is a followup request (has session tokens)
            is_followup = hasattr(conversation, 'last_backend_uuid') and conversation.last_backend_uuid
            
            # debug.log(f"Perplexity: is_followup={is_followup}")
            if is_followup:
                debug.log(f"Perplexity: followup with last_backend_uuid={conversation.last_backend_uuid}, read_write_token={getattr(conversation, 'read_write_token', None)}")
            
            # Generate new frontend_uuid for followup requests (browser does this)
            if is_followup:
                conversation.frontend_uid = str(uuid.uuid4())
            
            if not is_followup:
                data = {
                    "params": {
                        "attachments": [],
                        "language": "en-US",
                        "timezone": "America/Los_Angeles",
                        "search_focus": "internet",
                        "sources": ["web"],
                        "search_recency_filter": None,
                        "frontend_uuid": conversation.frontend_uid,
                        "mode": "copilot",  # Match HAR - use copilot mode
                        "model_preference": model,
                        "is_related_query": False,
                        "is_sponsored": False,
                        "frontend_context_uuid": conversation.frontend_context_uuid,
                        "prompt_source": "user",
                        "query_source": "home",
                        "is_incognito": False,
                        "time_from_first_type": 18361,  # Match HAR value
                        "local_search_enabled": False,
                        "use_schematized_api": True,
                        "send_back_text_in_streaming_api": False,
                        "supported_block_use_cases": [
                            "answer_modes",
                            "media_items",
                            "knowledge_cards",
                            "inline_entity_cards",
                            "place_widgets",
                            "finance_widgets",
                            "prediction_market_widgets",
                            "sports_widgets",
                            "flight_status_widgets",
                            "news_widgets",
                            "shopping_widgets",
                            "jobs_widgets",
                            "search_result_widgets",
                            "inline_images",
                            "inline_assets",
                            "placeholder_cards",
                            "diff_blocks",
                            "inline_knowledge_cards",
                            "entity_group_v2",
                            "refinement_filters",
                            "canvas_mode",
                            "maps_preview",
                            "answer_tabs",
                            "price_comparison_widgets",
                            "preserve_latex",
                            "generic_onboarding_widgets",
                            "in_context_suggestions",
                            "inline_claims"
                        ],
                        "client_coordinates": None,
                        "mentions": [],
                        "dsl_query": prompt,
                        "skip_search_enabled": True,
                        "is_nav_suggestions_disabled": False,
                        "source": "default",
                        "always_search_override": False,
                        "override_no_search": False,
                        "should_ask_for_mcp_tool_confirmation": True,
                        "browser_agent_allow_once_from_toggle": False,
                        "force_enable_browser_agent": False,
                        "supported_features": [
                            "browser_agent_permission_banner_v1.1"
                        ],
                        "version": "2.18"
                    },
                    "query_str": prompt
                }
            else:
                data = {
                    "params": {
                        "last_backend_uuid": getattr(conversation, 'last_backend_uuid', None),
                        "read_write_token": getattr(conversation, 'read_write_token', None),
                        "attachments": [],
                        "language": "en-US",
                        "timezone": "America/Los_Angeles",
                        "search_focus": "internet",
                        "sources": ["web"],
                        "search_recency_filter": None,
                        "frontend_uuid": conversation.frontend_uid,  # New UUID for followup
                        "mode": "copilot",  # Match HAR - use copilot mode
                        "model_preference": model,
                        "is_related_query": False,
                        "is_sponsored": False,
                        "prompt_source": "user",
                        "query_source": "followup",
                        "followup_source": "link",  # Critical for conversation continuity
                        "is_incognito": False,
                        "time_from_first_type": 8758,  # Match HAR value
                        "local_search_enabled": False,
                        "use_schematized_api": True,
                        "send_back_text_in_streaming_api": False,
                        "supported_block_use_cases": [
                            "answer_modes",
                            "media_items",
                            "knowledge_cards",
                            "inline_entity_cards",
                            "place_widgets",
                            "finance_widgets",
                            "prediction_market_widgets",
                            "sports_widgets",
                            "flight_status_widgets",
                            "news_widgets",
                            "shopping_widgets",
                            "jobs_widgets",
                            "search_result_widgets",
                            "inline_images",
                            "inline_assets",
                            "placeholder_cards",
                            "diff_blocks",
                            "inline_knowledge_cards",
                            "entity_group_v2",
                            "refinement_filters",
                            "canvas_mode",
                            "maps_preview",
                            "answer_tabs",
                            "price_comparison_widgets",
                            "preserve_latex",
                            "generic_onboarding_widgets",
                            "in_context_suggestions",
                            "inline_claims"
                        ],
                        "client_coordinates": None,
                        "mentions": [],
                        "dsl_query": prompt,
                        "skip_search_enabled": True,
                        "is_nav_suggestions_disabled": False,
                        "source": "default",
                        "always_search_override": False,
                        "override_no_search": False,
                        "should_ask_for_mcp_tool_confirmation": True,
                        "force_enable_browser_agent": False,
                        "supported_features": [
                            "browser_agent_permission_banner_v1.1"
                        ],
                        "version": "2.18"
                    },
                    "query_str": prompt
                }
            
            yield JsonRequest.from_dict(data)
            
            # Log full request data for debugging
            # debug.log(f"Perplexity: Request data: {json.dumps(data, indent=2, default=str)[:1000]}")
            
            # Send request
            # debug.log(f"Perplexity: Sending request to {QUERY_ENDPOINT}")
            
            async with session.post(QUERY_ENDPOINT, json=data) as response:
                # Process SSE stream
                # debug.log(f"Perplexity: Processing response...")
                await raise_for_status(response)
                
                full_response = ""
                full_reasoning = ""
                sources = []
                
                async for json_data in sse_stream(response):
                    yield JsonResponse.from_dict(json_data)
                    
                    # Capture session tokens for conversation continuity
                    # Note: The 'backend_uuid' field in responses is the backend UUID we need for followups
                    if 'backend_uuid' in json_data:
                        conversation.last_backend_uuid = json_data['backend_uuid']
                    
                    # Only capture read_write_token if we don't have one yet (like a session cookie)
                    if 'read_write_token' in json_data and not hasattr(conversation, 'read_write_token'):
                        conversation.read_write_token = json_data['read_write_token']
                    
                    # Capture thread_url_slug for conversation continuity via Referer header
                    if 'thread_url_slug' in json_data and (not hasattr(conversation, 'thread_url_slug') or not conversation.thread_url_slug):
                        conversation.thread_url_slug = json_data.get('thread_url_slug')

                    if 'thread_title' in json_data:
                        conversation.thread_title = json_data['thread_title']
                        yield TitleGeneration(json_data['thread_title'])
                    for block in json_data.get("blocks", []):
                        # Handle sources
                        if block.get("intended_usage") == "sources_answer_mode":
                            sources = block.get("sources_mode_block", {}).get("web_results", [])
                            continue
                        
                        # Handle media items
                        if block.get("intended_usage") == "media_items":
                            yield VariantResponse("".join([chunk.to_string() if hasattr(chunk, "to_string") else str(chunk) for chunk in [
                                ImageResponse(item.get("url"), item.get("name"), {
                                    "height": item.get("image_height"),
                                    "width": item.get("image_width"),
                                    **item
                                }) if item.get("medium") == "image" else YouTubeResponse(item.get("url").split("=").pop())
                                for item in block.get("media_block", {}).get("media_items", [])
                            ]]))
                            continue
                        
                        # Handle response text
                        for patch in block.get("diff_block", {}).get("patches", []):
                            if patch.get("path") == "/progress":
                                continue
                            
                            value = patch.get("value", "")
                            
                            # Handle reasoning
                            if isinstance(value, dict) and "chunks" in value:
                                value = "".join(value.get("chunks", []))
                            
                            if patch.get("path").startswith("/goals"):
                                if isinstance(value, str):
                                    if value.startswith(full_reasoning):
                                        value = value[len(full_reasoning):]
                                    if value:
                                        yield Reasoning(value)
                                        full_reasoning += value
                                else:
                                    yield Reasoning(status="")
                                continue
                            
                            # Handle regular response
                            if block.get("diff_block").get("field") != "markdown_block":
                                continue
                            
                            value = value.get("answer", "") if isinstance(value, dict) else value
                            
                            if value and isinstance(value, str):
                                if value.startswith(full_response):
                                    value = value[len(full_response):]
                                elif full_response.endswith(value):
                                    value = ""
                                if value:
                                    full_response += value
                                    yield value
                    
                    # Handle follow-ups
                    if "related_query_items" in json_data:
                        followups = []
                        for item in json_data["related_query_items"]:
                            followups.append(item.get("text", ""))
                        yield SuggestedFollowups(followups)
                if sources:
                    yield Sources([{"name": f"Perplexity - {conversation.thread_title}", "url": f"{cls.url}/search/{conversation.thread_url_slug}"}] + sources)
                yield conversation
                
                # debug.log("Perplexity: Request completed successfully")
                # debug.log(f"Perplexity: last_backend_uuid={getattr(conversation, 'last_backend_uuid', None)}, read_write_token={getattr(conversation, 'read_write_token', None)}")