| 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"
|
| )
|
|
|
|
|
| type Client struct {
|
| sessionToken string
|
| client *req.Client
|
| Model string
|
| Attachments []string
|
| OpenSerch bool
|
| }
|
|
|
|
|
| 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"`
|
| }
|
|
|
|
|
| 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"`
|
| }
|
|
|
|
|
| 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)
|
| }
|
|
|
|
|
| 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)
|
| }
|
|
|
|
|
| if sessionToken != "" {
|
| client.SetCommonCookies(&http.Cookie{
|
| Name: "__Secure-next-auth.session-token",
|
| Value: sessionToken,
|
| })
|
| }
|
|
|
|
|
| c := &Client{
|
| sessionToken: sessionToken,
|
| client: client,
|
| Model: model,
|
| Attachments: []string{},
|
| OpenSerch: openSerch,
|
| }
|
|
|
| return c
|
| }
|
|
|
|
|
| func (c *Client) SendMessage(message string, stream bool, is_incognito bool, gc *gin.Context) (int, error) {
|
|
|
| requestBody := PerplexityRequest{
|
| Params: PerplexityParams{
|
| Attachments: c.Attachments,
|
| Language: "en-US",
|
| Timezone: "America/New_York",
|
| SearchFocus: "writing",
|
| Sources: []string{},
|
|
|
|
|
| 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))
|
|
|
| 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()
|
|
|
| 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()
|
|
|
| if line == "" {
|
| continue
|
| }
|
| if !strings.HasPrefix(line, "data: ") {
|
| continue
|
| }
|
| data := line[6:]
|
|
|
| var response PerplexityResponse
|
| if err := json.Unmarshal([]byte(data), &response); err != nil {
|
| logger.Error(fmt.Sprintf("Error parsing JSON: %v", err))
|
| continue
|
| }
|
|
|
| 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
|
| }
|
|
|
| for _, block := range response.Blocks {
|
|
|
| if block.ReasoningPlanBlock != nil && len(block.ReasoningPlanBlock.Goals) > 0 {
|
|
|
| res_text := ""
|
| if !inThinking && !thinkShown {
|
| res_text += "<think>"
|
| 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 += "</think>\n\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 {
|
|
|
| gc.Writer.Write([]byte("data: [DONE]\n\n"))
|
| gc.Writer.Flush()
|
| }
|
|
|
| return nil
|
| }
|
|
|
|
|
| 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"`
|
| }
|
|
|
|
|
| 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)))
|
|
|
|
|
| for _, img := range img_list {
|
| filename := utils.RandomString(5) + ".jpg"
|
|
|
| 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))
|
|
|
| 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]))
|
| }
|
|
|
| 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
|
| }
|
| }
|
|
|
|
|
| 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)
|
| 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
|
| }
|
|
|
| if err := writer.Close(); err != nil {
|
| logger.Error(fmt.Sprintf("Error closing writer: %v", err))
|
| return err
|
| }
|
|
|
|
|
| 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
|
| }
|
|
|
|
|
| func (c *Client) UploadText(context string) error {
|
| logger.Info("Uploading txt to Cloudinary")
|
| filedata := base64.StdEncoding.EncodeToString([]byte(context))
|
| filename := utils.RandomString(5) + ".txt"
|
|
|
| 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))
|
|
|
| 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")
|
| }
|
|
|