本篇文章中,我们简单聊聊如何在 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 服务了。
如果你希望将服务搭建在其他的机器上,只需要调整上面配置中的两个环境变量即可(比如 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
是不是非常简单?当然,这个仅仅是个 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
。
每个 API 每天能够调用生成 500 张图,如果生成效果不好,使用 ChatGPT 自带的“Prompt”问题重写、补充连续对话、重新生成按钮都可以重新生成图片。
下面我们聊聊,如何封装这样一个简单的数据源,让 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