perf: 录音转文字支持自定义语言

This commit is contained in:
kuaifan 2025-03-09 15:32:38 +08:00
parent e53b65496f
commit e34aa77a54
9 changed files with 152 additions and 50 deletions

View File

@ -1356,7 +1356,15 @@ class DialogController extends AbstractController
* *
* @apiParam {String} base64 语音base64 * @apiParam {String} base64 语音base64
* @apiParam {Number} duration 语音时长(毫秒) * @apiParam {Number} duration 语音时长(毫秒)
* @apiParam {String} [language] 语音语言比如zh默认当前用户语言 * @apiParam {String} [language] 识别语言
* - 比如zh
* - 默认:自动识别
* - 格式:符合 ISO_639 标准
* - 此参数不一定起效果AI会根据语音和language参考翻译识别结果
* @apiParam {String} [translate] 翻译识别结果
* - 比如zh
* - 默认:不翻译结果
* - 格式:符合 ISO_639 标准
* *
* @apiSuccess {Number} ret 返回状态码1正确、0错误 * @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述) * @apiSuccess {String} msg 返回信息(错误描述)
@ -1370,10 +1378,12 @@ class DialogController extends AbstractController
$path = "uploads/tmp/chat/" . date("Ym") . "/" . $user->userid . "/"; $path = "uploads/tmp/chat/" . date("Ym") . "/" . $user->userid . "/";
$base64 = Request::input('base64'); $base64 = Request::input('base64');
$language = Request::input('language'); $language = Request::input('language');
$translate = Request::input('translate');
$duration = intval(Request::input('duration')); $duration = intval(Request::input('duration'));
if ($duration < 600) { if ($duration < 600) {
return Base::retError('说话时间太短'); return Base::retError('说话时间太短');
} }
// 保存录音
$data = Base::record64save([ $data = Base::record64save([
"base64" => $base64, "base64" => $base64,
"path" => $path, "path" => $path,
@ -1382,28 +1392,30 @@ class DialogController extends AbstractController
return Base::retError($data['msg']); return Base::retError($data['msg']);
} }
$recordData = $data['data']; $recordData = $data['data'];
// 转文字
$extParams = []; $extParams = [];
if ($language) { if ($language) {
$targetLanguage = Doo::getLanguages($language);
if (empty($targetLanguage)) {
return Base::retError("参数错误");
}
$extParams = [ $extParams = [
'language' => match ($language) { 'language' => $language === 'zh-CHT' ? 'zh' : $language,
'zh-CHT' => 'zh', 'prompt' => "将此语音识别为“" . Doo::getLanguages($language) . "”。",
default => $language,
},
'prompt' => "此音频为“{$targetLanguage}”语言。",
]; ];
} }
$res = Extranet::openAItranscriptions($recordData['file'], $extParams); $result = Extranet::openAItranscriptions($recordData['file'], $extParams);
if (Base::isError($res)) { if (Base::isError($result)) {
return $res; return $result;
} }
if (strlen($res['data']) < 1) { if (strlen($result['data']) < 1) {
return Base::retError('转文字失败'); return Base::retError('转文字失败');
} }
return $res; // 翻译
if ($translate) {
$result = Extranet::openAItranslations($result['data'], Doo::getLanguages($translate));
if (Base::isError($result)) {
return $result;
}
}
// 返回
return $result;
} }
/** /**

View File

@ -28,7 +28,6 @@ class Extranet
if ($systemSetting['voice2text'] !== 'open' || empty($aibotSetting['openai_key'])) { if ($systemSetting['voice2text'] !== 'open' || empty($aibotSetting['openai_key'])) {
return Base::retError("语音转文字功能未开启"); return Base::retError("语音转文字功能未开启");
} }
//
$extra = [ $extra = [
'Content-Type' => 'multipart/form-data', 'Content-Type' => 'multipart/form-data',
'Authorization' => 'Bearer ' . $aibotSetting['openai_key'], 'Authorization' => 'Bearer ' . $aibotSetting['openai_key'],
@ -41,7 +40,6 @@ class Extranet
'file' => new \CURLFile($filePath), 'file' => new \CURLFile($filePath),
'model' => 'whisper-1', 'model' => 'whisper-1',
]); ]);
// 转文字
$cacheKey = "openAItranscriptions::" . md5($filePath . '_' . Base::array2json($extra) . '_' . Base::array2json($extParams)); $cacheKey = "openAItranscriptions::" . md5($filePath . '_' . Base::array2json($extra) . '_' . Base::array2json($extParams));
$result = Cache::remember($cacheKey, Carbon::now()->addDays(), function() use ($extra, $post) { $result = Cache::remember($cacheKey, Carbon::now()->addDays(), function() use ($extra, $post) {
$res = Ihttp::ihttp_request('https://api.openai.com/v1/audio/transcriptions', $post, $extra, 15); $res = Ihttp::ihttp_request('https://api.openai.com/v1/audio/transcriptions', $post, $extra, 15);
@ -56,12 +54,6 @@ class Extranet
}); });
if (Base::isError($result)) { if (Base::isError($result)) {
Cache::forget($cacheKey); Cache::forget($cacheKey);
} elseif ($extParams['language']) {
// 翻译
$translResult = self::openAItranslations($result['data'], Doo::getLanguages($extParams['language']));
if (Base::isSuccess($result)) {
$result = $translResult;
}
} }
return $result; return $result;
} }
@ -92,11 +84,19 @@ class Extranet
"messages" => [ "messages" => [
[ [
"role" => "system", "role" => "system",
"content" => "你是一个专业的翻译器,请将<text>标签里面的内容翻译成“{$targetLanguage}”语言,翻译的结果尽量符合“项目任务管理系统”的使用并且保持原格式。" "content" => <<<EOF
你是一名专业翻译人员,请将 <translation_original_text> 标签内的内容翻译为{$targetLanguage}
翻译要求:
- 翻译结果需符合“项目任务管理系统”的专业术语和使用场景。
- 保持原文格式、结构和排版不变。
- 语言表达准确、简洁,符合项目管理领域的行业规范。
- 注意专业术语的一致性和连贯性。
EOF
], ],
[ [
"role" => "user", "role" => "user",
"content" => "<text>{$text}</text>" "content" => "<translation_original_text>{$text}</translation_original_text>"
] ]
] ]
]); ]);
@ -112,7 +112,7 @@ class Extranet
} }
$result = $resData['choices'][0]['message']['content']; $result = $resData['choices'][0]['message']['content'];
$result = preg_replace('/^\"|\"$/', '', trim($result)); $result = preg_replace('/^\"|\"$/', '', trim($result));
$result = preg_replace('/^<text>|<\/text>$/', '', trim($result)); $result = preg_replace('/<\/*translation_original_text>/', '', trim($result));
if (empty($result)) { if (empty($result)) {
return Base::retError("翻译失败", $result); return Base::retError("翻译失败", $result);
} }

View File

@ -11,14 +11,17 @@
<div ref="icon" class="general-operation-icon"></div> <div ref="icon" class="general-operation-icon"></div>
<EDropdownMenu ref="dropdownMenu" slot="dropdown" class="general-operation-more-dropdown menu-dropdown"> <EDropdownMenu ref="dropdownMenu" slot="dropdown" class="general-operation-more-dropdown menu-dropdown">
<li class="general-operation-more-warp small"> <li class="general-operation-more-warp small">
<ul> <ul :style="ulStyle">
<EDropdownItem <EDropdownItem
v-for="(item, key) in list" v-for="(item, key) in list"
:key="key" :key="key"
:command="item.value" :command="item.value"
:divided="!!item.divided" :divided="!!item.divided"
:disabled="active === item.value"> :disabled="active === item.value || !!item.disabled">
<div class="item">{{item.label}}</div> <div class="item">{{item.label}}</div>
<div v-if="tickShow" class="tick">
<i v-if="active === item.value && !item.disabled" class="taskfont">&#xe684;</i>
</div>
</EDropdownItem> </EDropdownItem>
</ul> </ul>
</li> </li>
@ -37,6 +40,8 @@ export default {
active: '', // active: '', //
onUpdate: null, // onUpdate: null, //
scrollHide: false, // scrollHide: false, //
tickShow: true, //
maxHeight: 0, //
element: null, element: null,
target: null, target: null,
@ -51,7 +56,11 @@ export default {
}, },
computed: { computed: {
...mapState(['menuOperation']) ...mapState(['menuOperation']),
ulStyle({maxHeight}) {
return maxHeight > 0 ? {maxHeight: `${maxHeight}px`} : {};
}
}, },
watch: { watch: {
@ -72,6 +81,8 @@ export default {
this.active = data.active && this.list.find(item => item.value === data.active) ? data.active : ''; this.active = data.active && this.list.find(item => item.value === data.active) ? data.active : '';
this.onUpdate = typeof data.onUpdate === "function" ? data.onUpdate : null; this.onUpdate = typeof data.onUpdate === "function" ? data.onUpdate : null;
this.scrollHide = typeof data.scrollHide === "boolean" ? data.scrollHide : false; this.scrollHide = typeof data.scrollHide === "boolean" ? data.scrollHide : false;
this.tickShow = typeof data.tickShow === "boolean" ? data.tickShow : true;
this.maxHeight = typeof data.maxHeight === "number" ? data.maxHeight : 0;
// //
this.$refs.icon.focus(); this.$refs.icon.focus();
this.show(); this.show();

View File

@ -208,8 +208,9 @@
<div class="convert-box"> <div class="convert-box">
<div class="convert-body"> <div class="convert-body">
<div class="convert-content"> <div class="convert-content">
<div class="convert-setting" @click="convertSetting"> <div class="convert-setting">
<i class="taskfont">&#xe691;</i> <i class="taskfont" :class="{active: !!cacheTranscriptionLanguage}" @click="convertSetting('transcription', $event)">&#xe628;</i>
<i class="taskfont" :class="{active: !!recordConvertTranslate}" @click="convertSetting('translate', $event)">&#xe795;</i>
</div> </div>
<div class="convert-input"> <div class="convert-input">
<Input <Input
@ -388,9 +389,9 @@ export default {
recordConvertIng: false, recordConvertIng: false,
recordConvertFocus: false, recordConvertFocus: false,
recordConvertStatus: 0, // 0: 1: 2: recordConvertStatus: 0, // 0: 1: 2:
recordConvertResult: '', recordConvertResult: '',
recordConvertLanguage: '', recordConvertTranslate: '', //
touchStart: {}, touchStart: {},
touchFocus: false, touchFocus: false,
@ -528,6 +529,7 @@ export default {
'cacheDialogs', 'cacheDialogs',
'dialogMsgs', 'dialogMsgs',
'cacheTranscriptionLanguage',
'cacheKeyboard', 'cacheKeyboard',
'keyboardType', 'keyboardType',
'isModKey', 'isModKey',
@ -1408,7 +1410,8 @@ export default {
dialog_id: this.dialogId, dialog_id: this.dialogId,
base64: reader.result, base64: reader.result,
duration: this.recordDuration, duration: this.recordDuration,
language: this.recordConvertLanguage || getLanguage() language: this.cacheTranscriptionLanguage,
translate: this.recordConvertTranslate
}, },
method: 'post', method: 'post',
}).then(({data}) => { }).then(({data}) => {
@ -1422,22 +1425,43 @@ export default {
reader.readAsDataURL(this.recordBlob); reader.readAsDataURL(this.recordBlob);
}, },
async convertSetting(event) { async convertSetting(type, event) {
if (this.recordConvertStatus !== 1) {
$A.messageWarning("正在识别中,请稍后")
return;
}
await this.$nextTick() await this.$nextTick()
const list = Object.keys(languageList).map(item => ({ const list = Object.keys(languageList).map(item => ({
label: languageList[item], label: languageList[item],
value: item value: item
})) }))
list.unshift(...[ let active
{label: '自动识别', value: ''}, if (type === 'transcription') {
]) //
list.unshift(...[
{label: '选择识别语言', value: '', disabled: true},
{label: '自动识别', value: ''},
])
active = this.cacheTranscriptionLanguage
} else {
//
list.unshift(...[
{label: '选择翻译结果', value: '', disabled: true},
{label: '不翻译结果', value: ''},
])
active = this.recordConvertTranslate
}
this.$store.state.menuOperation = { this.$store.state.menuOperation = {
event, event,
list, list,
active: this.recordConvertLanguage, active,
scrollHide: true, scrollHide: true,
onUpdate: async (language) => { onUpdate: async (language) => {
this.recordConvertLanguage = language if (type === 'transcription') {
await this.$store.dispatch('setTranscriptionLanguage', language)
} else {
this.recordConvertTranslate = language
}
this.convertRecord() this.convertRecord()
} }
} }

View File

@ -922,6 +922,7 @@ export default {
cacheFileSort: await $A.IDBJson("cacheFileSort"), cacheFileSort: await $A.IDBJson("cacheFileSort"),
cacheTaskBrowse: await $A.IDBArray("cacheTaskBrowse"), cacheTaskBrowse: await $A.IDBArray("cacheTaskBrowse"),
cacheTranslationLanguage: await $A.IDBString("cacheTranslationLanguage"), cacheTranslationLanguage: await $A.IDBString("cacheTranslationLanguage"),
cacheTranscriptionLanguage: await $A.IDBString("cacheTranscriptionLanguage"),
cacheTranslations: await $A.IDBArray("cacheTranslations"), cacheTranslations: await $A.IDBArray("cacheTranslations"),
cacheEmojis: await $A.IDBArray("cacheEmojis"), cacheEmojis: await $A.IDBArray("cacheEmojis"),
userInfo: await $A.IDBJson("userInfo"), userInfo: await $A.IDBJson("userInfo"),
@ -957,7 +958,8 @@ export default {
string: [ string: [
'clientId', 'clientId',
'cacheServerUrl', 'cacheServerUrl',
'cacheTranslationLanguage' 'cacheTranslationLanguage',
'cacheTranscriptionLanguage'
], ],
array: [ array: [
'cacheUserBasic', 'cacheUserBasic',
@ -1002,6 +1004,11 @@ export default {
state.cacheTranslationLanguage = languageName; state.cacheTranslationLanguage = languageName;
} }
// TranscriptionLanguage检查
if (typeof languageList[state.cacheTranscriptionLanguage] === "undefined") {
state.cacheTranscriptionLanguage = '';
}
// 处理用户信息 // 处理用户信息
if (state.userInfo.userid) { if (state.userInfo.userid) {
state.userId = state.userInfo.userid = $A.runNum(state.userInfo.userid); state.userId = state.userInfo.userid = $A.runNum(state.userInfo.userid);
@ -3627,6 +3634,16 @@ export default {
$A.IDBSave('cacheTranslationLanguage', language); $A.IDBSave('cacheTranslationLanguage', language);
}, },
/**
* 设置语音转文字语言
* @param state
* @param language
*/
setTranscriptionLanguage({state}, language) {
state.cacheTranscriptionLanguage = language
$A.IDBSave('cacheTranscriptionLanguage', language);
},
/** *****************************************************************************************/ /** *****************************************************************************************/
/** ************************************* loads *********************************************/ /** ************************************* loads *********************************************/
/** *****************************************************************************************/ /** *****************************************************************************************/

