给FoloToy ChatGPT火火兔加点小功能

2023 Oct 26 See all posts


前情

家里有娃的基本上都知道火火兔,OpenAI的ChatGPT更不用说,已经火了一年多了。这两个结合起来会怎么样?还真有人做了:Folotoy是最近很好玩的一个可以玩ChatGPT的火火兔智能硬件,9月27日从他们的B站店铺七里台技工的工房购买了成品兔子,10月13号兔子终于到货。

用火火兔试玩了一天他们免费提供的测试服务器后,开始自己搭建服务器。值得一提的是,在这搭建的过程中,碰到问题就问Folotoy的小伙子们,他们解答问题非常专业,热情满满,而且固件和服务程序都不断更新,真是一群朝气蓬勃的人,给他们点赞!

ChatGPT使用起来对国外群众来说可能跟呼吸一样容易,但是对国内群众,就没有这么简单了:

简单说来,Folotoy火火兔对话的原理是是三个步骤: - 1.asr语音识别为文字 - 2.文字给chatGPT,得到文字答案 - 3.文字转语音生成azure tts,

目前跟ChatGPT API的交互还是文字,如果以后OpenAI能放开multimodal input把1,2一步到位,或者把2,3一部到位也不是不可能。

上面的每个步骤的解决方案: - 步骤1目前主要用的是OpenAI 的 Whisper - 步骤2就体现出oneapi代理的厉害之处了,可以将所有大模型厂家的api,管你是Anthropic Claude, Google PaLM2,还是百度文心一言,智谱 ChatGLM统统都能转成和OpenAI兼容,功德无量 - 步骤3的可选更多,其中azure tts算是非常不错的。

小需求

Folotoy火火兔的定位应该是给孩子玩的,没有屏幕(没屏幕是对的,有屏幕那不如用手机app了不是,麦克风还更好),其实用下来,发现大人比孩子更喜欢用,家里小朋友们还是在户外疯玩或者看动画片是最爱,可能大一点的小孩更喜欢用吧。

即便如此,大人还是希望能看到字幕,首先网络延迟还是有的,等的过程看看东西也不错,二来用它来学英语的话精益求精有字幕也算一个有用的选项。

实现

官方实现的话,可能会从mqtt服务器消息入手,把对话发布出去,谁愿意订阅谁订阅,但是他们现在肯定没有时间,他们应该有最高优先级的新功能开发,另外每天在群里的各种问题也够他们忙的了。

自己实现的话,只能从服务器日志入手,这样也好,解耦:

后端

代码很简单,用Go实现,一会就写完了:

package main

import (
    "errors"
    "fmt"
    "github.com/hpcloud/tail"
    "gopkg.in/antage/eventsource.v1"
    "log"
    http "net/http"
    "os/exec"
    "strings"
    "time"
)

func extractContent(str, start, end string) string {
    startIndex := strings.Index(str, start)
    if startIndex == -1 {
        return ""
    }

    endIndex := strings.Index(str, end)
    if endIndex == -1 {
        return ""
    }

    content := str[startIndex+len(start) : endIndex]
    return strings.TrimSpace(content)
}

func getDockerLogFile(imageName string) (string, string, error) {

    // 执行 docker ps 命令获取容器 ID
    cmd := exec.Command("docker", "ps", "-qf", "ancestor="+imageName)
    output, err := cmd.Output()
    if err != nil {
        s := fmt.Sprintf("Failed to execute docker ps command: %s", err)
        return "", "", errors.New(s)
    }

    // 检查容器是否存在
    containerID := strings.TrimSpace(string(output))
    if containerID == "" {
        s := fmt.Sprintf("No container found for image: %s", imageName)
        return "", "", errors.New(s)
    }

    // 执行 docker inspect 命令获取容器日志文件位置
    cmd = exec.Command("docker", "inspect", "--format={{.LogPath}}", containerID)
    output, err = cmd.Output()
    if err != nil {
        s := fmt.Sprintf("Failed to execute docker inspect command: %s", err)
        return "", "", errors.New(s)
    }

    // 检查日志文件是否存在
    logFile := strings.TrimSpace(string(output))
    if logFile == "" {
        s := fmt.Sprintf("Log file not found for container: %s", containerID)
        return "", "", errors.New(s)
    }
    return logFile, containerID, nil
}

