mirror of
https://gitee.com/blackfox/geekai.git
synced 2025-12-06 16:58:24 +08:00
增加语音合成功能
This commit is contained in:
parent
afb9193985
commit
ff69cb231a
@ -30,20 +30,21 @@ func NewChatModelHandler(app *core.AppServer, db *gorm.DB) *ChatModelHandler {
|
|||||||
|
|
||||||
func (h *ChatModelHandler) Save(c *gin.Context) {
|
func (h *ChatModelHandler) Save(c *gin.Context) {
|
||||||
var data struct {
|
var data struct {
|
||||||
Id uint `json:"id"`
|
Id uint `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Value string `json:"value"`
|
Value string `json:"value"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
SortNum int `json:"sort_num"`
|
SortNum int `json:"sort_num"`
|
||||||
Open bool `json:"open"`
|
Open bool `json:"open"`
|
||||||
Platform string `json:"platform"`
|
Platform string `json:"platform"`
|
||||||
Power int `json:"power"`
|
Power int `json:"power"`
|
||||||
MaxTokens int `json:"max_tokens"` // 最大响应长度
|
MaxTokens int `json:"max_tokens"` // 最大响应长度
|
||||||
MaxContext int `json:"max_context"` // 最大上下文长度
|
MaxContext int `json:"max_context"` // 最大上下文长度
|
||||||
Temperature float32 `json:"temperature"` // 模型温度
|
Temperature float32 `json:"temperature"` // 模型温度
|
||||||
KeyId int `json:"key_id,omitempty"`
|
KeyId int `json:"key_id,omitempty"`
|
||||||
CreatedAt int64 `json:"created_at"`
|
CreatedAt int64 `json:"created_at"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
|
Options map[string]string `json:"options"`
|
||||||
}
|
}
|
||||||
if err := c.ShouldBindJSON(&data); err != nil {
|
if err := c.ShouldBindJSON(&data); err != nil {
|
||||||
resp.ERROR(c, types.InvalidArgs)
|
resp.ERROR(c, types.InvalidArgs)
|
||||||
@ -59,7 +60,6 @@ func (h *ChatModelHandler) Save(c *gin.Context) {
|
|||||||
item.Name = data.Name
|
item.Name = data.Name
|
||||||
item.Value = data.Value
|
item.Value = data.Value
|
||||||
item.Enabled = data.Enabled
|
item.Enabled = data.Enabled
|
||||||
item.SortNum = data.SortNum
|
|
||||||
item.Open = data.Open
|
item.Open = data.Open
|
||||||
item.Power = data.Power
|
item.Power = data.Power
|
||||||
item.MaxTokens = data.MaxTokens
|
item.MaxTokens = data.MaxTokens
|
||||||
@ -67,6 +67,7 @@ func (h *ChatModelHandler) Save(c *gin.Context) {
|
|||||||
item.Temperature = data.Temperature
|
item.Temperature = data.Temperature
|
||||||
item.KeyId = data.KeyId
|
item.KeyId = data.KeyId
|
||||||
item.Type = data.Type
|
item.Type = data.Type
|
||||||
|
item.Options = utils.JsonEncode(data.Options)
|
||||||
var res *gorm.DB
|
var res *gorm.DB
|
||||||
if data.Id > 0 {
|
if data.Id > 0 {
|
||||||
res = h.DB.Save(&item)
|
res = h.DB.Save(&item)
|
||||||
|
|||||||
@ -25,6 +25,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
@ -505,47 +506,90 @@ func (h *ChatHandler) saveChatHistory(
|
|||||||
// 文本生成语音
|
// 文本生成语音
|
||||||
func (h *ChatHandler) TextToSpeech(c *gin.Context) {
|
func (h *ChatHandler) TextToSpeech(c *gin.Context) {
|
||||||
var data struct {
|
var data struct {
|
||||||
Text string `json:"text"`
|
ModelId int `json:"model_id"`
|
||||||
|
Text string `json:"text"`
|
||||||
}
|
}
|
||||||
if err := c.ShouldBindJSON(&data); err != nil {
|
if err := c.ShouldBindJSON(&data); err != nil {
|
||||||
resp.ERROR(c, types.InvalidArgs)
|
resp.ERROR(c, types.InvalidArgs)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 调用 DeepSeek 的 API 接口
|
textHash := utils.Sha256(fmt.Sprintf("%d/%s", data.ModelId, data.Text))
|
||||||
var apiKey model.ApiKey
|
audioFile := fmt.Sprintf("%s/audio", h.App.Config.StaticDir)
|
||||||
h.DB.Where("type", "chat").Where("enabled", true).First(&apiKey)
|
if _, err := os.Stat(audioFile); err != nil {
|
||||||
if apiKey.Id == 0 {
|
os.MkdirAll(audioFile, 0755)
|
||||||
resp.ERROR(c, "no available key, please import key")
|
}
|
||||||
|
audioFile = fmt.Sprintf("%s/%s.mp3", audioFile, textHash)
|
||||||
|
if _, err := os.Stat(audioFile); err == nil {
|
||||||
|
// 设置响应头
|
||||||
|
c.Header("Content-Type", "audio/mpeg")
|
||||||
|
c.Header("Content-Disposition", "attachment; filename=speech.mp3")
|
||||||
|
c.File(audioFile)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 查询模型
|
||||||
|
var chatModel model.ChatModel
|
||||||
|
err := h.DB.Where("id", data.ModelId).First(&chatModel).Error
|
||||||
|
if err != nil {
|
||||||
|
resp.ERROR(c, "找不到语音模型")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用 DeepSeek 的 API 接口
|
||||||
|
var apiKey model.ApiKey
|
||||||
|
if chatModel.KeyId > 0 {
|
||||||
|
h.DB.Where("id", chatModel.KeyId).First(&apiKey)
|
||||||
|
}
|
||||||
|
if apiKey.Id == 0 {
|
||||||
|
h.DB.Where("type", "tts").Where("enabled", true).First(&apiKey)
|
||||||
|
}
|
||||||
|
if apiKey.Id == 0 {
|
||||||
|
resp.ERROR(c, "no TTS API key, please import key")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Debugf("chatModel: %+v, apiKey: %+v", chatModel, apiKey)
|
||||||
|
|
||||||
// 调用 openai tts api
|
// 调用 openai tts api
|
||||||
config := openai.DefaultConfig(apiKey.Value)
|
config := openai.DefaultConfig(apiKey.Value)
|
||||||
config.BaseURL = apiKey.ApiURL
|
config.BaseURL = apiKey.ApiURL + "/v1"
|
||||||
client := openai.NewClientWithConfig(config)
|
client := openai.NewClientWithConfig(config)
|
||||||
|
voice := openai.VoiceAlloy
|
||||||
|
var options map[string]string
|
||||||
|
err = utils.JsonDecode(chatModel.Options, &options)
|
||||||
|
if err == nil {
|
||||||
|
voice = openai.SpeechVoice(options["voice"])
|
||||||
|
}
|
||||||
req := openai.CreateSpeechRequest{
|
req := openai.CreateSpeechRequest{
|
||||||
Model: openai.TTSModel1,
|
Model: openai.SpeechModel(chatModel.Value),
|
||||||
Input: data.Text,
|
Input: data.Text,
|
||||||
Voice: openai.VoiceAlloy,
|
Voice: voice,
|
||||||
}
|
}
|
||||||
|
|
||||||
audioData, err := client.CreateSpeech(context.Background(), req)
|
audioData, err := client.CreateSpeech(context.Background(), req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error("failed to create speech: ", err)
|
resp.ERROR(c, err.Error())
|
||||||
resp.ERROR(c, "failed to create speech")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 先将音频数据读取到内存
|
||||||
|
audioBytes, err := io.ReadAll(audioData)
|
||||||
|
if err != nil {
|
||||||
|
resp.ERROR(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存到音频文件
|
||||||
|
err = os.WriteFile(audioFile, audioBytes, 0644)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("failed to save audio file: ", err)
|
||||||
|
}
|
||||||
|
|
||||||
// 设置响应头
|
// 设置响应头
|
||||||
c.Header("Content-Type", "audio/mpeg")
|
c.Header("Content-Type", "audio/mpeg")
|
||||||
c.Header("Content-Disposition", "attachment; filename=speech.mp3")
|
c.Header("Content-Disposition", "attachment; filename=speech.mp3")
|
||||||
|
|
||||||
// 将音频数据写入响应
|
// 直接写入完整的音频数据到响应
|
||||||
_, err = io.Copy(c.Writer, audioData)
|
c.Writer.Write(audioBytes)
|
||||||
if err != nil {
|
|
||||||
logger.Error("failed to write audio data: ", err)
|
|
||||||
resp.ERROR(c, "failed to write audio data")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -30,14 +30,17 @@ func NewChatModelHandler(app *core.AppServer, db *gorm.DB) *ChatModelHandler {
|
|||||||
func (h *ChatModelHandler) List(c *gin.Context) {
|
func (h *ChatModelHandler) List(c *gin.Context) {
|
||||||
var items []model.ChatModel
|
var items []model.ChatModel
|
||||||
var chatModels = make([]vo.ChatModel, 0)
|
var chatModels = make([]vo.ChatModel, 0)
|
||||||
session := h.DB.Session(&gorm.Session{}).Where("type", "chat").Where("enabled", true)
|
session := h.DB.Session(&gorm.Session{}).Where("enabled", true)
|
||||||
t := c.Query("type")
|
t := c.Query("type")
|
||||||
|
logger.Info("type: ", t)
|
||||||
if t != "" {
|
if t != "" {
|
||||||
session = session.Where("type", t)
|
session = session.Where("type", t)
|
||||||
|
} else {
|
||||||
|
session = session.Where("type", "chat")
|
||||||
}
|
}
|
||||||
|
|
||||||
session = session.Where("open", true)
|
session = session.Where("open", true)
|
||||||
if h.IsLogin(c) {
|
if h.IsLogin(c) && t == "chat" {
|
||||||
user, _ := h.GetLoginUser(c)
|
user, _ := h.GetLoginUser(c)
|
||||||
var models []int
|
var models []int
|
||||||
err := utils.JsonDecode(user.ChatModels, &models)
|
err := utils.JsonDecode(user.ChatModels, &models)
|
||||||
@ -48,7 +51,7 @@ func (h *ChatModelHandler) List(c *gin.Context) {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
res := session.Order("sort_num ASC").Find(&items)
|
res := session.Debug().Order("sort_num ASC").Find(&items)
|
||||||
if res.Error == nil {
|
if res.Error == nil {
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
var cm vo.ChatModel
|
var cm vo.ChatModel
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -13,4 +13,5 @@ type ChatModel struct {
|
|||||||
Temperature float32 // 模型温度
|
Temperature float32 // 模型温度
|
||||||
KeyId int // 绑定 API KEY ID
|
KeyId int // 绑定 API KEY ID
|
||||||
Type string // 模型类型
|
Type string // 模型类型
|
||||||
|
Options string // 模型选项
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,16 +2,17 @@ package vo
|
|||||||
|
|
||||||
type ChatModel struct {
|
type ChatModel struct {
|
||||||
BaseVo
|
BaseVo
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Value string `json:"value"`
|
Value string `json:"value"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
SortNum int `json:"sort_num"`
|
SortNum int `json:"sort_num"`
|
||||||
Power int `json:"power"`
|
Power int `json:"power"`
|
||||||
Open bool `json:"open"`
|
Open bool `json:"open"`
|
||||||
MaxTokens int `json:"max_tokens"` // 最大响应长度
|
MaxTokens int `json:"max_tokens"` // 最大响应长度
|
||||||
MaxContext int `json:"max_context"` // 最大上下文长度
|
MaxContext int `json:"max_context"` // 最大上下文长度
|
||||||
Temperature float32 `json:"temperature"` // 模型温度
|
Temperature float32 `json:"temperature"` // 模型温度
|
||||||
KeyId int `json:"key_id,omitempty"`
|
KeyId int `json:"key_id,omitempty"`
|
||||||
KeyName string `json:"key_name"`
|
KeyName string `json:"key_name"`
|
||||||
Type string `json:"type"`
|
Options map[string]string `json:"options"`
|
||||||
|
Type string `json:"type"`
|
||||||
}
|
}
|
||||||
|
|||||||
1
database/update-v4.2.2.sql
Normal file
1
database/update-v4.2.2.sql
Normal file
@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `chatgpt_chat_models` ADD `options` TEXT NOT NULL COMMENT '模型自定义选项' AFTER `key_id`;
|
||||||
@ -198,7 +198,7 @@ const isExternalImg = (link, files) => {
|
|||||||
width 100%
|
width 100%
|
||||||
padding-bottom: 1.5rem;
|
padding-bottom: 1.5rem;
|
||||||
padding-top: 1.5rem;
|
padding-top: 1.5rem;
|
||||||
border-bottom: 0.5px solid var(--el-border-color);
|
// border-bottom: 0.5px solid var(--el-border-color);
|
||||||
|
|
||||||
.chat-line-inner {
|
.chat-line-inner {
|
||||||
display flex;
|
display flex;
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<div class="chat-reply">
|
||||||
<div class="chat-line chat-line-reply-list" v-if="listStyle === 'list'">
|
<div class="chat-line chat-line-reply-list" v-if="listStyle === 'list'">
|
||||||
<div class="chat-line-inner">
|
<div class="chat-line-inner">
|
||||||
<div class="chat-icon">
|
<div class="chat-icon">
|
||||||
@ -7,10 +8,8 @@
|
|||||||
|
|
||||||
<div class="chat-item">
|
<div class="chat-item">
|
||||||
<div class="content-wrapper" v-html="md.render(processContent(data.content))"></div>
|
<div class="content-wrapper" v-html="md.render(processContent(data.content))"></div>
|
||||||
<div class="bar" v-if="data.created_at">
|
<div class="bar flex" v-if="data.created_at">
|
||||||
<span class="bar-item"
|
<span class="bar-item">{{ dateFormat(data.created_at) }}</span>
|
||||||
><el-icon><Clock /></el-icon> {{ dateFormat(data.created_at) }}</span
|
|
||||||
>
|
|
||||||
<span class="bar-item">tokens: {{ data.tokens }}</span>
|
<span class="bar-item">tokens: {{ data.tokens }}</span>
|
||||||
<span class="bar-item">
|
<span class="bar-item">
|
||||||
<el-tooltip class="box-item" effect="dark" content="复制回答" placement="bottom">
|
<el-tooltip class="box-item" effect="dark" content="复制回答" placement="bottom">
|
||||||
@ -19,16 +18,17 @@
|
|||||||
</el-icon>
|
</el-icon>
|
||||||
</el-tooltip>
|
</el-tooltip>
|
||||||
</span>
|
</span>
|
||||||
<span v-if="!readOnly">
|
<span v-if="!readOnly" class="flex">
|
||||||
<span class="bar-item" @click="reGenerate(data.prompt)">
|
<span class="bar-item" @click="reGenerate(data.prompt)">
|
||||||
<el-tooltip class="box-item" effect="dark" content="重新生成" placement="bottom">
|
<el-tooltip class="box-item" effect="dark" content="重新生成" placement="bottom">
|
||||||
<el-icon><Refresh /></el-icon>
|
<el-icon><Refresh /></el-icon>
|
||||||
</el-tooltip>
|
</el-tooltip>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span class="bar-item" @click="synthesis(data.content)">
|
<span class="bar-item">
|
||||||
<el-tooltip class="box-item" effect="dark" content="生成语音朗读" placement="bottom">
|
<el-tooltip class="box-item" effect="dark" content="生成语音朗读" placement="bottom">
|
||||||
<i class="iconfont icon-speaker"></i>
|
<i class="iconfont icon-speaker" v-if="!isPlaying" @click="synthesis(data.content)"></i>
|
||||||
|
<el-image class="voice-icon" :src="playIcon" v-else />
|
||||||
</el-tooltip>
|
</el-tooltip>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
@ -59,9 +59,7 @@
|
|||||||
<div class="content" v-html="md.render(processContent(data.content))"></div>
|
<div class="content" v-html="md.render(processContent(data.content))"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="bar" v-if="data.created_at">
|
<div class="bar" v-if="data.created_at">
|
||||||
<span class="bar-item"
|
<span class="bar-item">{{ dateFormat(data.created_at) }}</span>
|
||||||
><el-icon><Clock /></el-icon> {{ dateFormat(data.created_at) }}</span
|
|
||||||
>
|
|
||||||
<!-- <span class="bar-item">tokens: {{ data.tokens }}</span>-->
|
<!-- <span class="bar-item">tokens: {{ data.tokens }}</span>-->
|
||||||
<span class="bar-item bg">
|
<span class="bar-item bg">
|
||||||
<el-tooltip class="box-item" effect="dark" content="复制回答" placement="bottom">
|
<el-tooltip class="box-item" effect="dark" content="复制回答" placement="bottom">
|
||||||
@ -70,24 +68,29 @@
|
|||||||
</el-icon>
|
</el-icon>
|
||||||
</el-tooltip>
|
</el-tooltip>
|
||||||
</span>
|
</span>
|
||||||
<span v-if="!readOnly">
|
<span v-if="!readOnly" class="flex">
|
||||||
<span class="bar-item bg" @click="reGenerate(data.prompt)">
|
<span class="bar-item bg" @click="reGenerate(data.prompt)">
|
||||||
<el-tooltip class="box-item" effect="dark" content="重新生成" placement="bottom">
|
<el-tooltip class="box-item" effect="dark" content="重新生成" placement="bottom">
|
||||||
<el-icon><Refresh /></el-icon>
|
<el-icon><Refresh /></el-icon>
|
||||||
</el-tooltip>
|
</el-tooltip>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span class="bar-item bg" @click="synthesis(data.content)">
|
<span class="bar-item bg">
|
||||||
<el-tooltip class="box-item" effect="dark" content="生成语音朗读" placement="bottom">
|
<el-tooltip class="box-item" effect="dark" content="生成语音朗读" placement="bottom" v-if="!isPlaying">
|
||||||
<i class="iconfont icon-speaker"></i>
|
<i class="iconfont icon-speaker" @click="synthesis(data.content)"></i>
|
||||||
</el-tooltip>
|
</el-tooltip>
|
||||||
|
<el-tooltip class="box-item" effect="dark" content="暂停播放" placement="bottom" v-else>
|
||||||
|
<el-image class="voice-icon" :src="playIcon" @click="stopSynthesis()" />
|
||||||
|
</el-tooltip>
|
||||||
|
|
||||||
</span>
|
</span>
|
||||||
<img src="/images/voice.gif" />
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<audio ref="audio" @ended="isPlaying = false" />
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
@ -99,8 +102,8 @@ import emoji from "markdown-it-emoji";
|
|||||||
import mathjaxPlugin from "markdown-it-mathjax3";
|
import mathjaxPlugin from "markdown-it-mathjax3";
|
||||||
import MarkdownIt from "markdown-it";
|
import MarkdownIt from "markdown-it";
|
||||||
import { httpPost } from "@/utils/http";
|
import { httpPost } from "@/utils/http";
|
||||||
import RippleButton from "./ui/RippleButton.vue";
|
import { ref } from "vue";
|
||||||
|
import { useSharedStore } from "@/store/sharedata";
|
||||||
// eslint-disable-next-line no-undef,no-unused-vars
|
// eslint-disable-next-line no-undef,no-unused-vars
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
data: {
|
data: {
|
||||||
@ -122,6 +125,11 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const audio = ref(null);
|
||||||
|
const isPlaying = ref(false);
|
||||||
|
const playIcon = ref("/images/voice.gif");
|
||||||
|
const store = useSharedStore();
|
||||||
|
|
||||||
const md = new MarkdownIt({
|
const md = new MarkdownIt({
|
||||||
breaks: true,
|
breaks: true,
|
||||||
html: true,
|
html: true,
|
||||||
@ -158,34 +166,38 @@ if (!props.data.icon) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const synthesis = (text) => {
|
const synthesis = (text) => {
|
||||||
console.log(text);
|
isPlaying.value = true
|
||||||
// 生成语音
|
httpPost("/api/chat/tts", { text:text, model_id:store.ttsModel }, { responseType: 'blob' }).then(response => {
|
||||||
httpPost("/api/chat/tts", { text }, { responseType: 'blob' }).then(response => {
|
|
||||||
// 创建 Blob 对象,明确指定 MIME 类型
|
// 创建 Blob 对象,明确指定 MIME 类型
|
||||||
const blob = new Blob([response], { type: 'audio/wav' });
|
const blob = new Blob([response], { type: 'audio/mpeg' }); // 假设音频格式为 MP3
|
||||||
// 创建 URL
|
|
||||||
const audioUrl = URL.createObjectURL(blob);
|
const audioUrl = URL.createObjectURL(blob);
|
||||||
// 创建音频元素
|
|
||||||
const audio = new Audio(audioUrl);
|
|
||||||
// 播放音频
|
// 播放音频
|
||||||
audio.play().then(() => {
|
audio.value.src = audioUrl;
|
||||||
|
audio.value.play().then(() => {
|
||||||
// 播放完成后释放 URL
|
// 播放完成后释放 URL
|
||||||
URL.revokeObjectURL(audioUrl);
|
URL.revokeObjectURL(audioUrl);
|
||||||
}).catch(err => {
|
}).catch(() => {
|
||||||
console.error('播放音频失败:', err);
|
|
||||||
ElMessage.error('音频播放失败,请检查浏览器是否支持该音频格式');
|
ElMessage.error('音频播放失败,请检查浏览器是否支持该音频格式');
|
||||||
|
isPlaying.value = false
|
||||||
});
|
});
|
||||||
}).catch(err => {
|
}).catch(e => {
|
||||||
console.error('语音合成请求失败:', err);
|
ElMessage.error('语音合成失败:' + e.message);
|
||||||
ElMessage.error('语音合成失败,请稍后重试');
|
isPlaying.value = false
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const stopSynthesis = () => {
|
||||||
|
isPlaying.value = false
|
||||||
|
audio.value.pause()
|
||||||
|
audio.value.currentTime = 0
|
||||||
|
}
|
||||||
|
|
||||||
// 重新生成
|
// 重新生成
|
||||||
const reGenerate = (prompt) => {
|
const reGenerate = (prompt) => {
|
||||||
console.log(prompt);
|
console.log(prompt);
|
||||||
emits("regen", prompt);
|
emits("regen", prompt);
|
||||||
};
|
};
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="stylus">
|
<style lang="stylus">
|
||||||
@ -307,7 +319,8 @@ const reGenerate = (prompt) => {
|
|||||||
width 100%
|
width 100%
|
||||||
padding-bottom: 1.5rem;
|
padding-bottom: 1.5rem;
|
||||||
padding-top: 1.5rem;
|
padding-top: 1.5rem;
|
||||||
border-bottom: 0.5px solid var(--el-border-color);
|
border: 1px solid var(--el-border-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
|
||||||
.chat-line-inner {
|
.chat-line-inner {
|
||||||
display flex;
|
display flex;
|
||||||
@ -347,10 +360,18 @@ const reGenerate = (prompt) => {
|
|||||||
padding 10px 10px 10px 0;
|
padding 10px 10px 10px 0;
|
||||||
|
|
||||||
.bar-item {
|
.bar-item {
|
||||||
padding 3px 5px;
|
|
||||||
margin-right 10px;
|
margin-right 10px;
|
||||||
border-radius 5px;
|
border-radius 5px;
|
||||||
cursor pointer
|
cursor pointer
|
||||||
|
display flex
|
||||||
|
align-items center
|
||||||
|
justify-content center
|
||||||
|
height 26px
|
||||||
|
|
||||||
|
.voice-icon {
|
||||||
|
width 20px
|
||||||
|
height 20px
|
||||||
|
}
|
||||||
|
|
||||||
.el-icon {
|
.el-icon {
|
||||||
position relative
|
position relative
|
||||||
@ -426,11 +447,21 @@ const reGenerate = (prompt) => {
|
|||||||
|
|
||||||
.bar {
|
.bar {
|
||||||
padding 10px 10px 10px 0;
|
padding 10px 10px 10px 0;
|
||||||
|
display flex
|
||||||
|
|
||||||
.bar-item {
|
.bar-item {
|
||||||
padding 3px 5px;
|
|
||||||
margin-right 10px;
|
margin-right 10px;
|
||||||
border-radius 5px;
|
border-radius 5px;
|
||||||
|
display flex
|
||||||
|
align-items center
|
||||||
|
justify-content center
|
||||||
|
height 26px
|
||||||
|
|
||||||
|
.voice-icon {
|
||||||
|
width 20px
|
||||||
|
height 20px
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
.el-icon {
|
.el-icon {
|
||||||
position relative
|
position relative
|
||||||
|
|||||||
@ -18,19 +18,28 @@
|
|||||||
<el-form-item label="流式输出:">
|
<el-form-item label="流式输出:">
|
||||||
<el-switch v-model="data.stream" @change="(val) => {store.setChatStream(val)}" />
|
<el-switch v-model="data.stream" @change="(val) => {store.setChatStream(val)}" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<el-form-item label="语音音色:">
|
||||||
|
<el-select v-model="data.ttsModel" placeholder="请选择语音音色" @change="changeTTSModel">
|
||||||
|
<el-option v-for="v in models" :value="v.id" :label="v.name" :key="v.id">
|
||||||
|
{{ v.name }}
|
||||||
|
</el-option>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
</div>
|
</div>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import {computed, ref} from "vue"
|
import {computed, ref, onMounted} from "vue"
|
||||||
import {useSharedStore} from "@/store/sharedata";
|
import {useSharedStore} from "@/store/sharedata";
|
||||||
|
import {httpGet} from "@/utils/http";
|
||||||
const store = useSharedStore();
|
const store = useSharedStore();
|
||||||
|
|
||||||
const data = ref({
|
const data = ref({
|
||||||
style: store.chatListStyle,
|
style: store.chatListStyle,
|
||||||
stream: store.chatStream,
|
stream: store.chatStream,
|
||||||
|
ttsModel: store.ttsModel,
|
||||||
})
|
})
|
||||||
// eslint-disable-next-line no-undef
|
// eslint-disable-next-line no-undef
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@ -44,6 +53,20 @@ const emits = defineEmits(['hide']);
|
|||||||
const close = function () {
|
const close = function () {
|
||||||
emits('hide', false);
|
emits('hide', false);
|
||||||
}
|
}
|
||||||
|
const models = ref([]);
|
||||||
|
onMounted(() => {
|
||||||
|
// 获取模型列表
|
||||||
|
httpGet("/api/model/list?type=tts").then((res) => {
|
||||||
|
models.value = res.data;
|
||||||
|
if (!data.ttsModel) {
|
||||||
|
store.setTtsModel(models.value[0].id);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const changeTTSModel = (item) => {
|
||||||
|
store.setTtsModel(item);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="stylus" scoped>
|
<style lang="stylus" scoped>
|
||||||
|
|||||||
@ -10,6 +10,7 @@ export const useSharedStore = defineStore("shared", {
|
|||||||
theme: Storage.get("theme", "light"),
|
theme: Storage.get("theme", "light"),
|
||||||
isLogin: false,
|
isLogin: false,
|
||||||
chatListExtend: Storage.get("chat_list_extend", true),
|
chatListExtend: Storage.get("chat_list_extend", true),
|
||||||
|
ttsModel: Storage.get("tts_model", ""),
|
||||||
}),
|
}),
|
||||||
getters: {},
|
getters: {},
|
||||||
actions: {
|
actions: {
|
||||||
@ -74,5 +75,10 @@ export const useSharedStore = defineStore("shared", {
|
|||||||
setIsLogin(value) {
|
setIsLogin(value) {
|
||||||
this.isLogin = value;
|
this.isLogin = value;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setTtsModel(value) {
|
||||||
|
this.ttsModel = value;
|
||||||
|
Storage.set("tts_model", value);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -464,7 +464,7 @@ onUnmounted(() => {
|
|||||||
// 初始化数据
|
// 初始化数据
|
||||||
const initData = () => {
|
const initData = () => {
|
||||||
// 加载模型
|
// 加载模型
|
||||||
httpGet("/api/model/list")
|
httpGet("/api/model/list?type=chat")
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
models.value = res.data;
|
models.value = res.data;
|
||||||
if (!modelID.value) {
|
if (!modelID.value) {
|
||||||
|
|||||||
@ -142,6 +142,7 @@ const types = ref([
|
|||||||
{ label: "Luma视频", value: "luma" },
|
{ label: "Luma视频", value: "luma" },
|
||||||
{ label: "可灵视频", value: "keling" },
|
{ label: "可灵视频", value: "keling" },
|
||||||
{ label: "Realtime API", value: "realtime" },
|
{ label: "Realtime API", value: "realtime" },
|
||||||
|
{ label: "语音合成", value: "tts" },
|
||||||
{ label: "其他", value: "other" },
|
{ label: "其他", value: "other" },
|
||||||
]);
|
]);
|
||||||
const isEdit = ref(false);
|
const isEdit = ref(false);
|
||||||
|
|||||||
@ -126,6 +126,16 @@
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="item.type === 'tts'">
|
||||||
|
<el-form-item label="音色" prop="voice">
|
||||||
|
<el-select v-model="item.options.voice" placeholder="请选择音色">
|
||||||
|
<el-option v-for="v in voices" :value="v.value" :label="v.label" :key="v.value">
|
||||||
|
{{ v.label }}
|
||||||
|
</el-option>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</div>
|
||||||
|
|
||||||
<el-form-item label="绑定API-KEY:" prop="apikey">
|
<el-form-item label="绑定API-KEY:" prop="apikey">
|
||||||
<el-select v-model="item.key_id" placeholder="请选择 API KEY" filterable clearable>
|
<el-select v-model="item.key_id" placeholder="请选择 API KEY" filterable clearable>
|
||||||
<el-option v-for="v in apiKeys" :value="v.id" :label="v.name" :key="v.id">
|
<el-option v-for="v in apiKeys" :value="v.id" :label="v.name" :key="v.id">
|
||||||
@ -191,6 +201,15 @@ const formRef = ref(null);
|
|||||||
const type = ref([
|
const type = ref([
|
||||||
{ label: "聊天", value: "chat" },
|
{ label: "聊天", value: "chat" },
|
||||||
{ label: "绘图", value: "img" },
|
{ label: "绘图", value: "img" },
|
||||||
|
{ label: "语音", value: "tts" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const voices = ref([
|
||||||
|
{ label: "Echo", value: "echo" },
|
||||||
|
{ label: "Fable", value: "fable" },
|
||||||
|
{ label: "Onyx", value: "onyx" },
|
||||||
|
{ label: "Nova", value: "nova" },
|
||||||
|
{ label: "Shimmer", value: "shimmer" },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 获取 API KEY
|
// 获取 API KEY
|
||||||
@ -270,7 +289,7 @@ onUnmounted(() => {
|
|||||||
const add = function () {
|
const add = function () {
|
||||||
title.value = "新增模型";
|
title.value = "新增模型";
|
||||||
showDialog.value = true;
|
showDialog.value = true;
|
||||||
item.value = { enabled: true, power: 1, open: true, max_tokens: 1024, max_context: 8192, temperature: 0.9 };
|
item.value = { enabled: true, power: 1, open: true, max_tokens: 1024, max_context: 8192, temperature: 0.9, options: {} };
|
||||||
};
|
};
|
||||||
|
|
||||||
const edit = function (row) {
|
const edit = function (row) {
|
||||||
@ -282,9 +301,6 @@ const edit = function (row) {
|
|||||||
const save = function () {
|
const save = function () {
|
||||||
formRef.value.validate((valid) => {
|
formRef.value.validate((valid) => {
|
||||||
item.value.temperature = parseFloat(item.value.temperature);
|
item.value.temperature = parseFloat(item.value.temperature);
|
||||||
if (!item.value.sort_num) {
|
|
||||||
item.value.sort_num = items.value.length;
|
|
||||||
}
|
|
||||||
if (valid) {
|
if (valid) {
|
||||||
showDialog.value = false;
|
showDialog.value = false;
|
||||||
item.value.key_id = parseInt(item.value.key_id);
|
item.value.key_id = parseInt(item.value.key_id);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user