一个网页AI聊天,图像识别项目,使用到的技术栈有,Gin框架,GORM,rabbitmq,redis,eino框架,Vue框架等
项目地址:https://github.com/youngyangyang04/GopherAI
GopherAI第二版在第一版的基础上扩展了RAG服务,文件上传服务,MCP服务调用工具,TTS语言识别功能,下面进行一一梳理
TTS即Text To Speech,提供了将文本转为语音的功能,在这个项目中将AI回复的文本通过标准化的API调用生成高质量的音频,通过前端轮询的方式获取语言资源,任务完成后语音输出
路由层
在/AI路由组中新增了两个TTS相关接口,分别是创建TTS任务和查询TTS任务
func AIRouter(r *gin.RouterGroup) {
// 聊天相关接口
{
r.GET("/chat/sessions", session.GetUserSessionsByUserName)
r.POST("/chat/send-new-session", session.CreateSessionAndSendMessage)
r.POST("/chat/send", session.ChatSend)
r.POST("/chat/history", session.ChatHistory)
// TTS相关接口
r.POST("/chat/tts", tts.CreateTTSTask)
r.GET("/chat/tts/query", tts.QueryTTSTask)
r.POST("/chat/send-stream-new-session", session.CreateStreamSessionAndSendMessage)
r.POST("/chat/send-stream", session.ChatStreamSend)
}
}
控制器层
首先依旧是定义请求与响应JSON的结构体(注意这里的结构体与后面服务层的不同)
type (
TTSRequest struct {
Text string `json:"text,omitempty"`
}
TTSResponse struct {
TaskID string `json:"task_id,omitempty"`
controller.Response
}
QueryTTSResponse struct {
TaskID string `json:"task_id,omitempty"`
TaskStatus string `json:"task_status,omitempty"`
TaskResult string `json:"task_result,omitempty"`
controller.Response
}
)
创建TTS任务的handler函数
func CreateTTSTask(c *gin.Context) {
tts := NewTTSServices()
req := new(TTSRequest)
res := new(TTSResponse)
if err := c.ShouldBindJSON(req); err != nil {
c.JSON(http.StatusOK, res.CodeOf(code.CodeInvalidParams))
return
}
if req.Text == "" {
c.JSON(http.StatusOK, res.CodeOf(code.CodeInvalidParams))
return
}
// 创建TTS任务并返回任务ID,由前端轮询查询结果
taskID, err := tts.ttsService.CreateTTS(c, req.Text)
if err != nil {
c.JSON(http.StatusOK, res.CodeOf(code.TTSFail))
return
}
res.Success()
res.TaskID = taskID
c.JSON(http.StatusOK, res)
}
查询TTS任务的handler函数
func QueryTTSTask(c *gin.Context) {
tts := NewTTSServices()
res := new(QueryTTSResponse)
taskID := c.Query("task_id")
if taskID == "" {
c.JSON(http.StatusOK, res.CodeOf(code.CodeInvalidParams))
return
}
TTSQueryResponse, err := tts.ttsService.QueryTTSFull(c, taskID)
if err != nil {
log.Println("语音合成失败", err.Error())
c.JSON(http.StatusOK, res.CodeOf(code.TTSFail))
return
}
if len(TTSQueryResponse.TasksInfo) == 0 {
c.JSON(http.StatusOK, res.CodeOf(code.TTSFail))
return
}
res.Success()
res.TaskID = TTSQueryResponse.TasksInfo[0].TaskID
// 检查 TaskResult 是否为 nil,避免空指针异常
if TTSQueryResponse.TasksInfo[0].TaskResult != nil {
res.TaskResult = TTSQueryResponse.TasksInfo[0].TaskResult.SpeechURL
}
res.TaskStatus = TTSQueryResponse.TasksInfo[0].TaskStatus
c.JSON(http.StatusOK, res)
}
服务层
服务层调用了百度的TTS服务API
首先是定义了TTS请求结构体,后序函数中使用
type TTSRequest struct {
Text string `json:"text"`
Format string `json:"format"`
Voice int `json:"voice"`
Lang string `json:"lang"`
Speed int `json:"speed"`
Pitch int `json:"pitch"`
Volume int `json:"volume"`
EnableSubtitle int `json:"enable_subtitle"`
}
一个获取API accesstoken的函数,读取配置文件中的secretkey和apikey使用http请求获取accesstoken,后续函数也会使用
// ------------------ Access Token ------------------
func (s *TTSService) GetAccessToken() string {
conf := config.GetConfig()
url := "https://aip.baidubce.com/oauth/2.0/token"
postData := fmt.Sprintf(
"grant_type=client_credentials&client_id=%s&client_secret=%s",
conf.VoiceServiceConfig.VoiceServiceApiKey,
conf.VoiceServiceConfig.VoiceServiceSecretKey,
)
resp, err := http.Post(url, "application/x-www-form-urlencoded", bytes.NewReader([]byte(postData)))
if err != nil {
log.Println("get token error:", err)
return ""
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
log.Println("read token error:", err)
return ""
}
var tokenResp struct {
AccessToken string `json:"access_token"`
}
if err := json.Unmarshal(body, &tokenResp); err != nil {
log.Println("unmarshal token error:", err)
return ""
}
return tokenResp.AccessToken
}
创建TTS异步任务并返回taskID的函数,获取accesstoken,序列化创建TTS请求,创建http请求并发送,接收响应解析JSON返回百度分配的TaskID
func (s *TTSService) CreateTTS(ctx context.Context, text string) (string, error) {
accessToken := s.GetAccessToken()
if accessToken == "" {
return "", fmt.Errorf("failed to get access token")
}
payload := TTSRequest{
Text: text,
Format: "mp3-16k",
Voice: 4194,
Lang: "zh",
Speed: 5,
Pitch: 5,
Volume: 5,
EnableSubtitle: 0,
}
bodyBytes, err := json.Marshal(payload)
if err != nil {
return "", err
}
url := "https://aip.baidubce.com/rpc/2.0/tts/v1/create?access_token=" + accessToken
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(bodyBytes))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
log.Println("[TTS Create] raw:", string(respBody))
var result TTSCreateResponse
if err := json.Unmarshal(respBody, &result); err != nil {
return "", err
}
if result.TaskID == "" {
return "", fmt.Errorf("create tts failed: empty task_id")
}
return result.TaskID, nil
}
查询TTS状态的函数,查询到success后会解析返回,不然返回的是空的
// QueryTTSFull 查询官方 TTS 状态,解析完整 JSON
func (s *TTSService) QueryTTSFull(ctx context.Context, taskID string) (*TTSQueryResponse, error) {
accessToken := s.GetAccessToken()
if accessToken == "" {
return nil, fmt.Errorf("failed to get access token")
}
reqBody := map[string][]string{
"task_ids": {taskID},
}
bodyBytes, _ := json.Marshal(reqBody)
url := "https://aip.baidubce.com/rpc/2.0/tts/v1/query?access_token=" + accessToken
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(bodyBytes))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
log.Println("[TTS Query] raw:", string(respBody))
// 官方返回原始 JSON
var rawResp struct {
LogID json.Number `json:"log_id"`
TasksInfo []struct {
TaskID string `json:"task_id"`
TaskStatus string `json:"task_status"`
TaskResult json.RawMessage `json:"task_result,omitempty"`
} `json:"tasks_info"`
}
if err := json.Unmarshal(respBody, &rawResp); err != nil {
return nil, err
}
result := &TTSQueryResponse{
LogID: rawResp.LogID.String(),
TasksInfo: make([]TTSTask, 0, len(rawResp.TasksInfo)),
}
for _, t := range rawResp.TasksInfo {
task := TTSTask{
TaskID: t.TaskID,
TaskStatus: t.TaskStatus,
TaskResult: nil, // 默认 nil
}
if t.TaskStatus == "Success" && len(t.TaskResult) > 0 {
var r TTSTaskResult
if err := json.Unmarshal(t.TaskResult, &r); err != nil {
log.Println("parse task_result error:", err)
return nil, fmt.Errorf("failed to parse task result: %v", err)
}
task.TaskResult = &r
}
result.TasksInfo = append(result.TasksInfo, task)
}
return result, nil
}
Vue前端
前端部分采用轮询,后端返回的JSON中如果是success就播放音频
const playTTS = async (text) => {
try {
// 创建TTS任务
const createResponse = await api.post('/AI/chat/tts', { text })
if (createResponse.data && createResponse.data.status_code === 1000 && createResponse.data.task_id) {
const taskId = createResponse.data.task_id
// 先等待5秒钟再开始轮询
await new Promise(resolve => setTimeout(resolve, 5000))
// 轮询查询任务结果
const maxAttempts = 30
const pollInterval = 2000
let attempts = 0
const pollResult = async () => {
const queryResponse = await api.get('/AI/chat/tts/query', { params: { task_id: taskId } })
if (queryResponse.data && queryResponse.data.status_code === 1000) {
const taskStatus = queryResponse.data.task_status
if (taskStatus === 'Success' && queryResponse.data.task_result) {
// 任务完成,播放音频
// 后端返回的 task_result 是直接的 URL 字符串
const audio = new Audio(queryResponse.data.task_result)
audio.play()
return true
} else if (taskStatus === 'Running' ||taskStatus === 'Created' ) {
// 任务进行中,继续轮询
attempts++
if (attempts < maxAttempts) {
await new Promise(resolve => setTimeout(resolve, pollInterval))
return await pollResult()
} else {
ElMessage.error('语音合成超时')
return true
}
} else {
// 其他状态(如失败)
ElMessage.error('语音合成失败')
return true
}
}
attempts++
if (attempts < maxAttempts) {
await new Promise(resolve => setTimeout(resolve, pollInterval))
return await pollResult()
} else {
ElMessage.error('语音合成超时')
return true
}
}
await pollResult()
} else {
ElMessage.error('无法创建语音合成任务')
}
} catch (error) {
console.error('TTS error:', error)
ElMessage.error('请求语音接口失败')
}
} 