func handleFoloLog(mux *http.ServeMux) {
    logFilePath, _, err := getDockerLogFile("lewangdev/folotoy-server:latest")
    if err != nil {
        log.Fatalf(err.Error())
        return
    }

    es := eventsource.New(nil, nil)
    defer es.Close()
    mux.Handle("/events", es)
    config := tail.Config{
        ReOpen:    true,                                 // 重新打开
        Follow:    true,                                 // 是否跟随
        Location:  &tail.SeekInfo{Offset: 0, Whence: 2}, // 从文件的哪个地方开始读
        MustExist: false,                                // 文件不存在不报错
        Poll:      true,
    }
    tails, err := tail.TailFile(logFilePath, config)
    if err != nil {
        fmt.Println("tail file failed, err:", err)
        return
    }
    var (
        line *tail.Line
        ok   bool
    )
    for {
        line, ok = <-tails.Lines
        if !ok {
            fmt.Printf("tail file close reopen, filename:%s\n", tails.Filename)
            time.Sleep(time.Second)
            continue
        }

        if strings.Contains(line.Text, "Transcribed:") {
            content := extractContent(line.Text, "Transcribed:", "\",\"stream\"")
            es.SendEventMessage("[B]:"+content, "", "")
        }
        if strings.Contains(line.Text, "Speech synthesized for text [") {
            content := extractContent(line.Text, "Speech synthesized for text [", "]")
            es.SendEventMessage("[A]:"+content, "", "")
        }
    }

}

func main() {
    mux := http.NewServeMux()
    mux.Handle("/", http.FileServer(http.Dir("./public")))
    go func() {
        handleFoloLog(mux)
    }()
    go func() {
        http_err := http.ListenAndServe("127.0.0.1:9999", mux)
        if http_err != nil {
            log.Println(http_err)
        }
    }()
    <-make(chan struct{})
}

服务器配置

Nginx配置一个子域名,简单做一个权限控制,并加上对SSE的支持:

server {
        server_name dialogs.xxx.com;
        listen 443 ssl;
        charset UTF-8; 
        
        auth_basic "closed site";
        auth_basic_user_file some_passwd.txt;

        proxy_set_header Connection '';
        proxy_http_version 1.1;
        chunked_transfer_encoding off;
        proxy_buffering off;
        proxy_cache off;

        location / {
            proxy_pass  http://127.0.0.1:9999;
        }

前端

前端更简单,主流浏览器都支持SSE,页面不用手动刷新新消息就出来了,用户体验一流:

<!DOCTYPE html>
<html>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<meta http-equiv="x-ua-compatible" content="ie=edge"/>
<link rel="icon" href="data:,">
<head>
<title>Folotoy Dialogs</title>
    <script type="text/javascript">
        window.addEventListener("DOMContentLoaded", function () {
            var evsrc = new EventSource("/events");
            evsrc.onmessage = function (ev) {
                document.getElementById("log")
                    .insertAdjacentHTML("beforeend", "<li>" + ev.data + "</li>");
            }
            evsrc.onerror = function (ev) {
                console.log("readyState = " + ev.currentTarget.readyState);
            }
        })
    </script>
</head>
<body>
<h1>Dialogs:</h1>
<div>
    <ul id="log">
    </ul>
</div>
</body>
</html>

效果

娃在家里跟火火兔说话的时候,大人无论何时何地,用电脑或手机上打开这个网页,就可以实时看到对话的文字版,体验非常棒。

Back to top