({minRows:1, maxRows:6}),
+ default: () => ({minRows:2, maxRows:6}),
},
defaultInputMaxlength: {
type: Number,
@@ -77,28 +121,42 @@ export default {
},
data() {
return {
+ // 弹窗状态
showModal: false,
+ closing: false,
loadIng: 0,
-
+
+ // 输入配置
inputValue: '',
- inputModel: '',
- modelGroups: [],
- modelMap: {},
- modelsLoading: false,
inputPlaceholder: this.defaultPlaceholder,
inputRows: this.defaultInputRows,
inputAutosize: this.defaultInputAutosize,
inputMaxlength: this.defaultInputMaxlength,
inputOnOk: null,
+
+ // 模型选择
+ inputModel: '',
+ modelGroups: [],
+ modelMap: {},
+ modelsLoading: false,
+ modelCacheKey: 'aiAssistant.model',
+ cachedModelId: '',
+
+ // 响应渲染
+ responses: [],
+ pendingResponses: [],
+ responseSeed: 1,
+ maxResponses: 5,
}
},
mounted() {
emitter.on('openAIAssistant', this.onOpenAIAssistant);
- this.onOpenAIAssistant({})
- this.fetchModelOptions();
+ emitter.on('streamMsgData', this.onStreamMsgData);
+ this.initModelCache();
},
beforeDestroy() {
emitter.off('openAIAssistant', this.onOpenAIAssistant);
+ emitter.off('streamMsgData', this.onStreamMsgData);
},
computed: {
...mapState([
@@ -107,8 +165,19 @@ export default {
selectedModelOption({modelMap, inputModel}) {
return modelMap[inputModel] || null;
},
+ shouldCreateNewSession() {
+ return this.responses.length === 0;
+ },
+ },
+ watch: {
+ inputModel(value) {
+ this.saveModelCache(value);
+ },
},
methods: {
+ /**
+ * 打开助手弹窗并应用参数
+ */
onOpenAIAssistant(params) {
if ($A.isJson(params)) {
this.inputValue = params.value || '';
@@ -118,9 +187,44 @@ export default {
this.inputMaxlength = params.maxlength || this.defaultInputMaxlength;
this.inputOnOk = params.onOk || null;
}
+ this.responses = [];
+ this.pendingResponses = [];
this.showModal = true;
},
+ /**
+ * 初始化模型缓存与下拉数据
+ */
+ async initModelCache() {
+ await this.loadCachedModel();
+ this.fetchModelOptions();
+ },
+
+ /**
+ * 读取缓存的模型ID
+ */
+ async loadCachedModel() {
+ try {
+ this.cachedModelId = await $A.IDBString(this.modelCacheKey) || '';
+ } catch (e) {
+ this.cachedModelId = '';
+ }
+ },
+
+ /**
+ * 持久化模型选择
+ */
+ saveModelCache(value) {
+ if (!value) {
+ return;
+ }
+ $A.IDBSave(this.modelCacheKey, value);
+ this.cachedModelId = value;
+ },
+
+ /**
+ * 拉取模型配置
+ */
async fetchModelOptions() {
this.modelsLoading = true;
try {
@@ -136,6 +240,9 @@ export default {
}
},
+ /**
+ * 解析模型列表
+ */
normalizeModelOptions(data) {
const groups = [];
const map = {};
@@ -201,10 +308,17 @@ export default {
this.ensureSelectedModel();
},
+ /**
+ * 应用默认或缓存的模型
+ */
ensureSelectedModel() {
if (this.inputModel && this.modelMap[this.inputModel]) {
return;
}
+ if (this.cachedModelId && this.modelMap[this.cachedModelId]) {
+ this.inputModel = this.cachedModelId;
+ return;
+ }
for (const group of this.modelGroups) {
if (group.defaultModel) {
const match = group.options.find(option => option.value === group.defaultModel);
@@ -222,11 +336,15 @@ export default {
}
},
+ /**
+ * 发送提问并推入响应
+ */
async onSubmit() {
if (this.loadIng > 0) {
return;
}
- const content = (this.inputValue || '').trim();
+ const rawValue = this.inputValue || '';
+ const content = rawValue.trim();
if (!content) {
$A.messageWarning(this.$L('请输入你的问题'));
return;
@@ -238,34 +356,46 @@ export default {
}
this.loadIng++;
+ let responseEntry = null;
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,
- });
+ if (this.shouldCreateNewSession) {
+ await this.createAiSession(dialogId);
+ }
+ responseEntry = this.createResponseEntry({
+ modelOption,
+ dialogId,
+ prompt: rawValue,
+ });
+ this.scrollResponsesToBottom();
+ const message = await this.sendAiMessage(dialogId, this.formatPlainText(rawValue), modelOption.value);
+ if (responseEntry) {
+ responseEntry.userid = userid;
+ responseEntry.message = message;
+ responseEntry.messageId = message?.id || 0;
}
- this.showModal = false;
this.inputValue = '';
} catch (error) {
const msg = error?.msg || error?.message || error || this.$L('发送失败');
+ if (responseEntry) {
+ this.markResponseError(responseEntry, msg);
+ }
$A.modalError(msg);
} finally {
this.loadIng--;
}
},
+ /**
+ * 生成AI机器人邮箱
+ */
getAiEmail(type) {
return `ai-${type}@bot.system`;
},
+ /**
+ * 在缓存会话里查找AI
+ */
findAiDialog({type, userid}) {
const email = this.getAiEmail(type);
return this.cacheDialogs.find(dialog => {
@@ -285,35 +415,34 @@ export default {
});
},
- sleep(ms = 150) {
- return new Promise(resolve => setTimeout(resolve, ms));
- },
-
+ /**
+ * 确保能够打开AI会话
+ */
async ensureAiDialog(type) {
let dialog = this.findAiDialog({type});
+ let userid = dialog?.dialog_user?.userid || dialog?.userid || 0;
if (dialog) {
- await this.$store.dispatch("openDialog", dialog.id);
return {
dialogId: dialog.id,
- userid: dialog.dialog_user?.userid || dialog.userid || 0,
+ userid,
};
}
const {data} = await this.$store.dispatch("call", {
url: 'users/search/ai',
data: {type},
});
- const userid = data?.userid;
+ 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();
+ const dialogResult = await this.$store.dispatch("call", {
+ url: 'dialog/open/user',
+ data: {userid},
+ method: 'get',
+ });
+ dialog = dialogResult?.data || null;
+ if (dialog) {
+ this.$store.dispatch("saveDialog", dialog);
}
if (!dialog) {
throw new Error(this.$L('AI对话打开失败'));
@@ -324,6 +453,9 @@ export default {
};
},
+ /**
+ * 创建新的AI会话session
+ */
async createAiSession(dialogId) {
if (!dialogId) {
return;
@@ -337,6 +469,9 @@ export default {
});
},
+ /**
+ * 发送文本消息
+ */
async sendAiMessage(dialogId, text, model) {
const {data} = await this.$store.dispatch("call", {
url: 'dialog/msg/sendtext',
@@ -358,6 +493,9 @@ export default {
return data;
},
+ /**
+ * 将纯文本转换成HTML
+ */
formatPlainText(text) {
const escaped = `${text}`
.replace(/&/g, '&')
@@ -366,6 +504,157 @@ export default {
.replace(/\n/g, '
');
return `
${escaped}
`;
},
+
+ /**
+ * 新建响应卡片
+ */
+ createResponseEntry({modelOption, dialogId, prompt}) {
+ const entry = {
+ localId: this.responseSeed++,
+ id: null,
+ dialogId,
+ model: modelOption.value,
+ modelLabel: modelOption.label,
+ type: modelOption.type,
+ prompt: prompt.trim(),
+ text: '',
+ status: 'waiting',
+ error: '',
+ userid: 0,
+ message: null,
+ messageId: 0,
+ applyLoading: false,
+ };
+ this.responses.push(entry);
+ this.pendingResponses.push(entry);
+ if (this.responses.length > this.maxResponses) {
+ const removed = this.responses.shift();
+ this.pendingResponses = this.pendingResponses.filter(item => item !== removed);
+ }
+ return entry;
+ },
+
+ /**
+ * 处理流式输出
+ */
+ onStreamMsgData(data) {
+ if (!data || !data.id) {
+ return;
+ }
+ let response = this.responses.find(item => item.id === data.id);
+ if (!response && data.reply_id) {
+ response = this.responses.find(item => item.messageId === data.reply_id);
+ }
+ if (!response) {
+ const index = this.pendingResponses.findIndex(item => {
+ if (data.reply_id && item.messageId) {
+ return item.messageId === data.reply_id;
+ }
+ if (data.dialog_id && item.dialogId) {
+ return item.dialogId === data.dialog_id;
+ }
+ return true;
+ });
+ if (index === -1) {
+ return;
+ }
+ response = this.pendingResponses.splice(index, 1)[0];
+ response.id = data.id;
+ }
+ const chunk = typeof data.text === 'string' ? data.text : '';
+ if (data.type === 'replace') {
+ response.text = chunk;
+ response.status = 'completed';
+ } else {
+ response.text += chunk;
+ response.status = 'streaming';
+ }
+ this.scrollResponsesToBottom();
+ },
+
+ /**
+ * 标记响应失败
+ */
+ markResponseError(response, msg) {
+ response.status = 'error';
+ response.error = msg;
+ this.pendingResponses = this.pendingResponses.filter(item => item !== response);
+ },
+
+ /**
+ * 将AI内容应用到父组件
+ */
+ applyResponse(response) {
+ if (!response || response.applyLoading) {
+ return;
+ }
+ if (!response.text) {
+ $A.messageWarning(this.$L('暂无可用内容'));
+ return;
+ }
+ if (typeof this.inputOnOk !== 'function') {
+ this.closeAssistant();
+ return;
+ }
+ response.applyLoading = true;
+ const payload = {
+ dialogId: response.dialogId,
+ userid: response.userid,
+ model: response.model,
+ type: response.type,
+ content: response.prompt,
+ message: response.message,
+ aiContent: response.text,
+ };
+ try {
+ const result = this.inputOnOk(payload);
+ if (result && typeof result.then === 'function') {
+ result.then(() => {
+ this.closeAssistant();
+ }).catch(error => {
+ const msg = error?.msg || error?.message || error || this.$L('应用失败');
+ $A.modalError(msg);
+ }).finally(() => {
+ response.applyLoading = false;
+ });
+ } else {
+ this.closeAssistant();
+ response.applyLoading = false;
+ }
+ } catch (error) {
+ response.applyLoading = false;
+ const msg = error?.msg || error?.message || error || this.$L('应用失败');
+ $A.modalError(msg);
+ }
+ },
+
+ /**
+ * 关闭弹窗
+ */
+ closeAssistant() {
+ if (this.closing) {
+ return;
+ }
+ this.closing = true;
+ this.showModal = false;
+ this.responses = [];
+ this.pendingResponses = [];
+ setTimeout(() => {
+ this.closing = false;
+ }, 300);
+ },
+
+ /**
+ * 滚动结果区域到底部
+ */
+ scrollResponsesToBottom() {
+ this.$nextTick(() => {
+ const container = this.$refs.responseContainer;
+ if (container && container.scrollHeight) {
+ container.scrollTop = container.scrollHeight;
+ }
+ });
+ },
},
}
@@ -389,7 +678,102 @@ export default {
}
.ai-assistant-content {
-
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ .ai-assistant-output {
+ padding: 12px;
+ border-radius: 8px;
+ background: #f8f9fb;
+ border: 1px solid rgba(0, 0, 0, 0.04);
+ max-height: calc(100vh - 390px);
+ overflow-y: auto;
+ @media (height <= 900px) {
+ max-height: calc(100vh - 260px);
+ }
+ }
+
+ .ai-assistant-output-item + .ai-assistant-output-item {
+ margin-top: 12px;
+ padding-top: 12px;
+ border-top: 1px solid rgba(0, 0, 0, 0.05);
+ }
+
+ .ai-assistant-output-meta {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 12px;
+ }
+
+ .ai-assistant-output-meta-left {
+ flex: 1;
+ min-width: 0;
+ }
+
+ .ai-assistant-output-model {
+ display: inline-flex;
+ align-items: center;
+ font-size: 12px;
+ font-weight: 600;
+ color: #2f54eb;
+ background: rgba(47, 84, 235, 0.08);
+ border-radius: 4px;
+ padding: 2px 8px;
+ }
+
+ .ai-assistant-output-question {
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+ font-size: 12px;
+ color: #666;
+ line-height: 1.4;
+ margin-top: 8px;
+ }
+
+ .ai-assistant-output-meta-right {
+ display: flex;
+ align-items: center;
+ font-size: 12px;
+ color: #999;
+ gap: 4px;
+ }
+
+ .ai-assistant-output-icon {
+ font-size: 16px;
+ color: #2f54eb;
+ }
+
+ .ai-assistant-apply-btn {
+ font-size: 13px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ }
+
+ .ai-assistant-output-status {
+ color: #52c41a;
+ }
+
+ .ai-assistant-output-error {
+ color: #ff4d4f;
+ }
+
+ .ai-assistant-output-placeholder {
+ margin-top: 12px;
+ font-size: 13px;
+ color: #999;
+ padding: 8px;
+ border-radius: 6px;
+ background: rgba(0, 0, 0, 0.02);
+ }
+
+ .ai-assistant-output-markdown {
+ margin-top: 12px;
+ font-size: 13px;
+ }
}
.ai-assistant-footer {
@@ -415,4 +799,17 @@ export default {
}
}
}
+
+@keyframes ai-assistant-spin {
+ from {
+ transform: rotate(0deg);
+ }
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+.ai-spin {
+ animation: ai-assistant-spin 1s linear infinite;
+}