You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

176 lines
5.1 KiB

package migu
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"yunyan/lego/sys/log"
"yunyan/sys/migu"
"github.com/google/uuid"
)
// handleChat 处理普通聊天接口 POST /chat(支持流式 SSE 与非流式 JSON)
//
// @Summary 咪咕灵犀聊天
// @Description 普通聊天接口,内部对接咪咕灵犀。
// @Description stream=false:返回完整 JSON(ChatResponse),结构化音乐卡片在 cards 字段;
// @Description stream=true:返回 SSE 流,每帧一个 ChatChunk(音乐卡片在 card 字段),结束以 data: [DONE]。
// @Description 鉴权:服务端配置了 ApiKey 时需带请求头 Authorization: Bearer <key>。
// @Tags Migu
// @Accept json
// @Produce json
// @Produce text/event-stream
// @Param body body ChatRequest true "聊天请求"
// @Success 200 {object} ChatResponse "非流式完整响应;流式时为 SSE 流(data: {ChatChunk} ... data: [DONE])"
// @Failure 400 {object} map[string]string "参数错误"
// @Failure 401 {object} map[string]string "鉴权失败"
// @Router /chat [post]
func (this *Migu) handleChat(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
// 入站鉴权(配置了 ApiKey 才校验)
if this.options.ApiKey != "" {
token := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer"))
if token != this.options.ApiKey {
writeError(w, http.StatusUnauthorized, "invalid api key")
return
}
}
body, err := io.ReadAll(r.Body)
if err != nil {
writeError(w, http.StatusBadRequest, "read body failed: "+err.Error())
return
}
var req ChatRequest
if err = json.Unmarshal(body, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid json: "+err.Error())
return
}
if strings.TrimSpace(req.Message) == "" {
writeError(w, http.StatusBadRequest, "message is required")
return
}
// deviceId / sessionId 来源:请求体优先,其次请求头
deviceId := req.DeviceId
if deviceId == "" {
deviceId = r.Header.Get("X-Device-Id")
}
sessionId := req.SessionId
if sessionId == "" {
sessionId = r.Header.Get("X-Session-Id")
}
miguReq := buildMiguRequest(&req, deviceId, sessionId)
// 统一从咪咕流式拉取
ch := make(chan *migu.StreamResp, 8)
go func() {
if e := migu.Chat(context.Background(), miguReq, ch); e != nil {
log.Errorf("migu lingxi chat err: %v", e)
}
}()
if req.Stream {
this.streamResponse(w, r, miguReq, ch)
} else {
this.jsonResponse(w, miguReq, ch)
}
}
// buildMiguRequest 把聊天请求转换成咪咕灵犀请求
func buildMiguRequest(req *ChatRequest, deviceId, sessionId string) *migu.Request {
mr := &migu.Request{
ReqId: uuid.NewString(),
DeviceId: deviceId,
Messages: []migu.Message{{Role: "user", Content: req.Message}},
Stream: true,
}
if len(req.History) > 0 {
history := make([]migu.Message, 0, len(req.History))
for _, m := range req.History {
history = append(history, migu.Message{Role: m.Role, Content: m.Content})
}
mr.History = history
}
// 咪咕要求 session 必填:未携带 sessionId 时用 reqId 兜底,保证字段存在
if sessionId == "" {
sessionId = mr.ReqId
}
mr.Session = &migu.Session{SessionId: sessionId}
return mr
}
// streamResponse 把咪咕流式数据逐帧转换为 SSE(data: {ChatChunk})写回客户端
func (this *Migu) streamResponse(w http.ResponseWriter, r *http.Request, mr *migu.Request, ch chan *migu.StreamResp) {
flusher, ok := w.(http.Flusher)
if !ok {
writeError(w, http.StatusInternalServerError, "streaming unsupported")
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no")
w.WriteHeader(http.StatusOK)
ctx := r.Context()
for {
select {
case <-ctx.Done():
return
case sr, alive := <-ch:
if !alive {
fmt.Fprint(w, "data: [DONE]\n\n")
flusher.Flush()
return
}
chunk := convertChunk(sr)
if chunk == nil {
continue
}
data, _ := json.Marshal(chunk)
fmt.Fprintf(w, "data: %s\n\n", data)
flusher.Flush()
}
}
}
// jsonResponse 累积咪咕流式数据,聚合为一个完整的结构化响应
func (this *Migu) jsonResponse(w http.ResponseWriter, mr *migu.Request, ch chan *migu.StreamResp) {
resp := ChatResponse{
ReqId: mr.ReqId,
SessionId: mr.Session.SessionId,
}
var contentBuf, reasoningBuf strings.Builder
for sr := range ch {
chunk := convertChunk(sr)
if chunk == nil {
continue
}
reasoningBuf.WriteString(chunk.Reasoning)
contentBuf.WriteString(chunk.Content)
if chunk.Card != nil {
resp.Cards = append(resp.Cards, *chunk.Card)
}
resp.RecommendPrompts = append(resp.RecommendPrompts, chunk.RecommendPrompts...)
if chunk.FinishReason != "" {
resp.FinishReason = chunk.FinishReason
}
}
resp.Reasoning = reasoningBuf.String()
resp.Content = contentBuf.String()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(resp)
}