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

import json
import base64
import os
from urllib.parse import urlencode, quote

from ..typing import AsyncResult, Messages, ImageType
from ..requests import StreamSession, raise_for_status
from ..providers.response import (
    ProviderInfo, JsonConversation, Sources,
    SuggestedFollowups, Usage, Reasoning
)
from ..image import to_bytes
from ..tools.media import merge_media
from ..providers.base_provider import AsyncGeneratorProvider, ProviderModelMixin
from .. import debug


BRAVE_URL = "https://search.brave.com"
BRAVE_ASK_URL = f"{BRAVE_URL}/ask"
DATA_ENDPOINT = f"{BRAVE_ASK_URL}/__data.json"
API_BASE = f"{BRAVE_URL}/api/tap/v1"
NEW_ENDPOINT = f"{API_BASE}/new"
STREAM_ENDPOINT = f"{API_BASE}/stream"

# Deep Research takes much longer due to multi-iteration web crawling
DEEP_RESEARCH_TIMEOUT = 600


class Conversation(JsonConversation):
    """Manages session state for Brave Search Ask."""
    conversation_id: str = None
    symmetric_key: str = None
    nonce: str = None
    sig: str = None

    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        if self.symmetric_key is None:
            self.symmetric_key = base64.urlsafe_b64encode(
                os.urandom(32)
            ).decode().rstrip("=")


