package core import ( "bufio" "bytes" "encoding/base64" "encoding/json" "fmt" "io" "mime/multipart" "net/http" "pplx2api/config" "pplx2api/logger" "pplx2api/model" "pplx2api/utils" "strings" "time" "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/imroc/req/v3" ) // Client represents a Perplexity API client type Client struct { sessionToken string client *req.Client Model string Attachments []string OpenSerch bool } // Perplexity API structures type PerplexityRequest struct { Params PerplexityParams `json:"params"` QueryStr string `json:"query_str"` } type PerplexityParams struct { Attachments []string `json:"attachments"` Language string `json:"language"` Timezone string `json:"timezone"` SearchFocus string `json:"search_focus"` Sources []string `json:"sources"` SearchRecencyFilter interface{} `json:"search_recency_filter"` FrontendUUID string `json:"frontend_uuid"` Mode string `json:"mode"` ModelPreference string `json:"model_preference"` IsRelatedQuery bool `json:"is_related_query"` IsSponsored bool `json:"is_sponsored"` VisitorID string `json:"visitor_id"` UserNextauthID string `json:"user_nextauth_id"` FrontendContextUUID string `json:"frontend_context_uuid"` PromptSource string `json:"prompt_source"` QuerySource string `json:"query_source"` BrowserHistorySummary []interface{} `json:"browser_history_summary"` IsIncognito bool `json:"is_incognito"` UseSchematizedAPI bool `json:"use_schematized_api"` SendBackTextInStreaming bool `json:"send_back_text_in_streaming_api"` SupportedBlockUseCases []string `json:"supported_block_use_cases"` ClientCoordinates interface{} `json:"client_coordinates"` IsNavSuggestionsDisabled bool `json:"is_nav_suggestions_disabled"` Version string `json:"version"` } // Response structures type PerplexityResponse struct { Blocks []Block `json:"blocks"` Status string `json:"status"` DisplayModel string `json:"display_model"` } type Block struct { MarkdownBlock *MarkdownBlock `json:"markdown_block,omitempty"` ReasoningPlanBlock *ReasoningPlanBlock `json:"reasoning_plan_block,omitempty"` WebResultBlock *WebResultBlock `json:"web_result_block,omitempty"` ImageModeBlock *ImageModeBlock `json:"image_mode_block,omitempty"` } type MarkdownBlock struct { Chunks []string `json:"chunks"` } type ReasoningPlanBlock struct { Goals []Goal `json:"goals"` } type Goal struct { Description string `json:"description"` } type WebResultBlock struct { WebResults []WebResult `json:"web_results"` } type WebResult struct { Name string `json:"name"` Snippet string `json:"snippet"` URL string `json:"url"` } type ImageModeBlock struct { AnswerModeType string `json:"answer_mode_type"` Progress string `json:"progress"` MediaItems []struct { Medium string `json:"medium"` Image string `json:"image"` URL string `json:"url"` Name string `json:"name"` Source string `json:"source"` Thumbnail string `json:"thumbnail"` } `json:"media_items"` } // NewClient creates a new Perplexity API client func NewClient(sessionToken string, proxy string, model string, openSerch bool) *Client { client := req.C().ImpersonateChrome().SetTimeout(time.Minute * 10) client.Transport.SetResponseHeaderTimeout(time.Second * 10) if proxy != "" { client.SetProxyURL(proxy) } // Set common headers headers := map[string]string{ "accept-language": "en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7,zh-TW;q=0.6", "cache-control": "no-cache", "origin": "https://www.perplexity.ai", "pragma": "no-cache", "priority": "u=1, i", "referer": "https://www.perplexity.ai/", } for key, value := range headers { client.SetCommonHeader(key, value) } // Set cookies if sessionToken != "" { client.SetCommonCookies(&http.Cookie{ Name: "__Secure-next-auth.session-token", Value: sessionToken, }) } // Create client with visitor ID c := &Client{ sessionToken: sessionToken, client: client, Model: model, Attachments: []string{}, OpenSerch: openSerch, } return c } // SendMessage sends a message to Perplexity and returns the status and response func (c *Client) SendMessage(message string, stream bool, is_incognito bool, gc *gin.Context) (int, error) { // Create request body requestBody := PerplexityRequest{ Params: PerplexityParams{ Attachments: c.Attachments, Language: "en-US", Timezone: "America/New_York", SearchFocus: "writing", Sources: []string{}, // SearchFocus: "internet", // Sources: []string{"web"}, SearchRecencyFilter: nil, FrontendUUID: uuid.New().String(), Mode: "copilot", ModelPreference: c.Model, IsRelatedQuery: false, IsSponsored: false, VisitorID: uuid.New().String(), UserNextauthID: uuid.New().String(), FrontendContextUUID: uuid.New().String(), PromptSource: "user", QuerySource: "home", BrowserHistorySummary: []interface{}{}, IsIncognito: is_incognito, UseSchematizedAPI: true, SendBackTextInStreaming: false, SupportedBlockUseCases: []string{ "answer_modes", "media_items", "knowledge_cards", "inline_entity_cards", "place_widgets", "finance_widgets", "sports_widgets", "shopping_widgets", "jobs_widgets", "search_result_widgets", "entity_list_answer", "todo_list", }, ClientCoordinates: nil, IsNavSuggestionsDisabled: false, Version: "2.18", }, QueryStr: message, } if c.OpenSerch { requestBody.Params.SearchFocus = "internet" requestBody.Params.Sources = append(requestBody.Params.Sources, "web") } logger.Info(fmt.Sprintf("Perplexity request body: %v", requestBody)) // Make the request resp, err := c.client.R().DisableAutoReadResponse(). SetBody(requestBody). Post("https://www.perplexity.ai/rest/sse/perplexity_ask") if err != nil { logger.Error(fmt.Sprintf("Error sending request: %v", err)) return 500, fmt.Errorf("request failed: %w", err) } logger.Info(fmt.Sprintf("Perplexity response status code: %d", resp.StatusCode)) if resp.StatusCode == http.StatusTooManyRequests { resp.Body.Close() return http.StatusTooManyRequests, fmt.Errorf("rate limit exceeded") } if resp.StatusCode != http.StatusOK { logger.Error(fmt.Sprintf("Unexpected return data: %s", resp.String())) resp.Body.Close() return resp.StatusCode, fmt.Errorf("unexpected status code: %d", resp.StatusCode) } return 200, c.HandleResponse(resp.Body, stream, gc) } func (c *Client) HandleResponse(body io.ReadCloser, stream bool, gc *gin.Context) error { defer body.Close() // Set headers for streaming if stream { gc.Writer.Header().Set("Content-Type", "text/event-stream") gc.Writer.Header().Set("Cache-Control", "no-cache") gc.Writer.Header().Set("Connection", "keep-alive") gc.Writer.WriteHeader(http.StatusOK) gc.Writer.Flush() } scanner := bufio.NewScanner(body) clientDone := gc.Request.Context().Done() // 增大缓冲区大小 scanner.Buffer(make([]byte, 1024*1024), 1024*1024) full_text := "" inThinking := false thinkShown := false final := false for scanner.Scan() { select { case <-clientDone: logger.Info("Client connection closed") return nil default: } line := scanner.Text() // Skip empty lines if line == "" { continue } if !strings.HasPrefix(line, "data: ") { continue } data := line[6:] // logger.Info(fmt.Sprintf("Received data: %s", data)) var response PerplexityResponse if err := json.Unmarshal([]byte(data), &response); err != nil { logger.Error(fmt.Sprintf("Error parsing JSON: %v", err)) continue } // Check for completion and web results if response.Status == "COMPLETED" { final = true for _, block := range response.Blocks { if block.ImageModeBlock != nil && block.ImageModeBlock.Progress == "DONE" && len(block.ImageModeBlock.MediaItems) > 0 { imageResultsText := "" imageModelList := []string{} for i, result := range block.ImageModeBlock.MediaItems { imageResultsText += utils.ImageShow(i, result.Name, result.Image) imageModelList = append(imageModelList, result.Name) } if len(imageModelList) > 0 { imageResultsText = imageResultsText + "\n\n---\n" + strings.Join(imageModelList, ", ") } full_text += imageResultsText if stream { model.ReturnOpenAIResponse(imageResultsText, stream, gc) } } } for _, block := range response.Blocks { if !config.ConfigInstance.IgnoreSerchResult && block.WebResultBlock != nil && len(block.WebResultBlock.WebResults) > 0 { webResultsText := "\n\n---\n" for i, result := range block.WebResultBlock.WebResults { webResultsText += "\n\n" + utils.SearchShow(i, result.Name, result.URL, result.Snippet) } full_text += webResultsText if stream { model.ReturnOpenAIResponse(webResultsText, stream, gc) } } } if !config.ConfigInstance.IgnoreModelMonitoring && response.DisplayModel != c.Model { res_text := "\n\n---\n" res_text += fmt.Sprintf("Display Model: %s\n", config.ModelReverseMapGet(response.DisplayModel, response.DisplayModel)) full_text += res_text if !stream { break } model.ReturnOpenAIResponse(res_text, stream, gc) } } if final { break } // Process each block in the response for _, block := range response.Blocks { // Handle reasoning plan blocks (thinking) if block.ReasoningPlanBlock != nil && len(block.ReasoningPlanBlock.Goals) > 0 { res_text := "" if !inThinking && !thinkShown { res_text += "" inThinking = true } for _, goal := range block.ReasoningPlanBlock.Goals { if goal.Description != "" && goal.Description != "Beginning analysis" && goal.Description != "Wrapping up analysis" { res_text += goal.Description } } full_text += res_text if !stream { continue } model.ReturnOpenAIResponse(res_text, stream, gc) } } for _, block := range response.Blocks { if block.MarkdownBlock != nil && len(block.MarkdownBlock.Chunks) > 0 { res_text := "" if inThinking { res_text += "\n" inThinking = false thinkShown = true } for _, chunk := range block.MarkdownBlock.Chunks { if chunk != "" { res_text += chunk } } full_text += res_text if !stream { continue } model.ReturnOpenAIResponse(res_text, stream, gc) } } } if err := scanner.Err(); err != nil { return fmt.Errorf("error reading response: %w", err) } if !stream { model.ReturnOpenAIResponse(full_text, stream, gc) } else { // Send end marker for streaming mode gc.Writer.Write([]byte("data: [DONE]\n\n")) gc.Writer.Flush() } return nil } // UploadURLResponse represents the response from the create_upload_url endpoint type UploadURLResponse struct { S3BucketURL string `json:"s3_bucket_url"` S3ObjectURL string `json:"s3_object_url"` Fields CloudinaryUploadInfo `json:"fields"` RateLimited bool `json:"rate_limited"` } type CloudinaryUploadInfo struct { Timestamp int `json:"timestamp"` UniqueFilename string `json:"unique_filename"` Folder string `json:"folder"` UseFilename string `json:"use_filename"` PublicID string `json:"public_id"` Transformation string `json:"transformation"` Moderation string `json:"moderation"` ResourceType string `json:"resource_type"` APIKey string `json:"api_key"` CloudName string `json:"cloud_name"` Signature string `json:"signature"` AWSAccessKeyId string `json:"AWSAccessKeyId"` Key string `json:"key"` Tagging string `json:"tagging"` Policy string `json:"policy"` Xamzsecuritytoken string `json:"x-amz-security-token"` ACL string `json:"acl"` } // UploadFile is a placeholder for file upload functionality func (c *Client) createUploadURL(filename string, contentType string) (*UploadURLResponse, error) { requestBody := map[string]interface{}{ "filename": filename, "content_type": contentType, "source": "default", "file_size": 12000, "force_image": false, } resp, err := c.client.R(). SetBody(requestBody). Post("https://www.perplexity.ai/rest/uploads/create_upload_url?version=2.18&source=default") if err != nil { logger.Error(fmt.Sprintf("Error creating upload URL: %v", err)) return nil, err } if resp.StatusCode != http.StatusOK { logger.Error(fmt.Sprintf("Image Upload with status code %d: %s", resp.StatusCode, resp.String())) return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) } var uploadURLResponse UploadURLResponse logger.Info(fmt.Sprintf("Create upload with status code %d: %s", resp.StatusCode, resp.String())) if err := json.Unmarshal(resp.Bytes(), &uploadURLResponse); err != nil { logger.Error(fmt.Sprintf("Error unmarshalling upload URL response: %v", err)) return nil, err } if uploadURLResponse.RateLimited { logger.Error("Rate limit exceeded for upload URL") return nil, fmt.Errorf("rate limit exceeded") } return &uploadURLResponse, nil } func (c *Client) UploadImage(img_list []string) error { logger.Info(fmt.Sprintf("Uploading %d images to Cloudinary", len(img_list))) // Upload images to Cloudinary for _, img := range img_list { filename := utils.RandomString(5) + ".jpg" // Create upload URL uploadURLResponse, err := c.createUploadURL(filename, "image/jpeg") if err != nil { logger.Error(fmt.Sprintf("Error creating upload URL: %v", err)) return err } logger.Info(fmt.Sprintf("Upload URL response: %v", uploadURLResponse)) // Upload image to Cloudinary err = c.UloadFileToCloudinary(uploadURLResponse.Fields, "img", img, filename) if err != nil { logger.Error(fmt.Sprintf("Error uploading image: %v", err)) return err } } return nil } func (c *Client) UloadFileToCloudinary(uploadInfo CloudinaryUploadInfo, contentType string, filedata string, filename string) error { if len(filedata) > 100 { logger.Info(fmt.Sprintf("filedata: %s ……", filedata[:50])) } // Add form fields logger.Info(fmt.Sprintf("Uploading file %s to Cloudinary", filename)) var formFields map[string]string if contentType == "img" { formFields = map[string]string{ "timestamp": fmt.Sprintf("%d", uploadInfo.Timestamp), "unique_filename": uploadInfo.UniqueFilename, "folder": uploadInfo.Folder, "use_filename": uploadInfo.UseFilename, "public_id": uploadInfo.PublicID, "transformation": uploadInfo.Transformation, "moderation": uploadInfo.Moderation, "resource_type": uploadInfo.ResourceType, "api_key": uploadInfo.APIKey, "cloud_name": uploadInfo.CloudName, "signature": uploadInfo.Signature, "type": "private", } } else { formFields = map[string]string{ "acl": uploadInfo.ACL, "Content-Type": "text/plain", "tagging": uploadInfo.Tagging, "key": uploadInfo.Key, "AWSAccessKeyId": uploadInfo.AWSAccessKeyId, "x-amz-security-token": uploadInfo.Xamzsecuritytoken, "policy": uploadInfo.Policy, "signature": uploadInfo.Signature, } } var requestBody bytes.Buffer writer := multipart.NewWriter(&requestBody) for key, value := range formFields { if err := writer.WriteField(key, value); err != nil { logger.Error(fmt.Sprintf("Error writing form field %s: %v", key, err)) return err } } // Add the file,filedata 是base64编码的字符串 decodedData, err := base64.StdEncoding.DecodeString(filedata) if err != nil { logger.Error(fmt.Sprintf("Error decoding base64 data: %v", err)) return err } // 创建一个文件部分 part, err := writer.CreateFormFile("file", filename) // 替换 filename.ext 为实际文件名 if err != nil { logger.Error(fmt.Sprintf("Error creating form file: %v", err)) return err } // 将解码后的数据写入文件部分 if _, err := part.Write(decodedData); err != nil { logger.Error(fmt.Sprintf("Error writing file data: %v", err)) return err } // Close the writer to finalize the form if err := writer.Close(); err != nil { logger.Error(fmt.Sprintf("Error closing writer: %v", err)) return err } // Create the upload request var uploadURL string if contentType == "img" { uploadURL = fmt.Sprintf("https://api.cloudinary.com/v1_1/%s/image/upload", uploadInfo.CloudName) } else { uploadURL = "https://ppl-ai-file-upload.s3.amazonaws.com/" } resp, err := c.client.R(). SetHeader("Content-Type", writer.FormDataContentType()). SetBodyBytes(requestBody.Bytes()). Post(uploadURL) if err != nil { logger.Error(fmt.Sprintf("Error uploading file: %v", err)) return err } logger.Info(fmt.Sprintf("Image Upload with status code %d: %s", resp.StatusCode, resp.String())) if contentType == "img" { var uploadResponse map[string]interface{} if err := json.Unmarshal(resp.Bytes(), &uploadResponse); err != nil { return err } imgUrl := uploadResponse["secure_url"].(string) imgUrl = "https://pplx-res.cloudinary.com/image/private" + imgUrl[strings.Index(imgUrl, "/user_uploads"):] c.Attachments = append(c.Attachments, imgUrl) } else { c.Attachments = append(c.Attachments, "https://ppl-ai-file-upload.s3.amazonaws.com/"+uploadInfo.Key) } return nil } // SetBigContext is a placeholder for setting context func (c *Client) UploadText(context string) error { logger.Info("Uploading txt to Cloudinary") filedata := base64.StdEncoding.EncodeToString([]byte(context)) filename := utils.RandomString(5) + ".txt" // Upload images to Cloudinary uploadURLResponse, err := c.createUploadURL(filename, "text/plain") if err != nil { logger.Error(fmt.Sprintf("Error creating upload URL: %v", err)) return err } logger.Info(fmt.Sprintf("Upload URL response: %v", uploadURLResponse)) // Upload txt to Cloudinary err = c.UloadFileToCloudinary(uploadURLResponse.Fields, "txt", filedata, filename) if err != nil { logger.Error(fmt.Sprintf("Error uploading image: %v", err)) return err } return nil } func (c *Client) GetNewCookie() (string, error) { resp, err := c.client.R().Get("https://www.perplexity.ai/api/auth/session") if err != nil { logger.Error(fmt.Sprintf("Error getting session cookie: %v", err)) return "", err } if resp.StatusCode != http.StatusOK { logger.Error(fmt.Sprintf("Error getting session cookie: %s", resp.String())) return "", fmt.Errorf("unexpected status code: %d", resp.StatusCode) } for _, cookie := range resp.Cookies() { if cookie.Name == "__Secure-next-auth.session-token" { return cookie.Value, nil } } return "", fmt.Errorf("session cookie not found") }