faet: 新增文本消息长按翻译功能

This commit is contained in:
kuaifan 2024-10-27 07:44:32 +08:00
parent a4a9ab8d2d
commit 0c64cf0546
16 changed files with 352 additions and 30 deletions

View File

@ -2,12 +2,11 @@
namespace App\Http\Controllers\Api;
use App\Tasks\PushTask;
use DB;
use Hhxsv5\LaravelS\Swoole\Task\Task;
use Request;
use Redirect;
use Carbon\Carbon;
use App\Tasks\PushTask;
use App\Models\File;
use App\Models\User;
use App\Module\Base;
@ -20,6 +19,8 @@ use App\Models\WebSocketDialogMsg;
use App\Models\WebSocketDialogUser;
use App\Models\WebSocketDialogMsgRead;
use App\Models\WebSocketDialogMsgTodo;
use App\Models\WebSocketDialogMsgTranslate;
use Hhxsv5\LaravelS\Swoole\Task\Task;
/**
* @apiDefine dialog
@ -1539,6 +1540,75 @@ class DialogController extends AbstractController
return Base::retSuccess("success", $msg);
}
/**
* @api {get} api/dialog/msg/translation 31. 翻译消息
*
* @apiDescription 将文本消息翻译成当前语言需要token身份
* @apiVersion 1.0.0
* @apiGroup dialog
* @apiName msg__translation
*
* @apiParam {Number} msg_id 消息ID
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*/
public function msg__translation()
{
User::auth();
//
$msg_id = intval(Request::input("msg_id"));
$language = Base::headerOrInput('language');
$targetLanguage = match ($language) {
"zh" => "简体中文",
"zh-CHT" => "繁体中文",
"en" => "英语",
"ko" => "韩语",
"ja" => "日语",
"de" => "德语",
"fr" => "法语",
"id" => "印度尼西亚语",
"ru" => "俄语",
default => '',
};
//
if (empty($targetLanguage)) {
return Base::retError("参数错误");
}
$msg = WebSocketDialogMsg::whereId($msg_id)->first();
if (empty($msg)) {
return Base::retError("消息不存在或已被删除");
}
if (!in_array($msg->type, ['text', 'record'])) {
return Base::retError("此消息不支持翻译");
}
WebSocketDialog::checkDialog($msg->dialog_id);
//
$row = WebSocketDialogMsgTranslate::whereMsgId($msg_id)->whereLanguage($language)->first();
if ($row) {
return Base::retSuccess("success", $row->only(['msg_id', 'language', 'content']));
}
//
$msgData = Base::json2array($msg->getRawOriginal('msg'));
if (empty($msgData['text'])) {
return Base::retError("消息内容为空");
}
$res = Extranet::openAItranslations($msgData['text'], $targetLanguage);
if (Base::isError($res)) {
return $res;
}
$row = WebSocketDialogMsgTranslate::createInstance([
'dialog_id' => $msg->dialog_id,
'msg_id' => $msg_id,
'language' => $language,
'content' => $res['data'],
]);
$row->save();
//
return Base::retSuccess("success", $row->only(['msg_id', 'language', 'content']));
}
/**
* @api {get} api/dialog/msg/mark 32. 消息标记操作
*

View File

@ -40,7 +40,7 @@ class SystemController extends AbstractController
* @apiParam {String} type
* - get: 获取(默认)
* - all: 获取所有(需要管理员权限)
* - save: 保存设置(参数:['reg', 'reg_identity', 'reg_invite', 'temp_account_alias', 'login_code', 'password_policy', 'project_invite', 'chat_information', 'anon_message', 'voice2text', 'e2e_message', 'auto_archived', 'archived_day', 'task_visible', 'task_default_time', 'all_group_mute', 'all_group_autoin', 'user_private_chat_mute', 'user_group_chat_mute', 'image_compress', 'image_save_local', 'start_home']
* - save: 保存设置(参数:['reg', 'reg_identity', 'reg_invite', 'temp_account_alias', 'login_code', 'password_policy', 'project_invite', 'chat_information', 'anon_message', 'voice2text', 'translation', 'e2e_message', 'auto_archived', 'archived_day', 'task_visible', 'task_default_time', 'all_group_mute', 'all_group_autoin', 'user_private_chat_mute', 'user_group_chat_mute', 'image_compress', 'image_save_local', 'start_home']
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
@ -67,6 +67,7 @@ class SystemController extends AbstractController
'chat_information',
'anon_message',
'voice2text',
'translation',
'e2e_message',
'auto_archived',
'archived_day',
@ -97,6 +98,9 @@ class SystemController extends AbstractController
if ($all['voice2text'] == 'open' && empty(Base::settingFind('aibotSetting', 'openai_key'))) {
return Base::retError('开启语音转文字功能需要在应用中开启 ChatGPT AI 机器人。');
}
if ($all['translation'] == 'open' && empty(Base::settingFind('aibotSetting', 'openai_key'))) {
return Base::retError('开启翻译功能需要在应用中开启 ChatGPT AI 机器人。');
}
$setting = Base::setting('system', Base::newTrim($all));
} else {
$setting = Base::setting('system');
@ -118,6 +122,7 @@ class SystemController extends AbstractController
$setting['chat_information'] = $setting['chat_information'] ?: 'optional';
$setting['anon_message'] = $setting['anon_message'] ?: 'open';
$setting['voice2text'] = $setting['voice2text'] ?: 'close';
$setting['translation'] = $setting['translation'] ?: 'close';
$setting['e2e_message'] = $setting['e2e_message'] ?: 'close';
$setting['auto_archived'] = $setting['auto_archived'] ?: 'close';
$setting['archived_day'] = floatval($setting['archived_day']) ?: 7;

View File

@ -0,0 +1,36 @@
<?php
namespace App\Models;
/**
* App\Models\WebSocketDialogMsgTranslate
*
* @property int $id
* @property int|null $dialog_id 对话ID
* @property int|null $msg_id 消息ID
* @property string|null $language 语言
* @property string|null $content 翻译内容
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelAppend()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelHidden()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel change($array)
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel getKeyValue()
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialogMsgTranslate newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialogMsgTranslate newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialogMsgTranslate query()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialogMsgTranslate whereContent($value)
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialogMsgTranslate whereDialogId($value)
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialogMsgTranslate whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialogMsgTranslate whereLanguage($value)
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialogMsgTranslate whereMsgId($value)
* @mixin \Eloquent
*/
class WebSocketDialogMsgTranslate extends AbstractModel
{
function __construct(array $attributes = [])
{
parent::__construct($attributes);
$this->timestamps = false;
}
}