class BraveSearch(AsyncGeneratorProvider, ProviderModelMixin):
    """
    Free provider for Brave Search's Ask AI feature.
    
    Uses the Brave Search Ask endpoint (search.brave.com/ask) which provides
    AI-powered answers augmented with web search results. No authentication 
    required.
    
    Models:
    - brave: Standard Ask Brave (fast, single-pass answer)
    - brave-deep-research: Deep Research mode (multi-iteration web crawling,
      generates comprehensive research reports, takes 1-5 minutes)
    """

    label = "Brave Search"
    url = BRAVE_ASK_URL
    working = True
    needs_auth = False
    supports_stream = True
    supports_system_message = False
    supports_message_history = False

    default_model = "brave"
    models = [default_model, "brave-deep-research"]

    @classmethod
    def _is_deep_research(cls, model: str) -> bool:
        """Check if the model is Deep Research mode."""
        return model == "brave-deep-research"

    @classmethod
    def _parse_sveltekit_tap_data(cls, raw_json: dict) -> dict:
        """
        Parse SvelteKit __data.json format to extract nonce and sig 
        from the tap (Ask) page node.
        
        SvelteKit data format uses indexed arrays where:
        - node_data[0] is a schema dict mapping field names to indices
        - remaining elements are values referenced by those indices
        - The "token" field contains a nested schema with q/nonce/sig indices
        """
        result = {}

        if raw_json.get("type") != "data" or "nodes" not in raw_json:
            return result

        for node in raw_json["nodes"]:
            if node.get("type") != "data" or "data" not in node:
                continue

            node_data = node["data"]
            if not isinstance(node_data, list) or len(node_data) < 2:
                continue

            schema = node_data[0]
            if not isinstance(schema, dict):
                continue

            # Find the tap node (page == "tap")
            page_idx = schema.get("page")
            if not (isinstance(page_idx, int) and 0 < page_idx < len(node_data)):
                continue
            if node_data[page_idx] != "tap":
                continue

            # Extract token which contains q, nonce, sig
            token_idx = schema.get("token")
            if isinstance(token_idx, int) and 0 < token_idx < len(node_data):
                token = node_data[token_idx]
                if isinstance(token, dict):
                    for key, val_idx in token.items():
                        if isinstance(val_idx, int) and 0 < val_idx < len(node_data):
                            result[key] = node_data[val_idx]
            break

        return result

    @classmethod
    def _process_research_event(cls, event: dict):
        """
        Process a Deep Research 'research' event and return 
        a Reasoning object for status display, or None.
        """
        inner = event.get("event", {})
        if not isinstance(inner, dict):
            return None

        research_event = inner.get("event", "")

        if research_event == "queries":
            queries = inner.get("queries", [])
            return Reasoning(
                status=f"Researching: {', '.join(queries[:3])}",
                label="Deep Research"
            )

        elif research_event == "analyzing":
            query = inner.get("query", "")
            urls = inner.get("urls", 0)
            return Reasoning(
                status=f"Analyzing {urls} sources for: {query}",
                label="Deep Research"
            )

        elif research_event == "thinking":
            query = inner.get("query", "")
            chunks = inner.get("chunks_analyzed", 0)
            urls = inner.get("urls_analyzed", 0)
            return Reasoning(
                status=f"Processing {chunks} chunks from {urls} sources: {query}",
                label="Deep Research"
            )

        elif research_event == "progress":
            iterations = inner.get("number_of_iterations", 0)
            queries = inner.get("number_of_queries", 0)
            urls = inner.get("number_of_urls_analyzed", 0)
            elapsed = inner.get("elasped_seconds", 0)
            return Reasoning(
                status=f"Iteration {iterations}: {queries} queries, "
                       f"{urls} URLs analyzed ({elapsed:.0f}s)",
                label="Deep Research"
            )

        elif research_event == "blindspots":
            spots = inner.get("blindspots", [])
            if spots:
                return Reasoning(
                    status=f"Identified gaps: {', '.join(spots[:3])}",
                    label="Deep Research"
                )

        elif research_event == "answer" and inner.get("final"):
            return Reasoning(
                status="Research complete, generating final answer",
                label="Deep Research"
            )

        return None

    @classmethod
    async def create_async_generator(
        cls,
        model: str,
        messages: Messages,
        proxy: str = None,
        images: ImageType | list[ImageType] = None,
        media: MediaListType = None,
        conversation: Conversation = None,
        prompt: str = None,
        **kwargs
    ) -> AsyncResult:
        model = cls.get_model(model)
        is_deep = cls._is_deep_research(model)

        # Extract the last user message as the query
        if not prompt:
            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 isinstance(item, dict)]
                        ) if prompt else ""
                    break

        if not prompt:
            raise ValueError("No user message found in messages")

        # Initialize conversation if needed
        if conversation is None:
            conversation = Conversation()

        headers = {
            "accept": "application/json",
            "accept-language": "en-US,en;q=0.9",
            "cache-control": "no-cache",
            "pragma": "no-cache",
            "user-agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
                          "(KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36",
        }

        # Deep Research needs a much longer timeout
        timeout = DEEP_RESEARCH_TIMEOUT if is_deep else 120

        async with StreamSession(
            headers=headers,
            proxy=proxy,
            impersonate="chrome",
            timeout=timeout,
        ) as session:
            # ===== STEP 1: Get nonce + sig from __data.json =====
            referer = f"{BRAVE_ASK_URL}?q=&source=llmSuggest"
            if conversation.conversation_id:
                referer = (
                    f"{BRAVE_ASK_URL}?q={quote(prompt)}&source=llmSuggest"
                    f"&conversation={conversation.conversation_id}"
                )

            data_params = {
                "q": prompt,
                "x-sveltekit-invalidated": "11",
            }
            if is_deep:
                data_params["enable_research"] = "true"

            async with session.get(
                DATA_ENDPOINT,
                params=data_params,
                headers={"referer": referer}
            ) as response:
                await raise_for_status(response)
                data_json = await response.json()
                tap_data = cls._parse_sveltekit_tap_data(data_json)

                nonce = tap_data.get("nonce")
                sig = tap_data.get("sig")
                if not nonce or not sig:
                    raise RuntimeError(
                        "Failed to extract nonce/sig from __data.json. "
                        f"Got: {tap_data}"
                    )
                conversation.nonce = nonce
                conversation.sig = sig
                debug.log(f"BraveSearch: Got nonce={nonce[:8]}... sig={sig[:8]}...")

            # ===== STEP 2: Create new conversation =====
            new_params = {
                "language": "en",
                "country": "us",
                "ui_lang": "en-us",
                "safesearch": "moderate",
                "force_safesearch": "0",
                "units_of_measurement": "metric",
                "use_location": "1",
                "geoloc": "50.457x30.532",
                "premium_cookie_name": "__Secure-sku#brave-search-premium",
                "symmetric_key": conversation.symmetric_key,
                "source": "newThread" if is_deep else "llmSuggest",
                "enable_research": "true" if is_deep else "false",
                "q": prompt,
                "nonce": nonce,
                "sig": sig,
            }

            async with session.get(
                NEW_ENDPOINT,
                params=new_params,
                headers={
                    "referer": f"{BRAVE_ASK_URL}?q={quote(prompt)}&source=newThread"
                }
            ) as response:
                await raise_for_status(response)
                new_data = await response.json()
                conversation.conversation_id = new_data.get("id")
                if not conversation.conversation_id:
                    raise RuntimeError(
                        f"Failed to get conversation ID from /new: {new_data}"
                    )
                debug.log(
                    f"BraveSearch: Conversation ID: {conversation.conversation_id}"
                )

            yield conversation
            yield ProviderInfo(**cls.get_dict(), model=model)

            # ===== STEP 3: Stream the response =====
            stream_params = {
                "language": "en",
                "country": "us",
                "ui_lang": "en-us",
                "safesearch": "moderate",
                "force_safesearch": "0",
                "units_of_measurement": "metric",
                "use_location": "1",
                "geoloc": "50.457x30.532",
                "premium_cookie_name": "__Secure-sku#brave-search-premium",
                "id": conversation.conversation_id,
                "query": prompt,
                "symmetric_key": conversation.symmetric_key,
                "enable_inline_entities": "true",
            }

            stream_url = f"{STREAM_ENDPOINT}?{urlencode(stream_params)}"

            request_kwargs = {
                "headers": {
                    "accept": "text/event-stream",
                    "origin": "https://search.brave.com",
                    "referer": (
                        f"{BRAVE_ASK_URL}?q={quote(prompt)}&source=newThread"
                        f"&conversation={conversation.conversation_id}"
                    ),
                }
            }
            
            async with session.get(stream_url, **request_kwargs) as response:
                await raise_for_status(response)

                sources = []
                buffer = b""
                # Track if we've received the final research answer
                # to avoid yielding intermediate answers as text
                research_final_received = False

                async for chunk in response.iter_content():
                    if not chunk:
                        continue
                    buffer += chunk

                    # Process complete lines (newline-delimited JSON)
                    while b"\n" in buffer:
                        line_bytes, buffer = buffer.split(b"\n", 1)
                        line = line_bytes.decode("utf-8", errors="replace").strip()
                        if not line:
                            continue

                        try:
                            event = json.loads(line)
                        except json.JSONDecodeError:
                            continue

                        event_type = event.get("type", "")

                        if event_type == "text_delta":
                            delta = event.get("delta", "")
                            if delta:
                                yield delta

                        elif event_type == "research_start":
                            yield Reasoning(
                                status="Starting deep research...",
                                label="Deep Research"
                            )

                        elif event_type == "research":
                            reasoning = cls._process_research_event(event)
                            if reasoning:
                                yield reasoning

                        elif event_type == "research_end":
                            yield Reasoning(
                                status="Research phase complete",
                                label="Deep Research"
                            )

                        elif event_type == "augment_with_inline_citation":
                            url = event.get("url")
                            title = event.get("title")
                            if url:
                                sources.append({
                                    "url": url,
                                    "title": title or url,
                                })

                        elif event_type == "followups":
                            followups = event.get("followups", [])
                            if followups:
                                yield SuggestedFollowups(followups)

                        elif event_type == "usage":
                            yield Usage(
                                input_tokens=event.get("prompt_tokens"),
                                output_tokens=event.get("completion_tokens"),
                            )

                # Process remaining buffer
                if buffer:
                    line = buffer.decode("utf-8", errors="replace").strip()
                    if line:
                        try:
                            event = json.loads(line)
                            event_type = event.get("type", "")
                            if event_type == "text_delta":
                                delta = event.get("delta", "")
                                if delta:
                                    yield delta
                        except json.JSONDecodeError:
                            pass

                # Yield sources at the end
                if sources:
                    # Deduplicate sources by URL
                    seen_urls = set()
                    unique_sources = []
                    for src in sources:
                        if src["url"] not in seen_urls:
                            seen_urls.add(src["url"])
                            unique_sources.append(src)
                    yield Sources(unique_sources)

                yield conversation