From 2102e1afbb842e3939ab9c1ea6aa29cc2ac93a5c Mon Sep 17 00:00:00 2001 From: RockYang Date: Wed, 16 Oct 2024 18:16:09 +0800 Subject: [PATCH] add websocket relayer for openai realtime api --- api/core/app_server.go | 1 + api/handler/realtime_handler.go | 173 +++++++ api/main.go | 4 + database/update-v4.1.6.sql | 1 + web/src/assets/css/realtime.styl | 196 ++++++++ web/src/components/Conversation .vue | 161 ------ web/src/components/RealtimeConversation .vue | 357 ++++++++++++++ web/src/components/ui/RippleButton.vue | 102 ++++ web/src/router.js | 2 +- web/src/views/RealtimeTest.vue | 471 ++++++++++++++++++ web/src/views/Test.vue | 491 ++----------------- web/src/views/Test3.vue | 2 +- 12 files changed, 1337 insertions(+), 624 deletions(-) create mode 100644 api/handler/realtime_handler.go create mode 100644 database/update-v4.1.6.sql create mode 100644 web/src/assets/css/realtime.styl delete mode 100644 web/src/components/Conversation .vue create mode 100644 web/src/components/RealtimeConversation .vue create mode 100644 web/src/components/ui/RippleButton.vue create mode 100644 web/src/views/RealtimeTest.vue diff --git a/api/core/app_server.go b/api/core/app_server.go index 95cf680d..766290b8 100644 --- a/api/core/app_server.go +++ b/api/core/app_server.go @@ -221,6 +221,7 @@ func needLogin(c *gin.Context) bool { c.Request.URL.Path == "/api/suno/detail" || c.Request.URL.Path == "/api/suno/play" || c.Request.URL.Path == "/api/download" || + c.Request.URL.Path == "/api/realtime" || strings.HasPrefix(c.Request.URL.Path, "/api/test") || strings.HasPrefix(c.Request.URL.Path, "/api/payment/notify/") || strings.HasPrefix(c.Request.URL.Path, "/api/user/clogin") || diff --git a/api/handler/realtime_handler.go b/api/handler/realtime_handler.go new file mode 100644 index 00000000..0d9a7e51 --- /dev/null +++ b/api/handler/realtime_handler.go @@ -0,0 +1,173 @@ +package handler + +import ( + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" + "log" + "net/http" +) + +// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +// * Copyright 2023 The Geek-AI Authors. All rights reserved. +// * Use of this source code is governed by a Apache-2.0 license +// * that can be found in the LICENSE file. +// * @Author yangjian102621@163.com +// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + +// 实时 API 中继器 + +type RealtimeHandler struct { + BaseHandler +} + +func NewRealtimeHandler() *RealtimeHandler { + return &RealtimeHandler{} +} + +func (h *RealtimeHandler) Connection(c *gin.Context) { + // 获取客户端请求中指定的子协议 + clientProtocols := c.GetHeader("Sec-WebSocket-Protocol") + logger.Info(clientProtocols) + + // 升级HTTP连接为WebSocket,并传入客户端请求的子协议 + upgrader := websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { return true }, + Subprotocols: []string{clientProtocols}, + } + + ws, err := upgrader.Upgrade(c.Writer, c.Request, nil) + if err != nil { + logger.Error(err) + c.Abort() + return + } + defer ws.Close() + + // 连接到真实的后端服务器,传入相同的子协议 + headers := http.Header{} + if clientProtocols != "" { + headers.Set("Sec-WebSocket-Protocol", clientProtocols) + } + for key, values := range headers { + for _, value := range values { + logger.Infof("%s: %s", key, value) + } + } + backendConn, _, err := websocket.DefaultDialer.Dial("wss://api.geekai.pro/v1/realtime?model=gpt-4o-realtime-preview-2024-10-01", headers) + if err != nil { + log.Printf("Failed to connect to backend: %v", err) + return + } + defer backendConn.Close() + + //logger.Info(ws.Subprotocol(), ",", backendConn.Subprotocol()) + //// 确保协议一致性,如果失败返回 + //if ws.Subprotocol() != backendConn.Subprotocol() { + // log.Println("Subprotocol mismatch") + // return + //} + + // 开始双向转发 + errorChan := make(chan error, 2) + go relay(ws, backendConn, errorChan) + go relay(backendConn, ws, errorChan) + + // 等待其中一个连接关闭 + <-errorChan + log.Println("Relay ended") +} + +func relay(src, dst *websocket.Conn, errorChan chan error) { + for { + messageType, message, err := src.ReadMessage() + if err != nil { + errorChan <- err + return + } + err = dst.WriteMessage(messageType, message) + if err != nil { + errorChan <- err + return + } + } +} + +//func (h *RealtimeHandler) handleMessage(client *RealtimeClient, message []byte) { +// var event Event +// err := json.Unmarshal(message, &event) +// if err != nil { +// logger.Infof("Error parsing event from client: %s", message) +// return +// } +// logger.Infof("Relaying %q to OpenAI", event.Type) +// client.Send(event) +//} +// +//func relay(src, dst *websocket.Conn, errorChan chan error) { +// for { +// messageType, message, err := src.ReadMessage() +// if err != nil { +// errorChan <- err +// return +// } +// err = dst.WriteMessage(messageType, message) +// if err != nil { +// errorChan <- err +// return +// } +// } +//} +// +//func NewRealtimeClient(apiKey string) *RealtimeClient { +// return &RealtimeClient{ +// APIKey: apiKey, +// send: make(chan Event, 100), +// } +//} +// +//func (rc *RealtimeClient) Connect() error { +// u := url.URL{Scheme: "wss", Host: "api.geekai.pro", Path: "v1/realtime", RawQuery: "model=gpt-4o-realtime-preview-2024-10-01"} +// c, _, err := websocket.DefaultDialer.Dial(u.String(), nil) +// if err != nil { +// return err +// } +// rc.conn = c +// +// go rc.readPump() +// go rc.writePump() +// +// return nil +//} +// +//func (rc *RealtimeClient) readPump() { +// defer rc.conn.Close() +// for { +// _, message, err := rc.conn.ReadMessage() +// if err != nil { +// log.Println("read error:", err) +// return +// } +// var event Event +// err = json.Unmarshal(message, &event) +// if err != nil { +// log.Println("parse error:", err) +// continue +// } +// rc.send <- event +// } +//} +// +//func (rc *RealtimeClient) writePump() { +// defer rc.conn.Close() +// for event := range rc.send { +// err := rc.conn.WriteJSON(event) +// if err != nil { +// log.Println("write error:", err) +// return +// } +// } +//} +// +//func (rc *RealtimeClient) Send(event Event) { +// rc.send <- event +//} diff --git a/api/main.go b/api/main.go index bb2a57e8..34d01a0e 100644 --- a/api/main.go +++ b/api/main.go @@ -554,6 +554,10 @@ func main() { group.POST("/list/luma", h.LumaList) group.GET("/remove", h.Remove) }), + fx.Provide(handler.NewRealtimeHandler), + fx.Invoke(func(s *core.AppServer, h *handler.RealtimeHandler) { + s.Engine.Any("/api/realtime", h.Connection) + }), ) // 启动应用程序 go func() { diff --git a/database/update-v4.1.6.sql b/database/update-v4.1.6.sql new file mode 100644 index 00000000..ab941925 --- /dev/null +++ b/database/update-v4.1.6.sql @@ -0,0 +1 @@ +ALTER TABLE `chatgpt_chat_models` CHANGE `value` `value` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '模型值'; \ No newline at end of file diff --git a/web/src/assets/css/realtime.styl b/web/src/assets/css/realtime.styl new file mode 100644 index 00000000..78903246 --- /dev/null +++ b/web/src/assets/css/realtime.styl @@ -0,0 +1,196 @@ +.realtime-conversation { + /********************** connection ****************************/ + .connection-container { + background-color: #000; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + margin: 0; + overflow: hidden; + font-family: Arial, sans-serif; + width 100vw + + .phone-container { + position: relative; + width: 200px; + height: 200px; + } + + .phone { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 60px; + height: 60px; + background-color: #00ffcc; + mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M20 15.5c-1.25 0-2.45-.2-3.57-.57a1.02 1.02 0 0 0-1.02.24l-2.2 2.2a15.074 15.074 0 0 1-6.59-6.59l2.2-2.2c.27-.27.35-.68.24-1.02a11.36 11.36 0 0 1-.57-3.57c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1 0 9.39 7.61 17 17 17 .55 0 1-.45 1-1v-3.5c0-.55-.45-1-1-1zM5.03 5h1.5c.07.89.22 1.76.46 2.59l-1.2 1.2c-.41-1.2-.67-2.47-.76-3.79zM19 18.97c-1.32-.09-2.59-.35-3.8-.75l1.2-1.2c.85.24 1.72.39 2.6.45v1.5z'/%3E%3C/svg%3E") no-repeat 50% 50%; + mask-size: cover; + -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M20 15.5c-1.25 0-2.45-.2-3.57-.57a1.02 1.02 0 0 0-1.02.24l-2.2 2.2a15.074 15.074 0 0 1-6.59-6.59l2.2-2.2c.27-.27.35-.68.24-1.02a11.36 11.36 0 0 1-.57-3.57c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1 0 9.39 7.61 17 17 17 .55 0 1-.45 1-1v-3.5c0-.55-.45-1-1-1zM5.03 5h1.5c.07.89.22 1.76.46 2.59l-1.2 1.2c-.41-1.2-.67-2.47-.76-3.79zM19 18.97c-1.32-.09-2.59-.35-3.8-.75l1.2-1.2c.85.24 1.72.39 2.6.45v1.5z'/%3E%3C/svg%3E") no-repeat 50% 50%; + -webkit-mask-size: cover; + animation: shake 0.5s ease-in-out infinite; + } + + .signal { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 100px; + height: 100px; + border: 2px dashed #00ffcc; + border-radius: 50%; + opacity: 0; + animation: signal 2s linear infinite; + } + + .signal:nth-child(2) { + animation-delay: 0.5s; + } + + .signal:nth-child(3) { + animation-delay: 1s; + } + + .status-text { + color: #00ffcc; + font-size: 18px; + margin-top: 20px; + height: 1.2em; + overflow: hidden; + } + + @keyframes shake { + 0%, 100% { transform: translate(-50%, -50%) rotate(0deg); } + 25% { transform: translate(-52%, -48%) rotate(-5deg); } + 75% { transform: translate(-48%, -52%) rotate(5deg); } + } + + @keyframes signal { + 0% { + width: 60px; + height: 60px; + opacity: 1; + } + 100% { + width: 200px; + height: 200px; + opacity: 0; + } + } + } + /*********** end of connection ************/ + + .conversation-container { + background: linear-gradient(to right, #2c3e50, #4a5568, #6b46c1); + display: flex; + height 100% + flex-direction: column; + justify-content: space-between; + align-items: center; + padding: 0; + width 100vw + + .wave-container { + padding 3rem + .wave-animation { + display: flex; + justify-content: center; + align-items: center; + gap: 10px; + + .wave-ellipse { + width: 40px; + height: 40px; + background-color: white; + border-radius: 20px; + animation: wave 0.8s infinite ease-in-out; + } + + .wave-ellipse:nth-child(odd) { + height: 60px; + } + + .wave-ellipse:nth-child(even) { + height: 80px; + } + } + } + + @keyframes wave { + 0%, 100% { + transform: scaleY(0.8); + } + 50% { + transform: scaleY(1.2); + } + } + + .wave-ellipse:nth-child(2) { + animation-delay: 0.1s; + } + + .wave-ellipse:nth-child(3) { + animation-delay: 0.2s; + } + + .wave-ellipse:nth-child(4) { + animation-delay: 0.3s; + } + + .wave-ellipse:nth-child(5) { + animation-delay: 0.4s; + } + + .voice-indicators { + display flex + flex-flow row + justify-content: space-between; + width 100% + + .left { + margin-left 3rem + } + .right { + margin-right 3rem + } + } + + .call-controls { + display: flex; + justify-content: center; + gap: 3rem; + padding 3rem + + .call-button { + width: 60px; + height: 60px; + border-radius: 50%; + border: none; + display: flex; + justify-content: center; + align-items: center; + font-size: 24px; + color: white; + cursor: pointer; + + .iconfont { + font-size 24px + } + } + .hangup { + background-color: #e74c3c; + } + + .answer { + background-color: #2ecc71; + } + + .icon { + font-size: 28px; + } + } + + } +} \ No newline at end of file diff --git a/web/src/components/Conversation .vue b/web/src/components/Conversation .vue deleted file mode 100644 index 8b8e0736..00000000 --- a/web/src/components/Conversation .vue +++ /dev/null @@ -1,161 +0,0 @@ - - - - - \ No newline at end of file diff --git a/web/src/components/RealtimeConversation .vue b/web/src/components/RealtimeConversation .vue new file mode 100644 index 00000000..8ffd4147 --- /dev/null +++ b/web/src/components/RealtimeConversation .vue @@ -0,0 +1,357 @@ + + + + + \ No newline at end of file diff --git a/web/src/components/ui/RippleButton.vue b/web/src/components/ui/RippleButton.vue new file mode 100644 index 00000000..c21b5985 --- /dev/null +++ b/web/src/components/ui/RippleButton.vue @@ -0,0 +1,102 @@ + + + + + \ No newline at end of file diff --git a/web/src/router.js b/web/src/router.js index 464a9992..177240c7 100644 --- a/web/src/router.js +++ b/web/src/router.js @@ -328,7 +328,7 @@ const routes = [ name: 'test2', path: '/test2', meta: {title: '测试页面'}, - component: () => import('@/views/Test2.vue'), + component: () => import('@/views/RealtimeTest.vue'), }, { name: 'NotFound', diff --git a/web/src/views/RealtimeTest.vue b/web/src/views/RealtimeTest.vue new file mode 100644 index 00000000..7d7bfedc --- /dev/null +++ b/web/src/views/RealtimeTest.vue @@ -0,0 +1,471 @@ + + + + + \ No newline at end of file diff --git a/web/src/views/Test.vue b/web/src/views/Test.vue index 7d7bfedc..7ac7bc62 100644 --- a/web/src/views/Test.vue +++ b/web/src/views/Test.vue @@ -1,471 +1,40 @@ - \ No newline at end of file diff --git a/web/src/views/Test3.vue b/web/src/views/Test3.vue index da2492a2..5b7c1f84 100644 --- a/web/src/views/Test3.vue +++ b/web/src/views/Test3.vue @@ -15,7 +15,7 @@ import {ref} from 'vue'; import { RealtimeClient } from '@openai/realtime-api-beta'; import Calling from "@/components/Calling.vue"; -import Conversation from "@/components/Conversation .vue"; +import Conversation from "@/components/RealtimeConversation .vue"; import {playPCM16} from "@/utils/wav_player"; import {showMessageError} from "@/utils/dialog";