View File

@ -55,6 +55,59 @@ class Extranet
return Base::retSuccess("success", $resData['text']);
}
/**
* 通过 openAI 翻译
* @param $text
* @param $targetLanguage
* @return array
*/
public static function openAItranslations($text, $targetLanguage)
{
$systemSetting = Base::setting('system');
$aibotSetting = Base::setting('aibotSetting');
if ($systemSetting['translation'] !== 'open' || empty($aibotSetting['openai_key'])) {
return Base::retError("翻译功能未开启");
}
$extra = [
'Content-Type' => 'application/json',
'Authorization' => 'Bearer ' . $aibotSetting['openai_key'],
];
if ($aibotSetting['openai_agency']) {
$extra['CURLOPT_PROXY'] = $aibotSetting['openai_agency'];
if (str_contains($aibotSetting['openai_agency'], 'socks')) {
$extra['CURLOPT_PROXYTYPE'] = CURLPROXY_SOCKS5;
} else {
$extra['CURLOPT_PROXYTYPE'] = CURLPROXY_HTTP;
}
}
$res = Ihttp::ihttp_request('https://api.openai.com/v1/chat/completions', json_encode([
"model" => "gpt-3.5-turbo",
"messages" => [
[
"role" => "system",
"content" => "你是一个专业的翻译器,翻译的结果尽量符合“项目任务管理系统”的使用,并且翻译的结果不用额外添加换行尽量保持原格式,将提供的文本翻译成“{$targetLanguage}”语言。"
],
[
"role" => "user",
"content" => $text
]
]
]), $extra, 15);
if (Base::isError($res)) {
return Base::retError("翻译失败", $res);
}
$resData = Base::json2array($res['data']);
if (empty($resData['choices'])) {
return Base::retError("翻译失败", $resData);
}
$result = $resData['choices'][0]['message']['content'];
$result = preg_replace('/^\"|\"$/', '', $result);
if (empty($result)) {
return Base::retError("翻译失败", $result);
}
return Base::retSuccess("success", $result);
}
/**
* 获取IP地址经纬度
* @param string $ip

View File

@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateWebSocketDialogMsgTranslatesTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('web_socket_dialog_msg_translates', function (Blueprint $table) {
$table->bigIncrements('id');
$table->bigInteger('dialog_id')->nullable()->default(0)->comment('对话ID');
$table->bigInteger('msg_id')->nullable()->default(0)->comment('消息ID');
$table->string('language', 50)->nullable()->default('')->comment('语言');
$table->longText('content')->nullable()->comment('翻译内容');
$table->index(['msg_id', 'language']);
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('web_socket_dialog_msg_translates');
}
}

View File

@ -26,11 +26,11 @@
<!--详情-->
<div ref="content" class="dialog-content" :class="contentClass">
<!--文本-->
<TextMsg v-if="msgData.type === 'text'" :msg="msgData.msg" @viewText="viewText"/>
<TextMsg v-if="msgData.type === 'text'" :msgId="msgData.id" :msg="msgData.msg" @viewText="viewText"/>
<!--文件-->
<FileMsg v-else-if="msgData.type === 'file'" :msg="msgData.msg" @viewFile="viewFile" @downFile="downFile"/>
<!--录音-->
<RecordMsg v-else-if="msgData.type === 'record'" :msg="msgData.msg" @playRecord="playRecord"/>
<RecordMsg v-else-if="msgData.type === 'record'" :msgId="msgData.id" :msg="msgData.msg" @playRecord="playRecord"/>
<!--会议-->
<MeetingMsg v-else-if="msgData.type === 'meeting'" :msg="msgData.msg" @openMeeting="openMeeting"/>
<!--接龙-->

View File

@ -4,21 +4,45 @@
<div class="record-time">{{recordDuration(msg.duration)}}</div>
<div class="record-icon taskfont"></div>
</div>
<div v-if="msg.text" class="dialog-record-text">
{{msg.text}}
</div>
<template v-if="msg.text">
<div class="content-divider">
<span class="divider-full"></span>
</div>
<div class="content-additional">{{msg.text}}</div>
</template>
<template v-if="translation">
<div class="content-divider">
<span></span>
<div class="divider-label">{{ translation.label }}</div>
<span></span>
</div>
<div class="content-additional">{{translation.value}}</div>
</template>
</div>
</template>
<script lang="ts">
import {mapState} from "vuex";
import DialogMarkdown from "../DialogMarkdown.vue";
import {languageName} from "../../../../language";
export default {
components: {DialogMarkdown},
props: {
msgId: Number,
msg: Object,
},
computed: {
...mapState(['audioPlaying']),
...mapState(['audioPlaying', 'cacheTranslations']),
translation() {
const translation = this.cacheTranslations.find(item => {
return item.key === `msg-${this.msgId}` && item.lang === languageName;
});
return translation ? translation : null;
},
},
methods: {
playRecord() {

View File

@ -2,17 +2,40 @@
<div class="content-text no-dark-content">
<DialogMarkdown v-if="msg.type === 'md'" @click="viewText" :text="msg.text"/>
<pre v-else @click="viewText" v-html="$A.formatTextMsg(msg.text, userId)"></pre>
<template v-if="translation">
<div class="content-divider">
<span></span>
<div class="divider-label">{{ translation.label }}</div>
<span></span>
</div>
<DialogMarkdown v-if="msg.type === 'md'" :text="translation.value"/>
<pre v-else v-html="$A.formatTextMsg(translation.value, userId)"></pre>
</template>
</div>
</template>
<script lang="ts">
import {mapState} from "vuex";
import DialogMarkdown from "../DialogMarkdown.vue";
import {languageName} from "../../../../language";
export default {
components: {DialogMarkdown},
props: {
msgId: Number,
msg: Object,
},
computed: {
...mapState(['cacheTranslations']),
translation() {
const translation = this.cacheTranslations.find(item => {
return item.key === `msg-${this.msgId}` && item.lang === languageName;
});
return translation ? translation : null;
},
},
methods: {
viewText(e) {
this.$emit('viewText', e);

View File

@ -310,6 +310,10 @@
<i class="taskfont">&#xe628;</i>
<span>{{ $L('转文字') }}</span>
</li>
<li v-if="actionPermission(operateItem, 'translation')" @click="onOperate('translation')">
<i class="taskfont">&#xe795;</i>
<span>{{ $L('翻译') }}</span>
</li>
<li v-for="item in operateCopys" @click="onOperate('copy', item)">
<i class="taskfont" v-html="item.icon"></i>
<span>{{ $L(item.label) }}</span>
@ -2897,6 +2901,10 @@ export default {
this.onVoice2text()
break;
case "translation":
this.onTranslation()
break;
case "copy":
this.onCopy(value)
break;
@ -3020,6 +3028,9 @@ export default {
return;
}
const {id: msg_id} = this.operateItem
if (this.isLoad(`msg-${msg_id}`)) {
return;
}
this.$store.dispatch("setLoad", `msg-${msg_id}`)
this.$store.dispatch("call", {
url: 'dialog/msg/voice2text',
@ -3035,6 +3046,32 @@ export default {
});
},
onTranslation() {
if (!this.actionPermission(this.operateItem, 'translation')) {
return;
}
const {id: msg_id} = this.operateItem
if (this.isLoad(`msg-${msg_id}`)) {
return;
}
this.$store.dispatch("setLoad", `msg-${msg_id}`)
this.$store.dispatch("call", {
url: 'dialog/msg/translation',
data: {
msg_id
},
}).then(({data}) => {
this.$store.dispatch("saveTranslation", {
key: `msg-${msg_id}`,
value: data.content,
});
}).catch(({msg}) => {
$A.messageError(msg);
}).finally(_ => {
this.$store.dispatch("cancelLoad", `msg-${msg_id}`)
});
},
onCopy(data) {
if (!$A.isJson(data)) {
return
@ -3621,10 +3658,10 @@ export default {
if (item.msg.text) {
return false;
}
if (this.isLoad(`msg-${item.id}`)) {
return false;
}
break;
case 'translation':
return ['text', 'record'].includes(item.type) && item.msg.text //
}
return true // true
},

View File

@ -189,6 +189,14 @@
<div v-if="formDatum.voice2text == 'open'" class="form-tip">{{$L('长按语音消息可转换成文字')}} ({{$L('需要在应用中开启 ChatGPT AI 机器人')}})</div>
<div v-else class="form-tip">{{$L('关闭语音转文字功能。')}}</div>
</FormItem>
<FormItem :label="$L('翻译消息')" prop="translation">
<RadioGroup v-model="formDatum.translation">
<Radio label="open">{{$L('开启')}}</Radio>
<Radio label="close">{{$L('关闭')}}</Radio>
</RadioGroup>
<div v-if="formDatum.translation == 'open'" class="form-tip">{{$L('长按文本消息可翻译成当前设置的语言')}} ({{$L('需要在应用中开启 ChatGPT AI 机器人')}})</div>
<div v-else class="form-tip">{{$L('关闭文本消息翻译功能。')}}</div>
</FormItem>
<FormItem :label="$L('端到端加密')" prop="e2eMessage">
<RadioGroup v-model="formDatum.e2e_message">
<Radio label="open">{{$L('开启')}}</Radio>

View File

@ -1,6 +1,6 @@
import {Store} from 'le5le-store';
import * as openpgp from 'openpgp_hi/lightweight';
import {languageName} from "../language";
import {languageList, languageName} from "../language";
import {$callData, $urlSafe, SSEClient} from './utils'
export default {
@ -826,6 +826,7 @@ export default {
const cacheLoginEmail = await $A.IDBString("cacheLoginEmail");
const cacheFileSort = await $A.IDBJson("cacheFileSort");
const cacheTaskBrowse = await $A.IDBArray("cacheTaskBrowse")
const cacheTranslations = await $A.IDBArray("cacheTranslations")
const cacheEmojis = await $A.IDBArray("cacheEmojis")
const userInfo = await $A.IDBJson("userInfo")
await $A.IDBClear();
@ -835,6 +836,7 @@ export default {
await $A.IDBSet("cacheLoginEmail", cacheLoginEmail);
await $A.IDBSet("cacheFileSort", cacheFileSort);
await $A.IDBSet("cacheTaskBrowse", cacheTaskBrowse);
await $A.IDBSet("cacheTranslations", cacheTranslations);
await $A.IDBSet("cacheEmojis", cacheEmojis);
await $A.IDBSet("cacheVersion", state.cacheVersion)
@ -865,6 +867,7 @@ export default {
state.cacheTasks = await $A.IDBArray("cacheTasks")
state.cacheProjectParameter = await $A.IDBArray("cacheProjectParameter")
state.cacheTaskBrowse = await $A.IDBArray("cacheTaskBrowse")
state.cacheTranslations = await $A.IDBArray("cacheTranslations")
state.dialogMsgs = await $A.IDBArray("dialogMsgs")
state.fileLists = await $A.IDBArray("fileLists")
state.userInfo = await $A.IDBJson("userInfo")
@ -3341,6 +3344,27 @@ export default {
}
},
/**
* 保存翻译
* @param state
* @param dispatch
* @param data {key, value}
*/
saveTranslation({state, dispatch}, data) {
if (!$A.isJson(data)) {
return
}
const item = state.cacheTranslations.find(item => item.key == data.key && item.lang == languageName)
if (item) {
item.value = data.value
} else {
data.lang = languageName
data.label = languageList[languageName] || languageName
state.cacheTranslations.push(data)
}
$A.IDBSave("cacheTranslations", state.cacheTranslations.slice(-200))
},
/** *****************************************************************************************/
/** ************************************* loads *********************************************/
/** *****************************************************************************************/

View File

@ -231,5 +231,8 @@ export default {
model: 'details',
id: 0,
show: false
}
},
// 翻译
cacheTranslations: [],
};

View File

@ -1096,22 +1096,6 @@
}
}
}
.dialog-record-text {
position: relative;
margin-top: 8px;
padding-top: 8px;
&:before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background-color: rgba(255, 255, 255, 0.2);
transform: scaleY(0.5);
}
}
}
.content-meeting {
@ -1412,6 +1396,26 @@
}
}
.content-divider {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
margin: 6px 0;
> span {
flex: 1;
height: 1px;
background-color: rgba(255, 255, 255, 0.2);
transform: scaleY(0.5);
min-width: 18px;
}
.divider-label {
font-size: 12px;
padding: 0 8px;
opacity: 0.6;
}
}
.mention {
color: $flow-status-end-color;
background-color: transparent;