View File

@ -245,6 +245,9 @@ export default {
cacheTranslationLanguage: '', cacheTranslationLanguage: '',
cacheTranslations: [], cacheTranslations: [],
// 语音转文字(识别语言)
cacheTranscriptionLanguage: '',
// 下拉菜单操作 // 下拉菜单操作
menuOperation: {} menuOperation: {}
}; };

View File

@ -580,6 +580,17 @@ body.dark-mode-reverse {
.chat-input-convert-transfer { .chat-input-convert-transfer {
background-color: rgba(255, 255, 255, 0.9); background-color: rgba(255, 255, 255, 0.9);
.convert-box { .convert-box {
.convert-body {
.convert-content {
.convert-setting {
> i {
&.active {
color: #000000;
}
}
}
}
}
.convert-footer { .convert-footer {
color: #000000; color: #000000;
> li { > li {

View File

@ -768,20 +768,28 @@
width: 88%; width: 88%;
transform: translateY(12px); transform: translateY(12px);
.convert-setting { .convert-setting {
color: #4d4d4d;
background-color: #c7c7c7;
opacity: 0.7;
padding: 5px;
margin: 0 2px 8px 0; margin: 0 2px 8px 0;
border-radius: 50%;
width: 26px;
height: 26px;
display: flex; display: flex;
justify-content: center; gap: 12px;
align-items: center; align-items: center;
cursor: pointer;
> i { > i {
color: #4d4d4d;
background-color: #c7c7c7;
opacity: 0.7;
padding: 5px;
border-radius: 50%;
width: 26px;
height: 26px;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
font-size: 18px; font-size: 18px;
&.active {
background-color: $primary-color;
color: #ffffff;
opacity: 1
}
} }
} }
.convert-input { .convert-input {

View File

@ -32,6 +32,10 @@
} }
> li { > li {
display: flex;
align-items: center;
justify-content: space-between;
.item { .item {
display: flex; display: flex;
align-items: center; align-items: center;
@ -63,6 +67,18 @@
} }
} }
.tick {
color: $primary-color;
transform: translateX(40%);
width: 26px;
height: 26px;
text-align: right;
margin-left: 6px;
> i {
font-size: 14px;
}
}
.flow { .flow {
padding: 4px 0; padding: 4px 0;