|
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"
|
|
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")
|
|
}
|
|
|