refactor: 认证系统重构 + DevTools CLI 重写 + 文档全面更新
- auth: Login 简化为管理员始终通过 .env 验证,GetProfile 修正 admin DB 查询 - devtools: .sh/.bat 同步重写为完整 CLI (start/stop/status/logs/build/db:*) - docs: 新增 devtools.md,重写 Deploy.md (三种方式+Windows说明),更新 README/gateway-api - voice-service: DashScope 实时流式 STT 支持 - gateway: Phase 6 多模型配置 + 多端客户端管理 + WebSocket 增强 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -4,11 +4,12 @@ import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/yourname/cyrene-ai/pkg/logger"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -19,6 +20,7 @@ import (
|
||||
"github.com/yourname/cyrene-ai/gateway/internal/config"
|
||||
"github.com/yourname/cyrene-ai/gateway/internal/store"
|
||||
"github.com/yourname/cyrene-ai/gateway/internal/ws"
|
||||
"github.com/yourname/cyrene-ai/pkg/logger"
|
||||
)
|
||||
|
||||
// ChatHandler 聊天处理器
|
||||
@@ -422,6 +424,8 @@ func (h *ChatHandler) streamResponse(client *ws.Client, mode string, reqBody []b
|
||||
Type: "stream_end",
|
||||
MessageID: msgID,
|
||||
SessionID: client.SessionID,
|
||||
Content: fullText,
|
||||
Text: fullText,
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
})
|
||||
|
||||
@@ -458,14 +462,145 @@ func (h *ChatHandler) streamResponse(client *ws.Client, mode string, reqBody []b
|
||||
|
||||
// handleVoiceInput 处理语音输入
|
||||
func (h *ChatHandler) handleVoiceInput(client *ws.Client, msg ws.ClientMessage) {
|
||||
// MVP阶段:返回提示
|
||||
response := ws.ServerMessage{
|
||||
Type: "error",
|
||||
MessageID: "msg_" + generateID(),
|
||||
Error: "语音处理功能将在后续版本中启用",
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
audioB64 := msg.AudioData
|
||||
if audioB64 == "" {
|
||||
client.SendMessage(ws.ServerMessage{
|
||||
Type: "error",
|
||||
MessageID: "msg_" + generateID(),
|
||||
Error: "语音数据为空",
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
})
|
||||
return
|
||||
}
|
||||
client.SendMessage(response)
|
||||
|
||||
format := msg.Mode
|
||||
if format == "" {
|
||||
format = "webm"
|
||||
}
|
||||
|
||||
// 在 goroutine 中处理转录,避免阻塞 ReadPump
|
||||
go func() {
|
||||
text, err := h.transcribeAudio(audioB64, format)
|
||||
if err != nil {
|
||||
logger.Printf("[voice] 转录失败: %v", err)
|
||||
client.SendMessage(ws.ServerMessage{
|
||||
Type: "voice_transcript",
|
||||
MessageID: "msg_" + generateID(),
|
||||
Error: fmt.Sprintf("语音识别失败: %v", err),
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if text == "" {
|
||||
client.SendMessage(ws.ServerMessage{
|
||||
Type: "voice_transcript",
|
||||
MessageID: "msg_" + generateID(),
|
||||
Text: "",
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 发送转录结果给前端
|
||||
client.SendMessage(ws.ServerMessage{
|
||||
Type: "voice_transcript",
|
||||
MessageID: "msg_" + generateID(),
|
||||
Text: text,
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
})
|
||||
|
||||
// 将转录文本作为聊天消息处理
|
||||
chatMsg := ws.ClientMessage{
|
||||
Type: "message",
|
||||
Content: text,
|
||||
Mode: msg.Mode,
|
||||
}
|
||||
h.handleChatMessage(client, chatMsg)
|
||||
}()
|
||||
}
|
||||
|
||||
// transcribeAudio 将 base64 编码的音频发送到 voice-service 进行转录。
|
||||
func (h *ChatHandler) transcribeAudio(audioB64 string, format string) (string, error) {
|
||||
audioData, err := decodeBase64(audioB64)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("解码音频数据失败: %w", err)
|
||||
}
|
||||
|
||||
// 构建 multipart form
|
||||
var buf bytes.Buffer
|
||||
mw := multipart.NewWriter(&buf)
|
||||
|
||||
ext := ".webm"
|
||||
switch format {
|
||||
case "wav", "wave":
|
||||
ext = ".wav"
|
||||
case "mp3", "mpeg":
|
||||
ext = ".mp3"
|
||||
case "ogg", "opus":
|
||||
ext = ".ogg"
|
||||
case "pcm":
|
||||
ext = ".pcm"
|
||||
}
|
||||
|
||||
fw, err := mw.CreateFormFile("audio", "recording"+ext)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("创建表单字段失败: %w", err)
|
||||
}
|
||||
if _, err := fw.Write(audioData); err != nil {
|
||||
return "", fmt.Errorf("写入音频数据失败: %w", err)
|
||||
}
|
||||
mw.Close()
|
||||
|
||||
voiceURL := h.cfg.VoiceServiceURL
|
||||
if voiceURL == "" {
|
||||
voiceURL = "http://localhost:8093"
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequest("POST", voiceURL+"/api/v1/transcribe", &buf)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("创建请求失败: %w", err)
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
|
||||
httpClient := &http.Client{Timeout: 60 * time.Second}
|
||||
resp, err := httpClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("voice-service 调用失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("读取响应失败: %w", err)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Success bool `json:"success"`
|
||||
Text string `json:"text"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||
return "", fmt.Errorf("解析响应失败: %w", err)
|
||||
}
|
||||
|
||||
if !result.Success {
|
||||
if result.Error != "" {
|
||||
return "", fmt.Errorf("%s", result.Error)
|
||||
}
|
||||
return "", fmt.Errorf("转录返回空结果")
|
||||
}
|
||||
|
||||
return result.Text, nil
|
||||
}
|
||||
|
||||
// decodeBase64 解码 base64 字符串(支持 Data URL 前缀)。
|
||||
func decodeBase64(s string) ([]byte, error) {
|
||||
// 移除 data:xxx;base64, 前缀
|
||||
if idx := strings.Index(s, ","); idx != -1 {
|
||||
s = s[idx+1:]
|
||||
}
|
||||
return base64.StdEncoding.DecodeString(s)
|
||||
}
|
||||
|
||||
// handleHistoryRequest 处理历史消息请求
|
||||
|
||||
Reference in New Issue
Block a user