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
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)
|
|
}
|
|
|