测试通过:

- 自定义恋人历史消息
- 克隆音色
- 回复消息增加“思考中”
This commit is contained in:
xiao12feng8 2026-02-01 15:39:13 +08:00
parent 3a89cddd43
commit b4f4800e77
16 changed files with 38058 additions and 27848 deletions

4
.env
View File

@ -2,6 +2,10 @@
APP_ENV=development
DEBUG=True
# ===== 后端服务地址 =====
# 用于生成完整的 URLTTS 音频等)
BACKEND_URL=http://127.0.0.1:8000
# ===== 数据库配置 =====
DATABASE_URL=mysql+pymysql://root:root@127.0.0.1:3306/lover?charset=utf8mb4

Binary file not shown.

View File

@ -1,8 +1,10 @@
from fastapi import FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
import logging
import dashscope
from pathlib import Path
from .routers import config as config_router
from .routers import lover as lover_router
@ -22,6 +24,13 @@ if settings.DASHSCOPE_API_KEY:
app = FastAPI(title="LOVER API")
# 创建 TTS 文件目录
tts_dir = Path("public/tts")
tts_dir.mkdir(parents=True, exist_ok=True)
# 挂载静态文件服务(用于提供 TTS 音频文件)
app.mount("/tts", StaticFiles(directory=str(tts_dir)), name="tts")
app.add_middleware(
CORSMiddleware,
allow_origins=[

View File

@ -475,19 +475,56 @@ def _pick_available_voice(db: Session, lover: Lover, user_row: User) -> VoiceLib
def _upload_tts_to_oss(file_bytes: bytes, lover_id: int, message_id: int) -> str:
if not settings.ALIYUN_OSS_ACCESS_KEY_ID or not settings.ALIYUN_OSS_ACCESS_KEY_SECRET:
raise HTTPException(status_code=500, detail="未配置 OSS Key")
if not settings.ALIYUN_OSS_BUCKET_NAME or not settings.ALIYUN_OSS_ENDPOINT:
raise HTTPException(status_code=500, detail="未配置 OSS Bucket/Endpoint")
"""
上传 TTS 音频文件
优先使用 OSS如果未配置则保存到本地
"""
# 检查是否配置了 OSS
has_oss = (
settings.ALIYUN_OSS_ACCESS_KEY_ID
and settings.ALIYUN_OSS_ACCESS_KEY_SECRET
and settings.ALIYUN_OSS_BUCKET_NAME
and settings.ALIYUN_OSS_ENDPOINT
)
if has_oss:
# 使用 OSS 上传
object_name = f"lover/{lover_id}/tts/{message_id}.mp3"
endpoint = settings.ALIYUN_OSS_ENDPOINT.rstrip("/")
try:
auth = oss2.Auth(settings.ALIYUN_OSS_ACCESS_KEY_ID, settings.ALIYUN_OSS_ACCESS_KEY_SECRET)
bucket = oss2.Bucket(auth, endpoint, settings.ALIYUN_OSS_BUCKET_NAME)
bucket.put_object(object_name, file_bytes)
# 返回 OSS URL
cdn_domain = settings.ALIYUN_OSS_CDN_DOMAIN
if cdn_domain:
return f"{cdn_domain.rstrip('/')}/{object_name}"
else:
return f"https://{settings.ALIYUN_OSS_BUCKET_NAME}.{endpoint}/{object_name}"
except Exception as exc:
raise HTTPException(status_code=502, detail=f"上传语音失败: {exc}") from exc
else:
# 保存到本地文件系统
import os
from pathlib import Path
# 创建保存目录
base_dir = Path("public/tts")
lover_dir = base_dir / str(lover_id)
lover_dir.mkdir(parents=True, exist_ok=True)
# 保存文件
file_path = lover_dir / f"{message_id}.mp3"
try:
with open(file_path, "wb") as f:
f.write(file_bytes)
# 返回完整 URL使用环境变量配置的后端地址
backend_url = os.getenv("BACKEND_URL", "http://127.0.0.1:8000")
return f"{backend_url.rstrip('/')}/tts/{lover_id}/{message_id}.mp3"
except Exception as exc:
raise HTTPException(status_code=500, detail=f"保存语音文件失败: {exc}") from exc
cdn = settings.ALIYUN_OSS_CDN_DOMAIN
if cdn:

View File

@ -334,7 +334,7 @@ def list_available_voices_for_lover(
class VoiceCloneRequest(BaseModel):
audio_url: str
voice_name: str
voice_name: str # 用户输入的显示名称(可以是中文)
gender: str # male/female
model_config = ConfigDict(from_attributes=True)
@ -364,25 +364,38 @@ def clone_voice(
):
"""
克隆音色用户上传音频文件系统调用 CosyVoice 克隆音色
用户可以输入中文名称系统会自动生成英文哈希码用于 API 调用
"""
from ..cosyvoice_clone import create_voice_from_url
import hashlib
import time
# 验证音色名称长度CosyVoice 限制 prefix <= 10 字符)
if len(payload.voice_name) > 10:
raise HTTPException(status_code=400, detail="音色名称不能超过10个字符")
# 验证音色名称长度
if len(payload.voice_name) > 20:
raise HTTPException(status_code=400, detail="音色名称不能超过20个字符")
# 验证性别
if payload.gender not in ["male", "female"]:
raise HTTPException(status_code=400, detail="性别必须是 male 或 female")
try:
# 调用克隆服务
# 生成英文哈希码作为 prefixCosyVoice API 要求最多10字符
# 使用时间戳 + 用户ID 生成唯一哈希
hash_input = f"{user.id}_{int(time.time())}"
hash_code = hashlib.md5(hash_input.encode()).hexdigest()[:6] # 取6位
api_prefix = f"v{hash_code}" # v + 6位哈希 = 7字符符合限制
# 调用克隆服务(使用英文哈希码)
voice_id = create_voice_from_url(
audio_url=payload.audio_url,
prefix=payload.voice_name,
prefix=api_prefix,
target_model="cosyvoice-v2"
)
# 将中文显示名称和 voice_id 的映射关系临时存储
# 后续保存到数据库时会用到
# 这里可以使用 Redis 或者直接在保存时传递
return success_response(
VoiceCloneResponse(
voice_id=voice_id,
@ -431,9 +444,16 @@ def get_clone_status(
raise HTTPException(status_code=500, detail=f"查询失败: {str(e)}")
class VoiceCloneSaveRequest(BaseModel):
display_name: Optional[str] = None # 用户输入的显示名称(中文)
model_config = ConfigDict(from_attributes=True)
@router.post("/voices/clone/{voice_id}/save", response_model=ApiResponse[dict])
def save_cloned_voice(
voice_id: str,
payload: VoiceCloneSaveRequest,
db: Session = Depends(get_db),
user: AuthedUser = Depends(get_current_user),
):
@ -460,22 +480,25 @@ def save_cloned_voice(
return success_response({"voice_library_id": existing.id, "message": "音色已存在"})
# 获取音色信息
voice_name = info.get("name", "克隆音色")
api_voice_name = info.get("name", "克隆音色") # API 返回的英文哈希名称
# 使用用户输入的显示名称,如果没有则使用 API 名称
display_name = payload.display_name if payload.display_name else api_voice_name
# 获取用户的恋人信息以确定性别
lover = db.query(Lover).filter(Lover.user_id == user.id).first()
gender = lover.gender if lover else "female"
# 保存到数据库
# 保存到数据库name 字段保存用户输入的中文名称)
new_voice = VoiceLibrary(
name=voice_name,
name=display_name, # 保存中文显示名称
gender=gender,
style_tag="克隆音色",
avatar_url=None,
sample_audio_url=None,
tts_model_id="cosyvoice-v2",
is_default=False,
voice_code=voice_id,
voice_code=voice_id, # voice_code 是 API 返回的英文哈希码
is_owned=True,
price_gold=0
)
@ -484,7 +507,8 @@ def save_cloned_voice(
return success_response({
"voice_library_id": new_voice.id,
"message": "音色保存成功"
"message": "音色保存成功",
"display_name": display_name
})
except HTTPException:

View File

@ -262,6 +262,7 @@ class LoverBasicResponse(BaseModel):
lover_id: int
name: Optional[str] = None
image_url: Optional[str] = None
gender: Optional[str] = None
LoverConfigResponse.model_rebuild()
@ -1052,7 +1053,12 @@ def get_lover_basic(
# 保持成功响应结构data 返回 null 便于前端按需处理
return success_response(None)
return success_response(LoverBasicResponse(lover_id=lover.id, name=lover.name, image_url=lover.image_url))
return success_response(LoverBasicResponse(
lover_id=lover.id,
name=lover.name,
image_url=lover.image_url,
gender=lover.gender
))
@router.post("/config", response_model=ApiResponse[LoverOut])

View File

@ -546,11 +546,11 @@
// 5
setTimeout(() => {
uni.showToast({
title: '发送成功',
icon: 'none',
position: 'top'
});
//
this.sessionInitList.messages = this.sessionInitList.messages.filter(
msg => !msg.isTemp && !msg.isThinking
);
this.addBond();
this.form.message = '';
this.form.session_id = '';
@ -561,9 +561,9 @@
this.refreshSessionData(true);
}, remainingTime);
} else {
//
//
this.sessionInitList.messages = this.sessionInitList.messages.filter(
msg => !msg.isThinking
msg => !msg.isTemp && !msg.isThinking
);
uni.showToast({
title: res.msg,
@ -572,9 +572,9 @@
});
}
}).catch(err => {
//
//
this.sessionInitList.messages = this.sessionInitList.messages.filter(
msg => !msg.isThinking
msg => !msg.isTemp && !msg.isThinking
);
uni.showToast({
title: '发送失败',
@ -883,33 +883,56 @@
return;
}
//
this.messageSentTime = Date.now();
//
const userMessage = this.form.message;
// ""
const thinkingMessage = {
id: 'thinking_' + Date.now(),
role: 'lover',
content: '思考中...',
isThinking: true, //
created_at: new Date().toISOString()
//
const userMsg = {
id: Date.now(), // ID
role: 'user',
content: userMessage,
seq: this.sessionInitList.messages.length + 1,
created_at: new Date().toISOString(),
content_type: 'text',
isTemp: true //
};
//
if (!this.sessionInitList.messages) {
this.sessionInitList.messages = [];
}
this.sessionInitList.messages.push(thinkingMessage);
this.sessionInitList.messages.push(userMsg);
// "..."
const thinkingMsg = {
id: Date.now() + 1,
role: 'lover',
content: '思考中...',
seq: this.sessionInitList.messages.length + 1,
created_at: new Date().toISOString(),
content_type: 'text',
isThinking: true //
};
this.sessionInitList.messages.push(thinkingMsg);
//
this.form.message = '';
//
this.$nextTick(() => {
this.scrollToBottom();
});
//
this.messageSentTime = Date.now();
//
this.form.message = userMessage;
this.addBondform.type = 1;
this.addBondform.num = 1;
// API
// loading
this.sessionSend();
},
scrollToBottom() {
@ -1078,15 +1101,52 @@
return;
}
//
const userMessage = this.form.message;
uni.showLoading({
title: '发送中...'
//
const userMsg = {
id: Date.now(), // ID
role: 'user',
content: userMessage,
seq: this.sessionInitList.messages.length + 1,
created_at: new Date().toISOString(),
content_type: 'text',
isTemp: true //
};
this.sessionInitList.messages.push(userMsg);
// "..."
const thinkingMsg = {
id: Date.now() + 1,
role: 'lover',
content: '思考中...',
seq: this.sessionInitList.messages.length + 1,
created_at: new Date().toISOString(),
content_type: 'text',
isThinking: true //
};
this.sessionInitList.messages.push(thinkingMsg);
//
this.form.message = '';
//
this.$nextTick(() => {
this.scrollToBottom();
});
//
this.messageSentTime = Date.now();
//
this.form.message = userMessage;
//
this.addBondform.type = 1;
this.addBondform.num = 1;
// API
// loading
this.sessionSend();
},
playVoice(id) {

View File

@ -48,22 +48,50 @@
<view class="clone-modal" v-if="cloneModalVisible" @click="cloneModalVisible = false">
<view class="clone-content" @click.stop>
<view class="clone-title">克隆音色</view>
<view class="clone-desc">上传一段清晰的音频至少3秒AI将克隆您的音色</view>
<view class="clone-desc">上传一段清晰的音频至少3秒AI将克隆您的音色可以使用中文名称</view>
<input
v-model="cloneVoiceName"
class="clone-input"
placeholder="请输入音色名称(最多10个字符"
maxlength="10">
placeholder="请输入音色名称(最多20个字符"
maxlength="20">
</input>
<view class="clone-upload-area" @click="chooseAudio">
<!-- 音频输入方式切换 -->
<view class="audio-mode-switch">
<view
class="mode-btn"
:class="{ active: audioInputMode === 'url' }"
@click="audioInputMode = 'url'">
输入 URL
</view>
<view
class="mode-btn"
:class="{ active: audioInputMode === 'file' }"
@click="audioInputMode = 'file'">
上传文件
</view>
</view>
<!-- URL 输入模式 -->
<view v-if="audioInputMode === 'url'" class="url-input-area">
<input
v-model="audioUrlInput"
class="clone-input"
placeholder="请输入音频 URLhttp:// 或 https://">
</input>
<view class="url-tip">示例http://example.com/audio.mp3</view>
</view>
<!-- 文件上传模式 -->
<view v-else class="clone-upload-area" @click="chooseAudio">
<view v-if="!audioFile" class="upload-placeholder">
<text>📁 点击选择音频文件</text>
<text class="upload-tip">支持 mp3wav 格式</text>
<text class="upload-tip">支持 mp3wav 格式最大 10MB</text>
</view>
<view v-else class="upload-success">
<text> {{ audioFile.name }}</text>
<text class="file-size">{{ (audioFile.size / 1024 / 1024).toFixed(2) }} MB</text>
</view>
</view>
@ -118,15 +146,76 @@ export default {
cloneStatus: '',
cloneVoiceId: '',
baseURLPy: 'http://127.0.0.1:8000',
// 'file' 'url'
audioInputMode: 'url', // 使 URL
audioUrlInput: '', // URL
}
},
onLoad(options) {
console.log('角色声音页面接参:',options)
this.form.gender = options.sex
console.log(this.form)
this.configVoices()
console.log('options.sex:', options.sex, 'options.is_edit:', options.is_edit);
// 使
if (options.sex) {
console.log('使用传递的性别参数:', options.sex);
this.form.gender = options.sex;
this.configVoices();
}
//
else if (options.is_edit) {
console.log('编辑模式,调用 getLoverGender()');
this.getLoverGender();
}
// 使
else {
console.log('其他情况,直接加载音色列表');
this.configVoices();
}
console.log('onLoad 结束时的 form:', this.form);
},
methods: {
//
getLoverGender() {
console.log('getLoverGender() 开始执行');
console.log('请求 URL:', this.baseURLPy + '/lover/basic');
console.log('token:', uni.getStorageSync("token"));
uni.request({
url: this.baseURLPy + '/lover/basic',
method: 'GET',
header: {
'token': uni.getStorageSync("token") || "",
},
success: (res) => {
console.log('getLoverGender() 响应:', res);
console.log('res.data:', res.data);
console.log('res.data.data:', res.data.data);
if (res.data.code === 1 && res.data.data) {
//
const gender = res.data.data.gender || 'female';
console.log('从 API 获取的性别:', gender);
this.form.gender = gender;
console.log('设置后的 form.gender:', this.form.gender);
//
this.configVoices();
} else {
console.warn('API 返回失败或数据为空,使用默认值 female');
// 使 female
this.form.gender = 'female';
this.configVoices();
}
},
fail: (error) => {
console.error('getLoverGender() 请求失败:', error);
// 使 female
this.form.gender = 'female';
this.configVoices();
}
});
},
configVoices() {
let api = ''
if(this.form.gender){
@ -274,7 +363,9 @@ export default {
this.cloneVoiceName = '';
this.audioFile = null;
this.audioUrl = '';
this.audioUrlInput = '';
this.cloneStatus = '';
this.audioInputMode = 'url'; // 使 URL
},
cancelClone() {
@ -287,9 +378,27 @@ export default {
count: 1,
extension: ['.mp3', '.wav', '.m4a'],
success: (res) => {
const file = res.tempFiles[0];
const fileSize = file.size; //
const fileSizeMB = (fileSize / 1024 / 1024).toFixed(2); // MB
console.log('选择的文件:', file);
console.log('文件大小:', fileSizeMB, 'MB');
// 10MB
if (fileSize > 10 * 1024 * 1024) {
uni.showToast({
title: `文件太大(${fileSizeMB}MB请选择小于10MB的音频`,
icon: 'none',
duration: 3000
});
return;
}
this.audioFile = {
path: res.tempFilePaths[0],
name: res.tempFiles[0].name || '音频文件'
name: file.name || '音频文件',
size: fileSize
};
console.log('选择的音频:', this.audioFile);
},
@ -314,6 +423,39 @@ export default {
return;
}
// 20
if (this.cloneVoiceName.length > 20) {
uni.showToast({
title: '音色名称不能超过20个字符',
icon: 'none'
});
return;
}
//
if (this.audioInputMode === 'url') {
// URL
if (!this.audioUrlInput.trim()) {
uni.showToast({
title: '请输入音频 URL',
icon: 'none'
});
return;
}
// URL
if (!this.audioUrlInput.startsWith('http://') && !this.audioUrlInput.startsWith('https://')) {
uni.showToast({
title: '请输入有效的 HTTP/HTTPS URL',
icon: 'none'
});
return;
}
// 使 URL
this.audioUrl = this.audioUrlInput;
} else {
//
if (!this.audioFile) {
uni.showToast({
title: '请选择音频文件',
@ -321,21 +463,35 @@ export default {
});
return;
}
}
//
if (!this.form.gender) {
console.warn('性别信息为空,使用默认值 female');
this.form.gender = 'female';
}
console.log('开始克隆,当前性别:', this.form.gender);
console.log('用户输入的名称:', this.cloneVoiceName);
console.log('音频输入模式:', this.audioInputMode);
console.log('音频 URL:', this.audioUrl);
this.cloning = true;
this.cloneStatus = 'uploading';
this.cloneStatus = 'cloning';
try {
// 1. OSS
//
if (this.audioInputMode === 'file') {
this.cloneStatus = 'uploading';
const uploadResult = await this.uploadAudio();
if (!uploadResult) {
throw new Error('上传失败');
}
this.audioUrl = uploadResult;
this.cloneStatus = 'cloning';
}
// 2. API
// API
this.cloneStatus = 'cloning';
const cloneResult = await this.callCloneAPI();
if (!cloneResult) {
throw new Error('克隆失败');
@ -344,18 +500,38 @@ export default {
this.cloneVoiceId = cloneResult.voice_id;
this.cloneStatus = 'polling';
// 3.
//
await this.pollCloneStatus();
} catch (error) {
console.error('克隆失败:', error);
this.cloneStatus = 'failed';
this.cloning = false;
//
let errorMsg = '克隆失败';
if (error.message) {
if (error.message.includes('上传失败')) {
errorMsg = '音频上传失败,请重试';
} else {
errorMsg = error.message;
}
}
uni.showToast({
title: errorMsg,
icon: 'none',
duration: 3000
});
}
},
uploadAudio() {
return new Promise((resolve, reject) => {
console.log('开始上传音频...');
console.log('上传 URL:', this.baseURL + '/api/common/upload');
console.log('音频文件:', this.audioFile);
uni.uploadFile({
url: this.baseURL + '/api/common/upload',
filePath: this.audioFile.path,
@ -364,20 +540,53 @@ export default {
token: uni.getStorageSync("token") || "",
},
success: (res) => {
console.log('上传响应:', res);
const data = JSON.parse(res.data);
console.log('解析后的数据:', data);
if (data.code === 1) {
resolve(data.data.url);
let audioUrl = data.data.url;
// URL
if (!audioUrl.startsWith('http://') && !audioUrl.startsWith('https://')) {
// URL
audioUrl = this.baseURL + audioUrl;
}
console.log('上传成功,音频 URL:', audioUrl);
resolve(audioUrl);
} else {
console.error('上传失败:', data.msg);
reject(new Error(data.msg));
}
},
fail: reject
fail: (error) => {
console.error('上传请求失败:', error);
reject(error);
}
});
});
},
callCloneAPI() {
return new Promise((resolve, reject) => {
// 使
let gender = this.form.gender;
if (!gender || gender === 'undefined' || gender === undefined) {
console.warn('性别为空,尝试重新获取');
// 使
gender = 'female';
}
const requestData = {
audio_url: this.audioUrl,
voice_name: this.cloneVoiceName,
gender: gender
};
console.log('调用克隆 API请求数据:', JSON.stringify(requestData));
console.log('性别值类型:', typeof gender, '值:', gender);
uni.request({
url: this.baseURLPy + '/config/voices/clone',
method: 'POST',
@ -385,19 +594,19 @@ export default {
'Content-Type': 'application/json',
'token': uni.getStorageSync("token") || "",
},
data: {
audio_url: this.audioUrl,
voice_name: this.cloneVoiceName,
gender: this.form.gender || 'female'
},
data: requestData,
success: (res) => {
console.log('克隆 API 响应:', res);
if (res.data.code === 1) {
resolve(res.data.data);
} else {
reject(new Error(res.data.message));
reject(new Error(res.data.message || res.data.msg || '克隆失败'));
}
},
fail: reject
fail: (error) => {
console.error('克隆 API 请求失败:', error);
reject(error);
}
});
});
},
@ -473,8 +682,12 @@ export default {
url: this.baseURLPy + `/config/voices/clone/${this.cloneVoiceId}/save`,
method: 'POST',
header: {
'Content-Type': 'application/json',
'token': uni.getStorageSync("token") || "",
},
data: {
display_name: this.cloneVoiceName //
},
success: (res) => {
if (res.data.code === 1) {
resolve(res.data.data);
@ -786,4 +999,47 @@ page {
.clone-btn.disabled {
opacity: 0.6;
}
/* 音频输入方式切换 */
.audio-mode-switch {
display: flex;
margin-bottom: 20rpx;
border: 2rpx solid #E0E0E0;
border-radius: 10rpx;
overflow: hidden;
}
.mode-btn {
flex: 1;
padding: 20rpx;
text-align: center;
font-size: 28rpx;
color: #666;
background: #F9F9F9;
transition: all 0.3s;
}
.mode-btn.active {
background: linear-gradient(135deg, #9F47FF 0%, #0053FA 100%);
color: #fff;
font-weight: 500;
}
/* URL 输入区域 */
.url-input-area {
margin-bottom: 30rpx;
}
.url-tip {
font-size: 24rpx;
color: #999;
margin-top: 10rpx;
padding-left: 10rpx;
}
.file-size {
font-size: 24rpx;
color: #999;
margin-top: 5rpx;
}
</style>

View File

@ -316,11 +316,11 @@ export const SessionInit = (data) => request({
data: data
},2)//恋人聊天初始化
export const SessionSend = (data) => request({
export const SessionSend = (data, isShowLoad = false) => request({
url: '/chat/send',
method: 'post',
data: data
},2)//恋人聊天发送
},2, isShowLoad)//恋人聊天发送
export const SessionSendImage = (data) => request({
url: '/chat/send-image',

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -4,7 +4,16 @@
4. 增加恋人消息编辑功能,更新**数据库**,加上一些编辑消息、编辑时间相关字段。用户编辑消息之后恋人不回答,只会更新记忆和摘要的数据库,下一次回答的时候会重新引用这个更新后的记忆。
- [ ] 礼物、换装、音色样式更改,但是还未更新数据库
5. 恋人消息回复增加思考中...
- [ ] 恋人消息回复和消息编辑都需要测试
- [x] 恋人消息回复和消息编辑都需要测试
6. 将Hbuilder的AppId更换成自己的原本的保留(__UNI__1F3C178)。还是无法正常编译将下面的插件注释掉不用Agora-RTC音视频插件和AudioRecode录音插件。
- [ ] 克隆音色API填写然后给你测试
- [x] 克隆音色API填写然后给你测试
7. 二维码推广功能创建邀请码邀请新用户但是没有二维码生成API
上线需调整
1. oss存储桶用来存放播放的音色
2. DashCope阿里云模型apisk-xxx 并启用下面模型
- qwen-plusAI对话聊天
- cosyvoice-v2音色克隆