本篇文章中,我们简单聊聊如何在 OpenAI 的 ChatGPT Web 客户端中,自由的接入和使用各种数据源。

写在前面

三月以来,我在 ChatGPT 官方客户端上做了不少实践,也做过一些技术分享。也在网上晒过一些折腾的有趣的事情:

  • 例子 1,直接通过 ChatGPT 来搜索最新发行的游戏和游戏攻略,以及某些平台上的商品价格信息,并根据销量给出简单的购买建议。
  •  例子 2,通过 ChatGPT 来阅读超长的内容,你可以自由组合信息来源,或者使用开源的支持长 Token 生成的模型。
  •  例子 3,直接在 ChatGPT 里,调用 Mid Journey 来绘制图片。

有不少朋友好奇其中的实现,也有一些朋友觉得 ChatGPT Web 客户端是一个很棒的载体,拥有着不错的交互形式,希望能够使用这种方式来玩,节约大量不必要的系统开发成本,以及期待能够一起进行开源共建。

最近,在一位合作伙伴的推动下,从五一假期开始,我们陆陆续续进行了“ChatGPT”后端服务的代码重写,以及部分敏感信息的剥离:把 ChatGPT 的前端封装成了独立的 Docker 容器,并重写了一套兼容 ChatGPT 客户端的后端服务。

让任何人都可以在本地启动一套和官方交互体验一致的 ChatGPT 客户端,并能够根据自己需求接入合适的信息源来玩:

  • 可以是借助 API 调用的模型接口,不仅限于 OpenAI 3.5 或 4,你也可以接入 HuggingFace 或者国内的大模型,甚至是托管在你的私有环境的服务。比如,在 ChatGPT 里甚至能够调用 Claude、国内的通义千问、图片生成模型。
  • 可以是一个搜索引擎,用聊天的方式,实现信息的搜索,顺带再使用模型的生成能力来调整和润色返回的结果。
  • 可以是固定的数据源或数据库,比如指定的内容、博客长文,甚至是你预设的一个固定答案,哪怕存在文本文件或者 Excel 里的数据。
  • 也可以是 RSS 信息源,或者任意你希望对接的 “API”、“网站”等等,不论目前 ChatGPT 官方是否支持,你是否排队排到了的各种功能使用权限。

目前,这两个项目分别开放在了 GitHub 上:

或许等待后端相对完善之后,我会重写一套完全开源的前端客户端,让整个项目变的真的完整起来。

基础使用:OpenAI API

在项目的示例目录中,我们能够找到一些开箱即用的使用 Demo,先来看看最简单的接入 OpenAI API 的配置示例

version: '3'

services:

  # 能够私有化部署的 ChatGPT Web 客户端
  chatgpt-client:
    image: soulteary/chatgpt
    restart: always
    ports:
      - 8090:8090
    environment:
      # 容器中的服务使用的端口
      APP_PORT: 8090
      # 前端使用的 ChatGPT 客户端域名,需要和 `sparrow` 中的 `WEB_CLIENT_HOSTNAME` 中的设置保持一致
      APP_HOSTNAME: "http://localhost:8090"
      # 客户端使用的服务端地址,如果你使用这个配置文件,可以保持下面的数值,否则需要调整为 `sparrow` 部署的实际地址
      APP_UPSTREAM: "http://sparrow:8091"

  # 开源实现的后端服务
  sparrow:
    image: soulteary/sparrow
    restart: always
    environment:
      # [基础设置]
      # => ChatGPT Web 客户端使用的域名,需要和 `chatgpt-client` 的 `APP_HOSTNAME` 保持一致
      WEB_CLIENT_HOSTNAME: "http://localhost:8090"
      # => 服务端口,默认端口: 8091
      # APP_PORT: 8091

      # [私有使用 OpenAI API 服务设置] *可选配置
      # => 启用 OpenAI API
      ENABLE_OPENAI_API: "on"
      # => OpenAI API Key,填写你自己的 KEY
      OPENAI_API_KEY: "sk-123456789012345678901234567890123456789012345678"
      # => 启用访问 API 的代理,如果你不是在海外服务器使用
      # OPENAI_API_PROXY_ENABLE: "on"
      # => 设置 API 代理地址, eg: `"http://127.0.0.1:1234"` or ""
      # OPENAI_API_PROXY_ADDR: "http://127.0.0.1:1234"
    logging:
        driver: "json-file"
        options:
            max-size: "10m"

