mirror of
https://github.com/kuaifan/dootask.git
synced 2025-12-11 18:42:54 +00:00
feat: 优化 AI 助手,支持自定义模型
This commit is contained in:
parent
eaec8ef994
commit
e83fd7af1b
418
resources/assets/js/components/AIAssistant.vue
Normal file
418
resources/assets/js/components/AIAssistant.vue
Normal file
@ -0,0 +1,418 @@
|
||||
<template>
|
||||
<Modal
|
||||
v-model="showModal"
|
||||
:title="$L('AI 助手')"
|
||||
:mask-closable="false"
|
||||
:closable="false"
|
||||
:styles="{
|
||||
width: '90%',
|
||||
maxWidth: '420px'
|
||||
}"
|
||||
class-name="ai-assistant-modal">
|
||||
<div class="ai-assistant-content">
|
||||
<div class="ai-assistant-input">
|
||||
<Input
|
||||
v-model="inputValue"
|
||||
type="textarea"
|
||||
:placeholder="inputPlaceholder"
|
||||
:rows="inputRows"
|
||||
:autosize="inputAutosize"
|
||||
:maxlength="inputMaxlength" />
|
||||
</div>
|
||||
</div>
|
||||
<div slot="footer" class="ai-assistant-footer">
|
||||
<div class="ai-assistant-footer-models">
|
||||
<Select
|
||||
v-model="inputModel"
|
||||
:placeholder="$L('选择模型')"
|
||||
:loading="modelsLoading"
|
||||
:disabled="modelsLoading || modelGroups.length === 0"
|
||||
:not-found-text="$L('暂无可用模型')"
|
||||
transfer>
|
||||
<OptionGroup
|
||||
v-for="group in modelGroups"
|
||||
:key="group.type"
|
||||
:label="$L(group.label)">
|
||||
<Option
|
||||
v-for="option in group.options"
|
||||
:key="option.id"
|
||||
:value="option.id">
|
||||
{{ option.label }}
|
||||
</Option>
|
||||
</OptionGroup>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="ai-assistant-footer-btns">
|
||||
<Button type="text" @click="showModal=false">{{ $L('取消') }}</Button>
|
||||
<Button type="primary" :loading="loadIng > 0" @click="onSubmit">{{ $L('确定') }}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {mapState} from "vuex";
|
||||
import emitter from "../store/events";
|
||||
import {AIBotList, AIModelNames} from "../utils/ai";
|
||||
|
||||
export default {
|
||||
name: 'AIAssistant',
|
||||
props: {
|
||||
defaultPlaceholder: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
defaultInputRows: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
},
|
||||
defaultInputAutosize: {
|
||||
type: Object,
|
||||
default: () => ({minRows:1, maxRows:6}),
|
||||
},
|
||||
defaultInputMaxlength: {
|
||||
type: Number,
|
||||
default: 500,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showModal: false,
|
||||
loadIng: 0,
|
||||
|
||||
inputValue: '',
|
||||
inputModel: '',
|
||||
modelGroups: [],
|
||||
modelMap: {},
|
||||
modelsLoading: false,
|
||||
inputPlaceholder: this.defaultPlaceholder,
|
||||
inputRows: this.defaultInputRows,
|
||||
inputAutosize: this.defaultInputAutosize,
|
||||
inputMaxlength: this.defaultInputMaxlength,
|
||||
inputOnOk: null,
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
emitter.on('openAIAssistant', this.onOpenAIAssistant);
|
||||
this.onOpenAIAssistant({})
|
||||
this.fetchModelOptions();
|
||||
},
|
||||
beforeDestroy() {
|
||||
emitter.off('openAIAssistant', this.onOpenAIAssistant);
|
||||
},
|
||||
computed: {
|
||||
...mapState([
|
||||
'cacheDialogs',
|
||||
]),
|
||||
selectedModelOption({modelMap, inputModel}) {
|
||||
return modelMap[inputModel] || null;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onOpenAIAssistant(params) {
|
||||
if ($A.isJson(params)) {
|
||||
this.inputValue = params.value || '';
|
||||
this.inputPlaceholder = params.placeholder || this.defaultPlaceholder || this.$L('请输入你的问题...');
|
||||
this.inputRows = params.rows || this.defaultInputRows;
|
||||
this.inputAutosize = params.autosize || this.defaultInputAutosize;
|
||||
this.inputMaxlength = params.maxlength || this.defaultInputMaxlength;
|
||||
this.inputOnOk = params.onOk || null;
|
||||
}
|
||||
this.showModal = true;
|
||||
},
|
||||
|
||||
async fetchModelOptions() {
|
||||
this.modelsLoading = true;
|
||||
try {
|
||||
const {data} = await this.$store.dispatch("call", {
|
||||
url: 'system/setting/aibot_models',
|
||||
});
|
||||
this.normalizeModelOptions(data);
|
||||
} catch (error) {
|
||||
const msg = error?.msg || error?.message || error || this.$L('获取模型列表失败');
|
||||
$A.modalError(msg);
|
||||
} finally {
|
||||
this.modelsLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
normalizeModelOptions(data) {
|
||||
const groups = [];
|
||||
const map = {};
|
||||
const labelMap = AIBotList.reduce((acc, bot) => {
|
||||
acc[bot.value] = bot.label;
|
||||
return acc;
|
||||
}, {});
|
||||
if ($A.isJson(data)) {
|
||||
Object.keys(data).forEach(key => {
|
||||
const match = key.match(/^(.*?)_models$/);
|
||||
if (!match) {
|
||||
return;
|
||||
}
|
||||
const type = match[1];
|
||||
const raw = data[key];
|
||||
const list = raw ? AIModelNames(raw) : [];
|
||||
if (!list.length) {
|
||||
return;
|
||||
}
|
||||
const defaultModel = data[`${type}_model`] || '';
|
||||
const label = labelMap[type] || type;
|
||||
const options = list.slice(0, 5);
|
||||
if (defaultModel) {
|
||||
const defaultOption = list.find(option => option.value === defaultModel);
|
||||
if (defaultOption && !options.some(option => option.value === defaultOption.value)) {
|
||||
options.push(defaultOption);
|
||||
}
|
||||
}
|
||||
const group = {
|
||||
type,
|
||||
label,
|
||||
defaultModel,
|
||||
options: options.map(option => {
|
||||
const id = `${type}:${option.value}`;
|
||||
const item = Object.assign({}, option, {
|
||||
id,
|
||||
type,
|
||||
});
|
||||
map[id] = item;
|
||||
return item;
|
||||
}),
|
||||
};
|
||||
groups.push(group);
|
||||
});
|
||||
}
|
||||
const order = AIBotList.map(bot => bot.value);
|
||||
groups.sort((a, b) => {
|
||||
const indexA = order.indexOf(a.type);
|
||||
const indexB = order.indexOf(b.type);
|
||||
if (indexA === -1 && indexB === -1) {
|
||||
return a.label.localeCompare(b.label);
|
||||
}
|
||||
if (indexA === -1) {
|
||||
return 1;
|
||||
}
|
||||
if (indexB === -1) {
|
||||
return -1;
|
||||
}
|
||||
return indexA - indexB;
|
||||
});
|
||||
this.modelGroups = groups;
|
||||
this.modelMap = map;
|
||||
this.ensureSelectedModel();
|
||||
},
|
||||
|
||||
ensureSelectedModel() {
|
||||
if (this.inputModel && this.modelMap[this.inputModel]) {
|
||||
return;
|
||||
}
|
||||
for (const group of this.modelGroups) {
|
||||
if (group.defaultModel) {
|
||||
const match = group.options.find(option => option.value === group.defaultModel);
|
||||
if (match) {
|
||||
this.inputModel = match.id;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
const firstGroup = this.modelGroups.find(group => group.options.length > 0);
|
||||
if (firstGroup) {
|
||||
this.inputModel = firstGroup.options[0].id;
|
||||
} else {
|
||||
this.inputModel = '';
|
||||
}
|
||||
},
|
||||
|
||||
async onSubmit() {
|
||||
if (this.loadIng > 0) {
|
||||
return;
|
||||
}
|
||||
const content = (this.inputValue || '').trim();
|
||||
if (!content) {
|
||||
$A.messageWarning(this.$L('请输入你的问题'));
|
||||
return;
|
||||
}
|
||||
const modelOption = this.selectedModelOption;
|
||||
if (!modelOption) {
|
||||
$A.messageWarning(this.$L('请选择模型'));
|
||||
return;
|
||||
}
|
||||
|
||||
this.loadIng++;
|
||||
try {
|
||||
const {dialogId, userid} = await this.ensureAiDialog(modelOption.type);
|
||||
await this.createAiSession(dialogId);
|
||||
const message = await this.sendAiMessage(dialogId, this.formatPlainText(this.inputValue), modelOption.value);
|
||||
if (typeof this.inputOnOk === 'function') {
|
||||
this.inputOnOk({
|
||||
dialogId,
|
||||
userid,
|
||||
model: modelOption.value,
|
||||
type: modelOption.type,
|
||||
content: this.inputValue,
|
||||
message,
|
||||
});
|
||||
}
|
||||
this.showModal = false;
|
||||
this.inputValue = '';
|
||||
} catch (error) {
|
||||
const msg = error?.msg || error?.message || error || this.$L('发送失败');
|
||||
$A.modalError(msg);
|
||||
} finally {
|
||||
this.loadIng--;
|
||||
}
|
||||
},
|
||||
|
||||
getAiEmail(type) {
|
||||
return `ai-${type}@bot.system`;
|
||||
},
|
||||
|
||||
findAiDialog({type, userid}) {
|
||||
const email = this.getAiEmail(type);
|
||||
return this.cacheDialogs.find(dialog => {
|
||||
if (!dialog) {
|
||||
return false;
|
||||
}
|
||||
if (userid && dialog.dialog_user && dialog.dialog_user.userid === userid) {
|
||||
return true;
|
||||
}
|
||||
if (dialog.dialog_user && dialog.dialog_user.email === email) {
|
||||
return true;
|
||||
}
|
||||
if (dialog.email === email) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
},
|
||||
|
||||
sleep(ms = 150) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
},
|
||||
|
||||
async ensureAiDialog(type) {
|
||||
let dialog = this.findAiDialog({type});
|
||||
if (dialog) {
|
||||
await this.$store.dispatch("openDialog", dialog.id);
|
||||
return {
|
||||
dialogId: dialog.id,
|
||||
userid: dialog.dialog_user?.userid || dialog.userid || 0,
|
||||
};
|
||||
}
|
||||
const {data} = await this.$store.dispatch("call", {
|
||||
url: 'users/search/ai',
|
||||
data: {type},
|
||||
});
|
||||
const userid = data?.userid;
|
||||
if (!userid) {
|
||||
throw new Error(this.$L('未找到AI机器人'));
|
||||
}
|
||||
await this.$store.dispatch("openDialogUserid", userid);
|
||||
const retries = 5;
|
||||
for (let i = 0; i < retries; i++) {
|
||||
dialog = this.findAiDialog({type, userid});
|
||||
if (dialog) {
|
||||
break;
|
||||
}
|
||||
await this.sleep();
|
||||
}
|
||||
if (!dialog) {
|
||||
throw new Error(this.$L('AI对话打开失败'));
|
||||
}
|
||||
return {
|
||||
dialogId: dialog.id,
|
||||
userid,
|
||||
};
|
||||
},
|
||||
|
||||
async createAiSession(dialogId) {
|
||||
if (!dialogId) {
|
||||
return;
|
||||
}
|
||||
await this.$store.dispatch("call", {
|
||||
url: 'dialog/session/create',
|
||||
data: {dialog_id: dialogId},
|
||||
});
|
||||
await this.$store.dispatch("clearDialogMsgs", {
|
||||
id: dialogId,
|
||||
});
|
||||
},
|
||||
|
||||
async sendAiMessage(dialogId, text, model) {
|
||||
const {data} = await this.$store.dispatch("call", {
|
||||
url: 'dialog/msg/sendtext',
|
||||
method: 'post',
|
||||
data: {
|
||||
dialog_id: dialogId,
|
||||
text,
|
||||
model_name: model,
|
||||
},
|
||||
});
|
||||
if (data) {
|
||||
this.$store.dispatch("saveDialogMsg", data);
|
||||
this.$store.dispatch("increaseTaskMsgNum", {id: data.dialog_id});
|
||||
if (data.reply_id) {
|
||||
this.$store.dispatch("increaseMsgReplyNum", {id: data.reply_id});
|
||||
}
|
||||
this.$store.dispatch("updateDialogLastMsg", data);
|
||||
}
|
||||
return data;
|
||||
},
|
||||
|
||||
formatPlainText(text) {
|
||||
const escaped = `${text}`
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/\n/g, '<br/>');
|
||||
return `<p>${escaped}</p>`;
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.ai-assistant-modal {
|
||||
.ivu-modal {
|
||||
.ivu-modal-header {
|
||||
padding-left: 30px !important;
|
||||
padding-right: 30px !important;
|
||||
}
|
||||
.ivu-modal-body {
|
||||
padding-top: 0 !important;
|
||||
padding-bottom: 0 !important;
|
||||
}
|
||||
.ivu-modal-footer {
|
||||
.ivu-btn {
|
||||
min-width: auto !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ai-assistant-content {
|
||||
|
||||
}
|
||||
|
||||
.ai-assistant-footer {
|
||||
display: flex;
|
||||
justify-content: between;
|
||||
gap: 12px;
|
||||
.ai-assistant-footer-models {
|
||||
text-align: left;
|
||||
.ivu-select-selection {
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
.ivu-select-placeholder,
|
||||
.ivu-select-selected-value {
|
||||
padding-left: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
.ai-assistant-footer-btns {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -347,6 +347,9 @@
|
||||
<!--搜索框-->
|
||||
<SearchBox ref="searchBox"/>
|
||||
|
||||
<!--AI 助理-->
|
||||
<AIAssistant/>
|
||||
|
||||
<!--工作报告-->
|
||||
<DrawerOverlay
|
||||
v-model="workReportShow"
|
||||
@ -451,6 +454,7 @@ import ApproveDetails from "./manage/approve/details.vue";
|
||||
import notificationKoro from "notification-koro1";
|
||||
import emitter from "../store/events";
|
||||
import SearchBox from "../components/SearchBox.vue";
|
||||
import AIAssistant from "../components/AIAssistant.vue";
|
||||
import transformEmojiToHtml from "../utils/emoji";
|
||||
import {languageName} from "../language";
|
||||
import Draggable from 'vuedraggable'
|
||||
@ -459,6 +463,7 @@ export default {
|
||||
components: {
|
||||
Approve,
|
||||
SearchBox,
|
||||
AIAssistant,
|
||||
ApproveDetails,
|
||||
ImgUpload,
|
||||
UserSelect,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user