我们将上面的文件保存为 docker-compose.yml ,访问 OpenAI 的 API Key 管理页面,将自己的 API 更新到配置中。接着,使用 docker compose up 启动程序,将看到类似下面的日志输出:

# docker compose down && docker compose up

[+] Running 9/9
 ✔ sparrow 2 layers [⣿⣿]      0B/0B      Pulled                                                                                                                                                 41.5s 
   ✔ 178ce6ca3c2d Pull complete                                                                                                                                                                  2.6s 
   ✔ 6e49bc84596f Pull complete                                                                                                                                                                  6.1s 
 ✔ chatgpt-client 5 layers [⣿⣿⣿⣿⣿]      0B/0B      Pulled                                                                                                                                       25.7s 
   ✔ 2408cc74d12b Already exists                                                                                                                                                                 0.0s 
   ✔ 53e036a1e5c8 Pull complete                                                                                                                                                                  2.9s 
   ✔ b6a24d60453c Pull complete                                                                                                                                                                  3.7s 
   ✔ a5072006fa7c Pull complete                                                                                                                                                                  6.3s 
   ✔ 8a30712078cf Pull complete                                                                                                                                                                  6.3s 
[+] Running 3/1
 ✔ Network chatgpt_default             Created                                                                                                                                                   0.1s 
 ✔ Container chatgpt-sparrow-1         Created                                                                                                                                                   0.0s 
 ✔ Container chatgpt-chatgpt-client-1  Created                                                                                                                                                   0.0s 
Attaching to chatgpt-chatgpt-client-1, chatgpt-sparrow-1
chatgpt-sparrow-1         | Sparrow vv0.10.1
chatgpt-sparrow-1         | Sparrow Service has been launched 🚀
chatgpt-chatgpt-client-1  | [OpenAI Chat Client] http://localhost:8090
chatgpt-chatgpt-client-1  | - Project: https://github.com/soulteary/docker-chatgpt
chatgpt-chatgpt-client-1  | - Release: 2023.05.19 v1

等待服务启动完毕,我们在浏览器中打开 http://localhost:8090 (或你自定义的地址) 就能够使用自己搭建的 ChatGPT 服务了。

崭新的 “ChatGPT”

如果你希望将服务搭建在其他的机器上,只需要调整上面配置中的两个环境变量即可(比如 http://10.11.12.240:8090):

version: '3'
services:

  chatgpt-client:
...
    environment:
      APP_HOSTNAME: "http://10.11.12.240:8090"
...
  sparrow:
...
    environment:
      WEB_CLIENT_HOSTNAME: "http://10.11.12.240:8090"
...

当完成配置的调整后,我们重新使用 docker compose up 启动服务,能够看到日志输出的内容中包含了我们新的配置地址:

...
chatgpt-sparrow-1         | Sparrow vv0.10.1
chatgpt-sparrow-1         | Sparrow Service has been launched 🚀
chatgpt-chatgpt-client-1  | [OpenAI Chat Client] http://10.11.12.240:8090
chatgpt-chatgpt-client-1  | - Project: https://github.com/soulteary/docker-chatgpt
chatgpt-chatgpt-client-1  | - Release: 2023.05.19 v1

在 ChatGPT 中调用 OpenAI API

是不是非常简单?当然,这个仅仅是个 Demo,“OpenAI API 数据源”在开源的后端代码项目里是这样的,只有不到 40 行:

package OpenaiAPI

import (
	"context"
	"fmt"
	"net/http"
	"net/url"

	openai "github.com/sashabaranov/go-openai"
	"github.com/soulteary/sparrow/internal/define"
)

func GetClient() *openai.Client {
	config := openai.DefaultConfig(define.OPENAI_API_KEY)
	if define.ENABLE_OPENAI_API_PROXY {
		proxyUrl, err := url.Parse(define.OPENAI_API_PROXY_ADDR)
		if err != nil {
			panic(err)
		}
		transport := &http.Transport{Proxy: http.ProxyURL(proxyUrl)}
		config.HTTPClient = &http.Client{Transport: transport}
	}
	return openai.NewClientWithConfig(config)
}

func Get(prompt string) string {
	c := GetClient()
	resp, err := c.CreateChatCompletion(
		context.Background(),
		openai.ChatCompletionRequest{
			Model:    openai.GPT3Dot5Turbo,
			Messages: []openai.ChatCompletionMessage{{Role: openai.ChatMessageRoleUser, Content: prompt}},
		},
	)
	if err != nil {
		return fmt.Sprintf("OpenAI API, Chat Completion error: %v\n", err)
	}
	return resp.Choices[0].Message.Content
}

如果你想有更好的体验,比如完整的会话记录管理、多轮会话上下文保持,欢迎来开源项目中提交你的改进代码。

基础使用:官方不支持的图文模型

接下来,我们来看看如何在 ChatGPT 中使用官方原本不支持的数据源或模型。比如我们先来折腾一个接入获取难度非常低、支持在线申请免费 API 使用的,智源研究院推出的 Flag Studio 图文大模型。

使用 FlagStudio 的配置文件和使用 OpenAI API 差不多:

version: '3'

services:

  # 能够私有化部署的 ChatGPT Web 客户端
  chatgpt-client:
    image: soulteary/chatgpt
    restart: always
    ports:
      - 8090:8090
    environment:
      # 容器中的服务使用的端口
      APP_PORT: 8090
      # 前端使用的 ChatGPT 客户端域名,需要和 `sparrow` 中的 `WEB_CLIENT_HOSTNAME` 中的设置保持一致
      APP_HOSTNAME: "http://localhost:8090"
      # 客户端使用的服务端地址,如果你使用这个配置文件,可以保持下面的数值,否则需要调整为 `sparrow` 部署的实际地址
      APP_UPSTREAM: "http://sparrow:8091"

  # 开源实现的后端服务
  sparrow:
    image: soulteary/sparrow
    restart: always
    environment:
      # [基础设置]
      # => ChatGPT Web 客户端使用的域名,需要和 `chatgpt-client` 的 `APP_HOSTNAME` 保持一致
      WEB_CLIENT_HOSTNAME: "http://localhost:8090"
      # => 服务端口,默认端口: 8091
      # APP_PORT: 8091

      # [私有实现的 FlagStudio 服务] *可选
      # => 启用 FlagStudio
      ENABLE_FLAGSTUDIO: "on"
      # => 只启用 FlagStudio 数据源
      ENABLE_FLAGSTUDIO_ONLY: "off"
      # => FlagStudio API Key
      # FLAGSTUDIO_API_KEY: "your-flagstudio-api-key", like: `238dc972f6a2ebf15d787aef659cc4d1` (页面上获取)
      FLAGSTUDIO_API_KEY: "填写你自己的 API KEY"

    logging:
        driver: "json-file"
        options:
            max-size: "10m"

先将上面的内容保存为 docker-compose.yml,接着注册一个 FlagStudio 账号,访问官方文档页面获取你自己的 API Key,并将它更新到上面配置中的 FLAGSTUDIO_API_KEY

获取 FlagStudio API Key

每个 API 每天能够调用生成 500 张图,如果生成效果不好,使用 ChatGPT 自带的“Prompt”问题重写、补充连续对话、重新生成按钮都可以重新生成图片。

在 ChatGPT 中使用 FlagStudio

下面我们聊聊,如何封装这样一个简单的数据源,让 ChatGPT 能够输出一些不一样的东西。

封装自定义数据源:Flag Studio

Flag Studio 的数据源封装实现,存放在后端项目 sparrow 的 connectors/flag-studio 里,关键实现代码行数不到 200 行。

参考官方文档,一个完整的 Flag Studio 图片生成流程中,需要根据我们申请的 API Key 去换服务调用所需要的 Token,最后携带 Token 去调用图片生成接口即可。

我们先来实现根据 API Key 换 Token 的逻辑:

package FlagStudio

import (
	"encoding/json"
	"fmt"
	"io"
	"net/http"
)

const API_GET_TOKEN = "https://flagopen.baai.ac.cn/flagStudio/auth/getToken"

type ResponseToken struct {
	Code int `json:"code"`
	Data struct {
		Token string `json:"token"`
	} `json:"data"`
}

// parseToken parses the token from the response body
func parseToken(buf []byte) (string, error) {
	var data ResponseToken
	err := json.Unmarshal(buf, &data)
	if err != nil {
		return "", err
	}
	if data.Code != 200 || data.Data.Token == "" {
		return "", fmt.Errorf("FlagStudio API, Get Token error, Code %d\n, Token: %s", data.Code, data.Data.Token)
	}
	return data.Data.Token, nil
}

// get token from the API
func GetToken(apikey string) (string, error) {
	req, err := http.NewRequest("GET", API_GET_TOKEN, nil)
	if err != nil {
		return "", fmt.Errorf("FlagStudio API, Error initializing network components, err: %v", err)
	}

	req.Header.Set("Accept", "application/json")
	req.Header.Set("Content-Type", "application/json")
	q := req.URL.Query()
	q.Add("apikey", apikey)
	req.URL.RawQuery = q.Encode()

	client := &http.Client{}
	resp, err := client.Do(req)
	if err != nil {
		fmt.Println(err)
		return "", fmt.Errorf("FlagStudio API, Error sending request, err: %v", err)
	}
	defer resp.Body.Close()

	body, err := io.ReadAll(resp.Body)
	if err != nil {
		fmt.Println(err)
		return "", fmt.Errorf("FlagStudio API, Error reading response, err: %v", err)
	}

	token, err := parseToken(body)
	if err != nil {
		fmt.Println(err)
		return "", fmt.Errorf("FlagStudio API, Error parsing response, err: %v", err)
	}
	return token, nil
}

上面的代码中,我们实现了一个非常基础的 HTTP 调用,以及对服务端返回的 JSON 内容的解析,如果 API Key 正确、网络没有异常的情况下,函数运行结束,我们将得到生成图片所需要的 Token。

接下来,我们来实现主要逻辑,图片生成接口调用:

package FlagStudio

import (
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"strings"

	"github.com/soulteary/sparrow/internal/define"
)

type TextToImage struct {
	Prompt          string  `json:"prompt"`
	GuidanceScale   float64 `json:"guidance_scale"`
	Height          int     `json:"height"`
	NegativePrompts string  `json:"negative_prompts"`
	Sampler         string  `json:"sampler"`
	Seed            int     `json:"seed"`
	Steps           int     `json:"steps"`
	Style           string  `json:"style"`
	Upsample        int     `json:"upsample"`
	Width           int     `json:"width"`
}

const API_TEXT_TO_IMAGE = "https://flagopen.baai.ac.cn/flagStudio/v1/text2img"

var FS_STYLES = []string{"国画", "写实主义", "虚幻引擎", "黑白插画", "版绘", "低聚", "工业霓虹", "电影艺术", "史诗大片", "暗黑", "涂鸦", "漫画场景", "特写", "儿童画", "油画", "水彩画", "素描", "卡通画", "浮世绘", "赛博朋克", "吉卜力", "哑光", "现代中式", "相机", "CG渲染", "动漫", "霓虹游戏", "蒸汽波", "宝可梦", "火影忍者", "圣诞老人", "个人特效", "通用漫画", "Momoko", "MJ风格", "剪纸", "齐白石", "张大千", "丰子恺", "毕加索", "梵高", "塞尚", "莫奈", "马克·夏加尔", "丢勒", "米开朗基罗", "高更", "爱德华·蒙克", "托马斯·科尔", "安迪·霍尔", "新海诚", "倪传婧", "村上隆", "黄光剑", "吴冠中", "林风眠", "木内达朗", "萨雷尔", "杜拉克", "比利宾", "布拉德利", "普罗旺森", "莫比乌斯", "格里斯利", "比普", "卡尔·西松", "玛丽·布莱尔", "埃里克·卡尔", "扎哈·哈迪德", "包豪斯", "英格尔斯", "RHADS", "阿泰·盖兰", "俊西", "坎皮恩", "德尚鲍尔", "库沙特", "雷诺阿"}

func GetRandomStyle() string {
	return FS_STYLES[define.GetRandomNumber(0, len(FS_STYLES)-1)]
}

func GenerateImageByText(s string) string {
	data := TextToImage{
		Prompt:          s,
		GuidanceScale:   7.5,
		Width:           512,
		Height:          512,
		NegativePrompts: "",
		Sampler:         "ddim",
		Seed:            1024,
		Steps:           50,
		Style:           GetRandomStyle(),
		Upsample:        1,
	}

	payload, err := define.MakeJSON(data)
	if err != nil {
		return fmt.Sprintf("FlagStudio API, An error occurred while preparing to enter data: %v", err)
	}

	token, err := GetToken(define.FLAGSTUDIO_API_KEY)
	if err != nil {
		return fmt.Sprintf("FlagStudio API, An error occurred while getting the token: %v", err)
	}

	req, err := http.NewRequest("POST", API_TEXT_TO_IMAGE, strings.NewReader(payload))
	if err != nil {
		return fmt.Sprintf("FlagStudio API, An error occurred while initializing network components: %v", err)
	}

	req.Header.Set("Accept", "application/json")
	req.Header.Set("Content-Type", "application/json")
	req.Header.Add("token", token)
	client := &http.Client{}
	resp, err := client.Do(req)
	if err != nil {
		return fmt.Sprintf("FlagStudio API, An error occurred while sending request: %v", err)
	}

	defer resp.Body.Close()
	body, err := io.ReadAll(resp.Body)
	if err != nil {
		return fmt.Sprintf("FlagStudio API, An error occurred while reading response: %v", err)
	}

	base64Image, err := parseTextToImage(body)
	if err != nil {
		return fmt.Sprintf("FlagStudio API, An error occurred while parsing response: %v", err)
	}

	return `![](data:image/png;base64,` + base64Image + `)`
}

type ResponseTextToImage struct {
	Code int    `json:"code"`
	Data string `json:"data"`
	Nsfw int    `json:"nsfw"`
}

// parseToken parses the token from the response body
func parseTextToImage(buf []byte) (string, error) {
	var data ResponseTextToImage
	err := json.Unmarshal(buf, &data)
	if err != nil {
		return "", err
	}
	if data.Code != 200 || data.Data == "" {
		return "", fmt.Errorf("FlagStudio API, Get Result error, Code %d", data.Code)
	}

	if data.Nsfw != 0 {
		return "", fmt.Errorf("FlagStudio API, Get Token error, Code %d\n, NSFW: %d", data.Code, data.Nsfw)
	}

	return data.Data, nil
}

和上面调用 Token 的逻辑类似,不过这里我们需要使用 POST 来发送请求,并携带合适的请求参数。

关于图片风格的定义,这里简单实现了一个随机选取风格,更好的实现是根据用户的 Prompt 内容,自动选择合适的模型风格,如果你感兴趣,可以在项目中提交你的代码实现,让更多的人受惠于此。

好了,上面的代码就是核心实现。但是,为了让实现生效,我们还需要完成一些边边角角的调整。

我们需要先在流式响应组件中components/stream-responser/stream_builder.go,添加一段调用,让服务端在响应请求的时候,能够将用户提交的 Prompt 交给我们刚刚封装好的程序。

package StreamResponser

...

func StreamBuilder(parentMessageID string, conversationID string, modelSlug string, broker *eb.Broker, input string, mode StreamMessageMode) bool {
	...
	switch modelSlug {
	...
	case datatypes.MODEL_FLAGSTUDIO.Slug:
		if define.ENABLE_FLAGSTUDIO {
			sequences = MakeStreamingMessage(FlagStudio.GenerateImageByText(input), modelSlug, conversationID, messageID, mode)
			quickMode = true
		}
	...
	}
...
}

接着,是在程序功能开关中添加一些定义。如果你不需要按需启用,可以不进行实现:

var (
	ENABLE_FLAGSTUDIO      = GetBool("ENABLE_FLAGSTUDIO", false)                       // Enable Flagstudio
	ENABLE_FLAGSTUDIO_ONLY = GetBool("ENABLE_FLAGSTUDIO_ONLY", false)                  // Enable Flagstudio only
	FLAGSTUDIO_API_KEY     = GetSecret("FLAGSTUDIO_API_KEY", "YOUR_FLAGSTUDIO_SECRET") // Flagstudio API Token
)

为了实现多种模型、数据源的切换,我们还需要为每一种数据源进行一些数据预定义。在模型列表目录中创建一个新程序文件internal/datatypes/models.go,在其中添加我们自定义的新数据源:

var MODEL_FLAGSTUDIO = ModelListItem{
	Slug:        "flag-studio",
	MaxTokens:   1000,
	Title:       "FlagStudio",
	Description: "FlagStudio is a text-to-image platform developed by BAAI's z-lab and FlagAI team.\n\nIt supports 18-language text-to-image generation including Chinese and English, and aims to provide advanced AI art creation experience.",
	Tags:        []string{},
	QualitativeProperties: ModelListQualitativeProperties{
		Reasoning:   []int{4, 5},
		Speed:       []int{4, 5},
		Conciseness: []int{3, 5},
	},
}

为了让模型能够被 ChatGPT 正常调用,我们还需要实现模型获取 API 中的一些实现,依旧是创建一个新的程序 internal/api/models/flagstudio.go,定义一个获取我们定义好的模型类型的功能:

package models

import (
	"github.com/soulteary/sparrow/internal/datatypes"
	"github.com/soulteary/sparrow/internal/define"
)

func GetFlagStudioModel() (result []datatypes.ModelListItem) {
	model := datatypes.MODEL_FLAGSTUDIO

	if define.ENABLE_I18N {
		model.Description = "FlagStudio 是由 BAAI 旗下的创新应用实验室和 FlagAI 团队开发的文图生成工具。\n\n支持中英等18语的文图生成,旨在为大家提供先进的AI艺术创作体验。"
	}

	result = append(result, model)
	return result
}

最后,实现完调用函数,我们将调用函数添加到internal/api/models/models.go 中,当 ChatGPT 调用模型列表的时候,就能够访问到我们的新增的模型或者数据源了。

package models

import (
	"net/http"

	"github.com/gin-gonic/gin"
	"github.com/soulteary/sparrow/internal/datatypes"
	"github.com/soulteary/sparrow/internal/define"
)

func GetModels(c *gin.Context) {
...
	if define.ENABLE_FLAGSTUDIO {
		model := GetFlagStudioModel()
		if define.ENABLE_FLAGSTUDIO_ONLY {
			c.JSON(http.StatusOK, datatypes.Models{Models: model})
		}
		modelList = append(modelList, model...)
	}

...
}

目前添加新数据源的体验还不是很好,后续我考虑进行一些优化调整,让添加数据源能够更简单明了一些。当然,后端服务是开源实现,如果你有好的想法,也可以进行开源共建。

最后

关于 “ChatGPT” 还有很多其他的有趣的实现,接下来相关的文章里,我们慢慢展开 :D

–EOF