3513 lines
122 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<AssistantModal
ref="assistantModal"
v-model="showModal"
:displayMode="displayMode"
:shouldCreateNewSession="shouldCreateNewSession"
:zIndex="topZIndex">
<div slot="header" class="ai-assistant-header">
<div class="ai-assistant-header-title">
<i class="taskfont">&#xe8a1;</i>
<span>{{ modalTitle || $L('AI 助手') }}</span>
</div>
<div class="ai-assistant-header-actions">
<div v-if="sessionEnabled && (responses.length > 0 || hasSessionHistory)" class="ai-assistant-header-btn" :title="$L('新建会话')" @click="createNewSession()">
<i class="taskfont">&#xe6f2;</i>
</div>
<Dropdown
v-if="sessionEnabled && hasSessionHistory"
trigger="click"
placement="bottom-end"
:transfer="true"
:z-index="topZIndex + 1"
@on-visible-change="onHistoryVisibleChange">
<div class="ai-assistant-header-btn" :title="$L('历史会话')">
<i class="taskfont">&#xe6e8;</i>
</div>
<DropdownMenu slot="list" class="ai-assistant-history-menu">
<DropdownItem
v-for="session in currentSessionList"
:key="session.id"
:class="{'active': session.id === currentSessionId}"
@click.native="loadSession(session.id)">
<div class="history-item" @mouseleave="resetDeleteConfirm">
<div class="history-item-content">
<div class="history-item-title">{{ session.title }}</div>
<div
v-if="confirmingDeleteId === session.id"
class="history-item-delete history-item-delete-confirm"
:title="$L('确认删除')"
@click.stop="deleteSession(session.id)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>
</div>
<div
v-else
class="history-item-delete"
:title="$L('删除')"
@click.stop="askDeleteSession(session.id)">
<i class="taskfont">&#xe6e5;</i>
</div>
</div>
<span class="history-item-time">{{ formatSessionTime(session.updatedAt) }}</span>
</div>
</DropdownItem>
<DropdownItem divided @click.native="clearSessionHistory">
<div class="history-clear">
{{ $L('清空历史记录') }}
</div>
</DropdownItem>
</DropdownMenu>
</Dropdown>
</div>
</div>
<div
class="ai-assistant-content"
:class="{'ai-assistant-content-dragging': isDragging}"
@dragenter.prevent="onDragEnter"
@dragover.prevent
@dragleave="onDragLeave"
@drop.prevent="onDrop">
<!-- 拖放提示遮罩 -->
<div v-if="isDragging" class="ai-assistant-drop-overlay">
<div class="ai-assistant-drop-hint">
<i class="taskfont">&#xe7bc;</i>
<span>{{ $L('松开以上传图片') }}</span>
</div>
</div>
<div
v-if="visibleResponses.length"
ref="responseContainer"
class="ai-assistant-output">
<div
v-for="response in visibleResponses"
:key="response.localId"
class="ai-assistant-output-item">
<div class="ai-assistant-output-apply">
<template v-if="response.status === 'error'">
<span class="ai-assistant-output-error">{{ $L('发送失败') }}</span>
</template>
<template v-else-if="response.rawOutput && response.status !== 'streaming'">
<Button
v-if="showApplyButton"
type="primary"
size="small"
:loading="response.applyLoading"
class="ai-assistant-apply-btn"
@click="applyResponse(response)">
{{ applyButtonText || $L('应用此内容') }}
</Button>
</template>
<template v-else-if="!response.rawOutput || response.status === 'streaming'">
<Icon type="ios-loading" class="ai-assistant-output-icon icon-loading"/>
<span v-if="loadingText && !response.rawOutput" class="ai-assistant-output-status">{{ loadingText }}</span>
</template>
</div>
<div class="ai-assistant-output-meta">
<span class="ai-assistant-output-model">{{ response.modelLabel || response.model }}</span>
</div>
<!-- 问题区域正常显示或编辑模式 -->
<div v-if="response.prompt" class="ai-assistant-output-question-wrap">
<!-- 编辑模式 -->
<div v-if="editingIndex === responses.indexOf(response)" class="ai-assistant-question-editor">
<Input
v-model="editingValue"
ref="editInputRef"
type="textarea"
:autosize="{minRows: 1, maxRows: 6}"
:maxlength="inputMaxlength || 500"
@on-keydown="onEditKeydown"
@compositionstart.native="isComposing = true"
@compositionend.native="isComposing = false" />
<div class="ai-assistant-question-editor-btns">
<Button size="small" @click="cancelEditQuestion">{{ $L('取消') }}</Button>
<Button type="primary" size="small" :loading="loadIng > 0" @click="submitEditedQuestion">{{ $L('发送') }}</Button>
</div>
</div>
<!-- 正常显示模式使用 template 缓存 parsePromptContent 结果避免重复调用 -->
<template v-else v-for="(parsed, _pk) in [parsePromptContent(response.prompt)]">
<div class="ai-assistant-output-question">
<!-- 图片区域单独一行 -->
<div v-if="parsed.images.length" class="ai-assistant-output-question-images">
<PromptImage
v-for="(img, imgIndex) in parsed.images"
:key="'img' + imgIndex"
:image-id="img.imageId"
:get-image="getImageFromCache" />
</div>
<!-- 文字区域 -->
<div class="ai-assistant-output-question-content">
<span
class="ai-assistant-output-question-text"
:class="{ 'is-expanded': expandedQuestionIds.includes(response.localId) }"
@click="toggleQuestionExpand(response.localId)">{{ parsed.text }}</span>
<span class="ai-assistant-output-question-edit" :title="$L('编辑问题')" @click="startEditQuestion(responses.indexOf(response))">
<svg viewBox="0 0 20 20" fill="currentColor"><path d="M11.331 3.568a3.61 3.61 0 0 1 4.973.128l.128.135a3.61 3.61 0 0 1 0 4.838l-.128.135-6.292 6.29c-.324.324-.558.561-.79.752l-.235.177q-.309.21-.65.36l-.23.093c-.181.066-.369.114-.585.159l-.765.135-2.394.399c-.142.024-.294.05-.422.06-.1.007-.233.01-.378-.026l-.149-.049a1.1 1.1 0 0 1-.522-.474l-.046-.094a1.1 1.1 0 0 1-.074-.526c.01-.129.035-.28.06-.423l.398-2.394.134-.764a4 4 0 0 1 .16-.586l.093-.23q.15-.342.36-.65l.176-.235c.19-.232.429-.466.752-.79l6.291-6.292zm-5.485 7.36c-.35.35-.533.535-.66.688l-.11.147a2.7 2.7 0 0 0-.24.433l-.062.155c-.04.11-.072.225-.106.394l-.127.717-.398 2.393-.001.002h.003l2.393-.399.717-.126c.169-.034.284-.065.395-.105l.153-.062q.228-.1.433-.241l.148-.11c.153-.126.338-.31.687-.66l4.988-4.988-3.226-3.226zm9.517-6.291a2.28 2.28 0 0 0-3.053-.157l-.173.157-.364.363L15 8.226l.363-.363.157-.174a2.28 2.28 0 0 0 0-2.878z"/></svg>
</span>
</div>
</div>
</template>
</div>
<DialogMarkdown
v-if="response.rawOutput"
class="ai-assistant-output-markdown no-dark-content"
:text="response.displayOutput || response.rawOutput"
:before-navigate="onMarkdownNavigate"/>
<div v-else class="ai-assistant-output-placeholder">
{{ response.status === 'error' ? (response.error || $L('发送失败')) : $L('等待 AI 回复...') }}
</div>
<div
v-if="response.rawOutput && response.status === 'completed'"
class="ai-assistant-output-feedback">
<span
class="ai-assistant-feedback-btn"
:title="$L('复制')"
@click="copyResponse(response)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.0"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>
</span>
<span
:class="['ai-assistant-feedback-btn', {active: response.feedback === 'like'}]"
:title="$L('有帮助')"
@click="submitFeedback(response, 'like')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.0"><path d="M15 5.88 14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2a3.13 3.13 0 0 1 3 3.88Z"/><path d="M7 10v12"/></svg>
</span>
<span
:class="['ai-assistant-feedback-btn ai-assistant-feedback-btn-down', {active: response.feedback === 'dislike'}]"
:title="$L('没帮助')"
@click="submitFeedback(response, 'dislike')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.0"><path d="M9 18.12 10 14H4.17a2 2 0 0 1-1.92-2.56l2.33-8A2 2 0 0 1 6.5 2H20a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-2.76a2 2 0 0 0-1.79 1.11L12 22a3.13 3.13 0 0 1-3-3.88Z"/><path d="M17 14V2"/></svg>
</span>
<span v-if="response.createdAt" class="ai-assistant-output-time" :title="$A.dayjs(response.createdAt).format('YYYY-MM-DD HH:mm:ss')">{{ formatResponseTime(response.createdAt) }}</span>
</div>
</div>
</div>
<div v-else-if="displayMode === 'chat'" class="ai-assistant-welcome" @click="onFocus">
<div class="ai-assistant-welcome-icon">
<svg class="no-dark-content" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
<path d="M385.80516777 713.87417358c-12.76971517 0-24.13100586-7.79328205-28.82575409-19.62404756l-48.91927648-123.9413531c-18.40341303-46.75969229-55.77360888-84.0359932-102.53330118-102.53330117l-123.94135309-48.91927649c-11.83076552-4.69474822-19.62404757-16.05603892-19.62404757-28.8257541s7.79328205-24.13100586 19.62404757-28.82575407l123.94135309-48.91927649c46.75969229-18.40341303 84.0359932-55.77360888 102.53330118-102.53330119l48.91927648-123.94135308c4.69474822-11.83076552 16.05603892-19.62404757 28.8257541-19.62404757s24.13100586 7.79328205 28.82575408 19.62404757l48.91927648 123.94135308c18.40341303 46.75969229 55.77360888 84.0359932 102.53330118 102.53330119l123.94135309 48.91927649c11.83076552 4.69474822 19.62404757 16.05603892 19.62404757 28.82575407 0 12.76971517-7.79328205 24.13100586-19.62404757 28.8257541l-123.94135309 48.91927649c-46.75969229 18.40341303-84.0359932 55.77360888-102.53330118 102.53330117l-48.91927648 123.9413531c-4.69474822 11.83076552-16.14993388 19.62404757-28.82575408 19.62404756zM177.45224165 390.12433614l50.89107073 20.0935224c62.62794129 24.69437565 112.67395736 74.74039171 137.368333 137.36833299l20.09352239 50.89107073 20.0935224-50.89107073c24.69437565-62.62794129 74.74039171-112.67395736 137.368333-137.36833299l50.89107072-20.0935224-50.89107073-20.09352239c-62.62794129-24.69437565-112.67395736-74.74039171-137.36833299-137.36833301l-20.09352239-50.89107074-20.0935224 50.89107074c-24.69437565 62.62794129-74.74039171 112.67395736-137.368333 137.36833301l-50.89107073 20.09352239zM771.33789183 957.62550131c-12.76971517 0-24.13100586-7.79328205-28.82575409-19.62404758l-26.6661699-67.6043744c-8.63833672-21.87752672-26.10280012-39.34199011-47.98032684-47.98032684l-67.60437441-26.6661699c-11.83076552-4.69474822-19.62404757-16.05603892-19.62404757-28.82575409s7.79328205-24.13100586 19.62404757-28.82575409l67.60437441-26.6661699c21.87752672-8.63833672 39.34199011-26.10280012 47.98032684-47.98032685l26.6661699-67.6043744c4.69474822-11.83076552 16.05603892-19.62404757 28.82575409-19.62404757s24.13100586 7.79328205 28.82575409 19.62404757l26.66616991 67.6043744c8.63833672 21.87752672 26.10280012 39.34199011 47.98032684 47.98032685l67.6043744 26.6661699c11.83076552 4.69474822 19.62404757 16.05603892 19.62404757 28.82575409s-7.79328205 24.13100586-19.62404757 28.82575409l-67.6043744 26.6661699c-21.87752672 8.63833672-39.34199011 26.10280012-47.98032684 47.98032684l-26.66616991 67.6043744c-4.69474822 11.83076552-16.14993388 19.62404757-28.82575409 19.62404758z m-75.58544639-190.70067281c33.61439727 14.83540438 60.75004201 41.87715415 75.49155143 75.49155143 14.83540438-33.61439727 41.87715415-60.75004201 75.49155142-75.49155143-33.61439727-14.83540438-60.75004201-41.87715415-75.49155142-75.49155143-14.74150942 33.61439727-41.87715415 60.75004201-75.49155143 75.49155143z"/>
</svg>
</div>
<div class="ai-assistant-welcome-title">
{{ $L('欢迎使用 AI 助手') }}
</div>
<div class="ai-assistant-welcome-prompts">
<div
v-for="(prompt, index) in displayWelcomePrompts"
:key="index"
class="ai-assistant-prompt-card"
@click="onPromptClick(prompt)">
<span v-if="prompt.svg" class="ai-assistant-prompt-icon no-dark-content" v-html="prompt.svg"></span>
<span>{{ prompt.text }}</span>
</div>
</div>
</div>
<div class="ai-assistant-input">
<!-- 图片预览区域 -->
<div v-if="pendingImages.length" class="ai-assistant-images">
<div
v-for="img in pendingImages"
:key="img.id"
class="ai-assistant-image-item">
<img :src="img.dataUrl" alt="preview" />
<div class="ai-assistant-image-remove" @click="removeImage(img.id)">
<i class="taskfont">&#xe6e5;</i>
</div>
</div>
</div>
<Input
v-model="inputValue"
ref="inputRef"
type="textarea"
:placeholder="inputPlaceholder || $L('请输入你的问题...')"
:rows="inputRows || 1"
:autosize="inputAutosize || {minRows:1, maxRows:6}"
:maxlength="inputMaxlength || 500"
@on-keydown="onInputKeydown"
@compositionstart.native="isComposing = true"
@compositionend.native="isComposing = false"
@paste.native="onPaste" />
<!-- 隐藏的图片上传 input -->
<input
ref="imageInput"
type="file"
accept="image/*"
multiple
style="display: none"
@change="onImageSelect" />
<div 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
:z-index="topZIndex + 1">
<OptionGroup
v-for="group in modelGroups"
:key="group.type"
:label="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">
<div class="ai-assistant-image-btn" :title="$L('上传图片')" @click="triggerImageSelect">
<i class="taskfont">&#xe7bc;</i>
</div>
<Button v-if="submitButtonText" type="primary" shape="circle" icon="md-arrow-up" :loading="loadIng > 0" @click="onSubmit">{{ submitButtonText }}</Button>
<Button v-else type="primary" shape="circle" icon="md-arrow-up" :loading="loadIng > 0" @click="onSubmit"></Button>
</div>
</div>
</div>
</div>
</AssistantModal>
</template>
<script>
import Vue from "vue";
import {debounce} from "lodash";
import emitter from "../../store/events";
import {SSEClient} from "../../utils";
import {AIBotMap, AIModelNames, BASE_ASSISTANT_SYSTEM_PROMPT, withLanguagePreferencePrompt} from "../../utils/ai";
import DialogMarkdown from "../../pages/manage/components/DialogMarkdown.vue";
import FloatButton from "./float-button.vue";
import AssistantModal from "./modal.vue";
import PromptImage from "./prompt-image.vue";
import {buildWeakPrompt, renderWeakPromptText} from "./page-context";
import {getLanguage} from "../../i18n";
import {getWelcomePrompts} from "./welcome-prompts";
export default {
name: 'AIAssistant',
components: {AssistantModal, DialogMarkdown, PromptImage},
floatButtonInstance: null,
data() {
return {
// 弹窗状态
displayMode: 'modal',
showModal: false,
closing: false,
loadIng: 0,
pendingAutoSubmit: false,
autoSubmitTimer: null,
confirmingDeleteId: null,
confirmingDeleteTimer: null,
modalTitle: null,
applyButtonText: null,
submitButtonText: null,
showApplyButton: true,
loadingText: null,
// 输入配置
inputValue: '',
inputPlaceholder: null,
inputRows: null,
inputAutosize: null,
inputMaxlength: null,
// 回调钩子
applyHook: null,
beforeSendHook: null,
renderHook: null,
// 运行时附加 system 内容(每次发送时调用,返回 string 则追加;不入库)
dynamicSystemHook: null,
// 模型选择
inputModel: '',
modelGroups: [],
modelMap: {},
modelsFirstLoad: true,
modelsLoading: false,
modelCacheKey: 'aiAssistant.model',
cachedModelId: '',
// 输入法组合状态
isComposing: false,
// 响应渲染
responses: [],
responseSeed: 1,
maxResponses: 50,
contextWindowSize: 10,
activeSSEClients: [],
// 会话管理
sessionEnabled: false,
sessionStore: [],
currentSessionKey: 'default',
currentSessionId: null,
sessionCacheKeyPrefix: 'aiAssistant.sessions',
maxSessionsPerKey: 20,
sessionStoreLoaded: false,
// 欢迎提示词(防抖更新,避免场景切换时连续刷新导致闪屏)
displayWelcomePrompts: [],
// 编辑历史问题
editingIndex: -1, // 正在编辑的响应索引,-1 表示不在编辑模式
editingValue: '', // 编辑中的文本内容
expandedQuestionIds: [], // 已展开(显示全部内容)的用户消息 localId 列表
// 输入历史(上下键切换)
inputHistoryList: [], // 历史输入列表
inputHistoryIndex: 0, // 当前历史索引
inputHistoryCurrent: '', // 切换前的当前输入
inputHistoryCacheKey: 'aiAssistant.inputHistory',
inputHistoryLimit: 50,
// 图片上传
pendingImages: [], // 待发送的图片列表 [{id, dataUrl, file}]
imageIdSeed: 0, // 图片 ID 种子
maxImages: 5, // 最大图片数量
imageCacheKeyPrefix: 'aiAssistant.images', // 图片缓存 key 前缀
imageCache: {}, // 内存中的图片缓存 {imageId: dataUrl}
serverImageMap: {}, // 服务端返回的 {imageId: url} 映射
isDragging: false, // 是否正在拖放图片
dragCounter: 0, // 拖放计数器(处理嵌套元素)
// 动态 z-index确保始终在最顶层
topZIndex: (window.modalTransferIndex || 1000) + 1000,
zIndexTimer: null,
}
},
created() {
// 创建防抖函数(每个实例独立)
this.refreshWelcomePromptsDebounced = debounce(() => {
this.displayWelcomePrompts = getWelcomePrompts(this.$store, this.$route?.params || {});
}, 100);
this.saveSessionStoreDebouncedMap = {};
},
mounted() {
emitter.on('openAIAssistant', this.onOpenAIAssistant);
this.loadCachedModel();
this.loadInputHistory();
this.mountFloatButton();
this.startZIndexTimer(20000);
},
beforeDestroy() {
emitter.off('openAIAssistant', this.onOpenAIAssistant);
this.clearActiveSSEClients();
this.clearAutoSubmitTimer();
this.resetDeleteConfirm();
this.unmountFloatButton();
this.refreshWelcomePromptsDebounced?.cancel();
this.stopZIndexTimer();
},
computed: {
selectedModelOption({modelMap, inputModel}) {
return modelMap[inputModel] || null;
},
// 渲染时跳过 role==='system' 的弱提示词条目
visibleResponses() {
return this.responses.filter(r => r.role !== 'system');
},
shouldCreateNewSession() {
return this.visibleResponses.length === 0;
},
currentSessionList() {
return this.sessionStore || [];
},
hasSessionHistory() {
return this.currentSessionList.length > 0;
},
// 提示词数据源(用于触发 watch实际渲染用 displayWelcomePrompts
welcomePromptsKey() {
// 返回影响提示词的关键数据,用于判断是否需要刷新
const routeName = this.$store.state.routeName;
const dialogId = this.$store.state.dialogId;
const projectId = this.$store.getters.projectData?.id;
const taskId = this.$store.state.taskId;
const dialogModalShow = this.$store.state.dialogModalShow;
return `${routeName}|${dialogId}|${projectId}|${taskId}|${dialogModalShow}`;
},
},
watch: {
inputModel(value) {
this.saveModelCache(value);
},
welcomePromptsKey: {
handler() {
this.refreshWelcomePromptsDebounced?.();
},
immediate: true,
},
showModal(value) {
if (value) {
// 弹窗打开时5 秒刷新 z-index
this.startZIndexTimer(5000);
} else {
// 弹窗关闭时20 秒刷新 z-index并通知操作模块
this.startZIndexTimer(20000);
emitter.emit('aiAssistantClosed');
}
},
},
methods: {
/**
* 获取输入框焦点事件
*/
onFocus() {
this.$refs.inputRef?.focus();
},
/**
* 点击快捷提示,填入输入框
*/
onPromptClick(prompt) {
if (!prompt || !prompt.text) {
return;
}
this.inputValue = prompt.text;
this.$nextTick(() => {
this.onFocus();
});
},
/**
* 挂载浮动按钮到 body
*/
mountFloatButton() {
const FloatButtonCtor = Vue.extend(FloatButton);
this.$options.floatButtonInstance = new FloatButtonCtor({
parent: this,
});
this.$options.floatButtonInstance.$mount();
document.body.appendChild(this.$options.floatButtonInstance.$el);
},
/**
* 卸载浮动按钮
*/
unmountFloatButton() {
if (this.$options.floatButtonInstance) {
this.$options.floatButtonInstance.$destroy();
if (this.$options.floatButtonInstance.$el && this.$options.floatButtonInstance.$el.parentNode) {
this.$options.floatButtonInstance.$el.parentNode.removeChild(this.$options.floatButtonInstance.$el);
}
this.$options.floatButtonInstance = null;
}
},
/**
* 打开助手弹窗并应用参数
*/
onOpenAIAssistant(params) {
if (!$A.isJson(params)) {
params = {};
}
const newDisplayMode = params.displayMode === 'chat' ? 'chat' : 'modal';
let timeout = 0;
if (this.showModal && this.displayMode === 'chat' && newDisplayMode === 'modal') {
this.showModal = false;
timeout = 50;
}
setTimeout(() => {
this.doOpenAssistant(params, newDisplayMode);
}, timeout);
},
/**
* 实际执行打开助手的逻辑
*/
async doOpenAssistant(params, displayMode) {
// 应用参数
this.displayMode = displayMode;
this.inputValue = params.value || '';
this.inputPlaceholder = params.placeholder || null;
this.inputRows = params.rows || null;
this.inputAutosize = params.autosize || null;
this.inputMaxlength = params.maxlength || null;
this.applyHook = params.onApply || null;
this.beforeSendHook = params.onBeforeSend || null;
this.dynamicSystemHook = params.onDynamicSystem || null;
this.modalTitle = params.title || null;
this.applyButtonText = params.applyButtonText || null;
this.submitButtonText = params.submitButtonText || null;
this.showApplyButton = params.showApplyButton !== false;
this.loadingText = params.loadingText || null;
this.renderHook = params.onRender || null;
this.pendingAutoSubmit = !!params.autoSubmit;
// 会话管理(不再按场景分桶,统一对话池)
await this.initSession(params.sessionKey);
this.showModal = true;
this.fetchModelOptions();
this.clearAutoSubmitTimer();
this.$nextTick(() => {
this.scheduleAutoSubmit();
this.scrollResponsesToBottom();
this.onFocus();
});
},
/**
* 读取缓存的模型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() {
const needFetch = this.modelsFirstLoad
if (needFetch) {
this.modelsFirstLoad = false;
this.modelsLoading = true;
}
try {
const {data} = await this.$store.dispatch("call", {
url: 'assistant/models',
});
this.normalizeModelOptions(data);
} catch (error) {
if (this.modelGroups.length > 0) {
return;
}
$A.modalError({
content: error?.msg || '获取模型列表失败',
onOk: _ => {
this.showModal = false;
},
});
} finally {
if (needFetch) {
this.modelsLoading = false;
}
}
},
/**
* 解析模型列表
*/
normalizeModelOptions(data) {
const groups = [];
const map = {};
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 = AIBotMap[type] || type;
const group = {
type,
label,
defaultModel,
options: list.map(option => {
const id = `${type}:${option.value}`;
const item = Object.assign({}, option, {
id,
type,
});
map[id] = item;
return item;
}),
};
groups.push(group);
});
}
const order = Object.keys(AIBotMap);
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;
}
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);
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 = '';
}
},
/**
* 点击 AI 回复内链接导航前的处理,策略委托给 AssistantModal.prepareForNavigate
*/
onMarkdownNavigate() {
const modal = this.$refs.assistantModal;
if (modal && typeof modal.prepareForNavigate === 'function') {
modal.prepareForNavigate();
} else {
this.showModal = false;
}
},
/**
* 输入框键盘事件回车发送Shift+回车换行,上下键切换历史
* 注意:输入法组合输入时(如中文候选字)不发送
*/
onInputKeydown(e) {
if (this.isComposing) {
return;
}
if (!e.shiftKey) {
if (e.key === 'Enter') {
e.preventDefault();
this.onSubmit();
return;
}
}
// 上下键切换历史输入
if (e.key === 'ArrowUp') {
if (!this.navigateInputHistory('up')) {
e.preventDefault();
}
} else if (e.key === 'ArrowDown') {
if (!this.navigateInputHistory('down')) {
e.preventDefault();
}
}
},
/**
* 发送提问并推入响应
*/
async onSubmit() {
if (this.loadIng > 0) {
return;
}
const prompt = (this.inputValue || '').trim();
if (!prompt) {
return;
}
const success = await this._doSendQuestion(prompt);
if (success) {
this.inputValue = '';
this.clearPendingImages();
}
},
/**
* 执行发送问题的核心逻辑
* @param {string} prompt - 要发送的问题
* @returns {Promise<boolean>} - 是否发送成功
* @private
*/
async _doSendQuestion(prompt) {
const modelOption = this.selectedModelOption;
if (!modelOption) {
$A.messageWarning('请选择模型');
return false;
}
this.loadIng++;
let responseEntry = null;
try {
// 浮窗普通对话(无 beforeSendHook):按需在历史尾部追加一条页面弱提示词(role:system,UI 隐藏)
// 专用入口(ChatInput/TaskAdd/ReportEdit 等)走 beforeSendHook 自定义 context,不参与该机制
if (this.sessionEnabled && !this.beforeSendHook) {
this.injectWeakPromptIfNeeded();
}
const baseContext = await this.collectBaseContext(prompt);
const context = await this.buildPayloadData(baseContext);
// 处理图片:存储到独立缓存,生成带占位符的 prompt
const currentContent = this.buildCurrentContent(prompt);
const displayPrompt = await this.processContentForStorage(currentContent);
responseEntry = this.createResponseEntry({
modelOption,
prompt: displayPrompt,
});
this.scrollResponsesToBottom();
const streamKey = await this.fetchStreamKey({
model_type: modelOption.type,
model_name: modelOption.value,
context,
});
this.persistInputHistory(prompt);
this.startStream(streamKey, responseEntry);
return true;
} catch (error) {
const msg = error?.msg || '发送失败';
if (responseEntry) {
this.markResponseError(responseEntry, msg);
}
$A.modalError(msg);
return false;
} finally {
this.loadIng--;
}
},
/**
* 构建最终发送的数据
*/
async buildPayloadData(context) {
const baseContext = this.normalizeContextEntries(context);
if (typeof this.beforeSendHook !== 'function') {
return baseContext;
}
try {
const clonedContext = baseContext.map(entry => entry.slice());
const result = this.beforeSendHook(clonedContext);
const resolved = result && typeof result.then === 'function'
? await result
: result;
const prepared = this.normalizeContextEntries(resolved);
if (prepared.length) {
return prepared;
}
} catch (e) {
console.warn('[AIAssistant] onBeforeSend error:', e);
}
return baseContext;
},
/**
* 还原历史 prompt 中的图片占位符为多模态内容
* @param {string} prompt - 带占位符的 prompt
* @returns {Promise<string|Array>} - 纯文本或多模态数组
*/
async restorePromptImages(prompt) {
if (!prompt || typeof prompt !== 'string') {
return prompt || '';
}
const parsed = this.parsePromptContent(prompt);
// 没有图片,返回纯文本
if (parsed.images.length === 0) {
return parsed.text;
}
// 有图片,构建多模态内容
const content = [];
for (const img of parsed.images) {
const dataUrl = await this.getImageFromCache(img.imageId);
if (dataUrl) {
content.push({
type: 'image_url',
image_url: {url: dataUrl}
});
}
}
if (parsed.text) {
content.push({type: 'text', text: parsed.text});
}
// 如果所有图片都获取失败,返回纯文本
return content.length > 0 ? content : parsed.text;
},
/**
* 汇总当前会话的基础上下文
* 浮窗普通对话:context[0] 永远是默认系统提示词;之后可选地追加一条
* 动态附加 system(operationSessionId 等运行时信息,不入库);再后是历史
* 消息(其中 role==='system' 的弱提示词原样穿插);末尾是本次用户消息。
*/
async collectBaseContext(prompt) {
const pushEntry = (context, role, value) => {
if (typeof value === 'undefined' || value === null) {
return;
}
// value 可以是字符串或多模态数组
if (typeof value === 'string') {
const content = value.trim();
if (!content) {
return;
}
context.push([role, content]);
} else if (Array.isArray(value) && value.length > 0) {
// 多模态内容
context.push([role, value]);
}
};
const context = [];
// 默认系统提示词只在"浮窗普通对话"场景注入(无 beforeSendHook)
// 专用入口走 beforeSendHook 完全重建 context,不应受默认 prompt 干扰
if (this.sessionEnabled && !this.beforeSendHook) {
pushEntry(context, 'system', withLanguagePreferencePrompt(BASE_ASSISTANT_SYSTEM_PROMPT));
// 运行时附加 system(如操作模块的 session_id),不入库
if (typeof this.dynamicSystemHook === 'function') {
try {
const extra = this.dynamicSystemHook();
if (extra) {
pushEntry(context, 'system', String(extra));
}
} catch (e) {
console.warn('[AIAssistant] dynamicSystemHook error:', e);
}
}
}
const windowSize = Number(this.contextWindowSize) || 0;
const recentResponses = windowSize > 0
? this.responses.slice(-windowSize)
: this.responses;
// 处理历史消息(还原图片 base64
for (const item of recentResponses) {
if (item.role === 'system') {
// 弱提示词等持久化的 system 节点
pushEntry(context, 'system', item.content);
continue;
}
if (item.prompt) {
const restoredPrompt = await this.restorePromptImages(item.prompt);
pushEntry(context, 'human', restoredPrompt);
}
if (item.rawOutput) {
pushEntry(context, 'assistant', item.rawOutput);
}
}
// 构建当前提问内容(可能包含图片)
if (prompt && String(prompt).trim()) {
const currentContent = this.buildCurrentContent(prompt);
pushEntry(context, 'human', currentContent);
}
return context;
},
/**
* 在 responses 中从后向前查找最后一条 role==='system' 节点
*/
findLastSystemEntry(kind) {
for (let i = this.responses.length - 1; i >= 0; i--) {
const r = this.responses[i];
if (r.role === 'system' && (!kind || r.systemKind === kind)) {
return r;
}
}
return null;
},
/**
* 浮窗普通对话:按需追加一条页面弱提示词节点
* - 与最后一条 pageContext system 的 contextKey 不同(或没有)才追加
* - 首次追加用 [当前页面] 前缀,后续追加用 [页面切换]
*
* 为什么穿插进历史而不是"只在顶部放当前页":
* 用户可能在不同项目/任务页连续提问,如果只放当前页,历史里两个"这个项目"
* 会失去锚点,AI 可能把旧问题也归到当前页。穿插能给历史每一条 user 消息
* 打上"问题发生时所在的页面",AI 才能正确分辨各自指向哪个实体。
*/
injectWeakPromptIfNeeded() {
let weak = null;
try {
weak = buildWeakPrompt(this.$store, this.$route?.params || {});
} catch (e) {
console.warn('[AIAssistant] buildWeakPrompt error:', e);
return;
}
if (!weak || !weak.contextKey) {
return;
}
const lastWeak = this.findLastSystemEntry('pageContext');
if (lastWeak && lastWeak.contextKey === weak.contextKey) {
return;
}
const switching = !!lastWeak;
const content = renderWeakPromptText(weak, switching);
if (!content) {
return;
}
this.responses.push({
localId: this.responseSeed++,
role: 'system',
systemKind: 'pageContext',
contextKey: weak.contextKey,
content,
});
},
/**
* 构建当前提问内容(支持多模态)
*/
buildCurrentContent(prompt) {
const text = String(prompt).trim();
if (!this.pendingImages.length) {
// 无图片,返回纯文本
return text;
}
// 有图片,构建多模态内容数组
const content = [];
// 先添加图片
for (const img of this.pendingImages) {
content.push({
type: 'image_url',
image_url: {url: img.dataUrl}
});
}
// 后添加文本
if (text) {
content.push({type: 'text', text});
}
return content;
},
/**
* 归一化上下文结构
*/
normalizeContextEntries(context) {
if (!Array.isArray(context)) {
return [];
}
const normalized = [];
context.forEach(entry => {
if (!Array.isArray(entry) || entry.length < 2) {
return;
}
const [role, value] = entry;
const roleName = typeof role === 'string' ? role.trim() : '';
// 支持多模态内容(数组)
if (Array.isArray(value)) {
if (roleName && value.length > 0) {
normalized.push([roleName, value]);
}
return;
}
const content = typeof value === 'string'
? value.trim()
: String(value ?? '').trim();
if (!roleName || !content) {
return;
}
const last = normalized[normalized.length - 1];
const canMergeWithLast = last
&& last[0] === roleName
&& typeof last[1] === 'string'
&& last[1].slice(-4) === '++++';
if (canMergeWithLast) {
const previousContent = last[1].slice(0, -4);
last[1] = previousContent ? `${previousContent}\n${content}` : content;
return;
}
normalized.push([roleName, content]);
});
return normalized;
},
/**
* 请求 stream_key
*/
async fetchStreamKey({model_type, model_name, context}) {
const locale = /zh/i.test(getLanguage()) ? 'zh' : 'en';
const payload = {
model_type,
model_name,
context: JSON.stringify(context || []),
locale,
// 前端会话ID链路透传为 AI 服务 context_key用于检索打点关联
session_id: this.currentSessionId || '',
};
const {data} = await this.$store.dispatch("call", {
url: 'assistant/auth',
method: 'post',
data: payload,
});
const streamKey = data?.stream_key || '';
if (!streamKey) {
throw new Error('获取 stream_key 失败');
}
return streamKey;
},
/**
* 启动 SSE 订阅
*/
startStream(streamKey, responseEntry) {
if (!streamKey) {
throw new Error('获取 stream_key 失败');
}
const owner = {
sessionKey: this.currentSessionKey,
sessionId: this.currentSessionId,
localId: responseEntry.localId,
};
// 只清理同一会话的残留流,保留其它会话的后台流以支持并发续推
this.clearOwnerSSEClients(owner.sessionKey, owner.sessionId);
const sse = new SSEClient($A.mainUrl(`ai/invoke/stream/${streamKey}`));
this.registerSSEClient(sse, owner);
sse.subscribe(['append', 'replace', 'done'], (type, event) => {
switch (type) {
case 'append':
case 'replace':
this.handleStreamChunk(owner, type, event);
break;
case 'done':
this.handleStreamDone(owner, sse, event);
break;
}
}, () => {
this.handleStreamFailed(owner, sse);
});
return sse;
},
/**
* 定位流式回调应写入的目标(前台当前会话或已切走的后台会话)
*/
locateStreamTarget(owner) {
// 归属会话正好在前台,直接写当前视图
if (this.currentSessionKey === owner.sessionKey
&& this.currentSessionId === owner.sessionId) {
return {
entry: this.responses.find(r => r.localId === owner.localId) || null,
isCurrent: true,
session: null,
};
}
// 已切到后台但仍是同一场景,写回存储里的会话
if (this.currentSessionKey === owner.sessionKey) {
const session = this.sessionStore.find(s => s.id === owner.sessionId) || null;
const entry = session
? (session.responses || []).find(r => r.localId === owner.localId) || null
: null;
return {entry, isCurrent: false, session};
}
// 场景已切换,存储已被替换,放弃归位避免写串到别的场景
return {entry: null, isCurrent: false, session: null};
},
/**
* 流式完成
*/
handleStreamDone(owner, sse, event) {
const donePayload = this.parseStreamPayload(event);
const target = this.locateStreamTarget(owner);
// 完成前先判断是否仍贴底(此时反馈行尚未渲染)
const stickToBottom = target.isCurrent && this.shouldStickToBottom();
if (target.entry) {
if (donePayload && donePayload.error) {
this.markResponseError(target.entry, donePayload.error);
} else if (target.entry.status !== 'error') {
target.entry.status = 'completed';
}
}
this.releaseSSEClient(sse);
this.persistStreamTarget(target);
// 完成后反馈/时间等按钮新增了高度,贴底用户需要再滚到底部
if (stickToBottom) {
this.scrollResponsesToBottom();
}
},
/**
* 流式失败(重试用尽)
*/
handleStreamFailed(owner, sse) {
const target = this.locateStreamTarget(owner);
if (target.entry && ['streaming', 'waiting'].includes(target.entry.status)) {
this.markResponseError(target.entry, this.$L('连接失败,请重试'));
}
this.releaseSSEClient(sse);
this.persistStreamTarget(target);
},
/**
* 按归属持久化:前台存当前会话,后台存对应会话
*/
persistStreamTarget(target) {
if (target.isCurrent) {
this.saveCurrentSession();
} else if (target.session) {
target.session.updatedAt = Date.now();
this.saveSessionStore(target.session.id);
}
},
/**
* 处理 SSE 片段
*/
handleStreamChunk(owner, type, event) {
const target = this.locateStreamTarget(owner);
const entry = target.entry;
if (!entry) {
return;
}
const stickToBottom = target.isCurrent && this.shouldStickToBottom();
const payload = this.parseStreamPayload(event);
const chunk = this.resolveStreamContent(payload);
if (type === 'replace') {
entry.rawOutput = chunk;
} else {
entry.rawOutput += chunk;
}
this.updateResponseDisplayOutput(entry);
entry.status = 'streaming';
if (target.isCurrent && stickToBottom) {
this.scrollResponsesToBottom();
}
},
/**
* 解析 SSE 数据
*/
parseStreamPayload(event) {
if (!event || !event.data) {
return {};
}
try {
return JSON.parse(event.data);
} catch (e) {
return {};
}
},
/**
* 获取 SSE 文本
*/
resolveStreamContent(payload) {
if (!payload || typeof payload !== 'object') {
return '';
}
if (typeof payload.content === 'string') {
return payload.content;
}
if (typeof payload.c === 'string') {
return payload.c;
}
return '';
},
/**
* 将 SSE 客户端加入活跃列表,记录归属以便定向清理
*/
registerSSEClient(sse, owner = {}) {
if (!sse) {
return;
}
this.activeSSEClients.push({
sse,
sessionKey: owner.sessionKey,
sessionId: owner.sessionId,
localId: owner.localId,
});
},
/**
* 从活跃列表移除 SSE 客户端并执行注销
*/
releaseSSEClient(sse) {
const index = this.activeSSEClients.findIndex(item => item.sse === sse);
if (index > -1) {
this.activeSSEClients.splice(index, 1);
}
sse.unsunscribe();
},
/**
* 关闭所有活跃的 SSE 连接
*/
clearActiveSSEClients() {
this.activeSSEClients.forEach(item => {
try {
item.sse.unsunscribe();
} catch (e) {
}
});
this.activeSSEClients = [];
},
/**
* 仅关闭指定会话的活跃 SSE 连接
*/
clearOwnerSSEClients(sessionKey, sessionId) {
this.activeSSEClients = this.activeSSEClients.filter(item => {
if (item.sessionKey === sessionKey && item.sessionId === sessionId) {
this.cancelStreamOwner(item);
try {
item.sse.unsunscribe();
} catch (e) {
}
return false;
}
return true;
});
},
/**
* 取消归属的流式条目
*/
cancelStreamOwner(item) {
const target = this.locateStreamTarget({
sessionKey: item.sessionKey,
sessionId: item.sessionId,
localId: item.localId,
});
if (target.entry && ['streaming', 'waiting'].includes(target.entry.status)) {
this.markResponseError(target.entry, this.$L('已取消'));
this.persistStreamTarget(target);
}
},
/**
* 清除自动提交定时器
*/
clearAutoSubmitTimer() {
if (this.autoSubmitTimer) {
clearTimeout(this.autoSubmitTimer);
this.autoSubmitTimer = null;
}
},
/**
* 调度自动提交
*/
scheduleAutoSubmit() {
if (!this.pendingAutoSubmit) {
return;
}
const attemptSubmit = () => {
if (!this.pendingAutoSubmit) {
return;
}
if (this.canAutoSubmit()) {
this.pendingAutoSubmit = false;
this.clearAutoSubmitTimer();
this.onSubmit();
return;
}
this.autoSubmitTimer = setTimeout(attemptSubmit, 200);
};
this.clearAutoSubmitTimer();
this.autoSubmitTimer = setTimeout(attemptSubmit, 0);
},
/**
* 检查是否可以自动提交
*/
canAutoSubmit() {
return !this.modelsLoading
&& !!this.selectedModelOption
&& this.responses.length === 0
&& this.loadIng === 0;
},
/**
* 新建响应卡片
*/
createResponseEntry({modelOption, prompt}) {
const entry = {
localId: this.responseSeed++,
id: null,
model: modelOption.value,
modelLabel: modelOption.label,
type: modelOption.type,
prompt: prompt.trim(),
rawOutput: '',
displayOutput: '',
status: 'waiting',
error: '',
applyLoading: false,
feedback: '',
createdAt: Date.now(),
};
this.responses.push(entry);
if (this.responses.length > this.maxResponses) {
this.responses.shift();
}
return entry;
},
/**
* 标记响应失败
*/
markResponseError(response, msg) {
response.status = 'error';
response.error = msg;
},
/**
* 将AI内容应用到父组件
*/
applyResponse(response) {
if (!response || response.applyLoading) {
return;
}
if (!response.rawOutput) {
$A.messageWarning('暂无可用内容');
return;
}
if (typeof this.applyHook !== 'function') {
this.closeAssistant();
return;
}
response.applyLoading = true;
const payload = this.buildResponsePayload(response, true);
try {
const result = this.applyHook(payload);
if (result && typeof result.then === 'function') {
result.then(() => {
this.closeAssistant();
}).catch(error => {
$A.modalError(error?.msg || '应用失败');
}).finally(() => {
response.applyLoading = false;
});
} else {
this.closeAssistant();
response.applyLoading = false;
}
} catch (error) {
response.applyLoading = false;
$A.modalError(error?.msg || '应用错误');
}
},
/**
* 构造发送给外部回调的统一数据结构
*/
buildResponsePayload(response, removeReasoning = false) {
if (!response) {
return {
model: '',
type: '',
prompt: '',
rawOutput: '',
};
}
return {
model: response.model,
type: response.type,
prompt: response.prompt,
rawOutput: removeReasoning ? this.removeReasoningSections(response.rawOutput) : response.rawOutput,
};
},
/**
* 从回调中剔除 reasoning block
*/
removeReasoningSections(text) {
if (typeof text !== 'string') {
return text;
}
return text.replace(/:::\s*reasoning[\s\S]*?:::/gi, '').trim();
},
/**
* 从回复末尾的引用行提取 ai-kb source id
* 主格式RAG hint 要求):"参考howto.task-create, faq.xxx" / "References: ..."
* 同时兼容 LLM 偶发输出的 markdown 链接形态 "[标题](howto.task-create)"
*/
extractSourceIds(text) {
if (typeof text !== 'string' || !text) {
return [];
}
const cleaned = this.removeReasoningSections(text);
const m = cleaned.match(/(?:参考|References?)\s*[:]\s*(.+?)\s*$/im);
if (!m) {
return [];
}
const ids = m[1].match(/[a-z0-9][\w-]*(?:\.[a-z0-9][\w-]*)+/gi) || [];
return [...new Set(ids)].slice(0, 10);
},
/**
* 复制回复内容(去除推理段落)
*/
copyResponse(response) {
const text = this.removeReasoningSections(response?.displayOutput || response?.rawOutput) || '';
if (!text) {
return;
}
this.copyText(text);
},
/**
* 提交 👍/👎 反馈(可改票:点另一个值覆盖更新;再点当前已选项则取消)
*/
async submitFeedback(response, value) {
if (!response || response.feedbackLoading) {
return;
}
// 点击当前已选中的反馈表示取消
const next = response.feedback === value ? '' : value;
const prev = response.feedback;
this.$set(response, 'feedback', next);
this.$set(response, 'feedbackLoading', true);
try {
await this.$store.dispatch("call", {
url: 'assistant/feedback/save',
method: 'post',
data: {
session_key: this.currentSessionKey,
session_id: this.currentSessionId || '',
local_id: response.localId,
feedback: next,
prompt: this.parsePromptContent(response.prompt).text.substring(0, 1000),
answer: (this.removeReasoningSections(response.rawOutput) || '').substring(0, 2000),
source_ids: this.extractSourceIds(response.rawOutput),
model: response.model,
},
});
// 先复位 loading 再保存,避免把 feedbackLoading=true 持久化导致重载后无法再点
this.$set(response, 'feedbackLoading', false);
this.saveCurrentSession();
} catch (e) {
this.$set(response, 'feedback', prev);
this.$set(response, 'feedbackLoading', false);
$A.messageError(e?.msg || '反馈失败,请重试');
}
},
/**
* 根据 onRender 回调生成展示文本
*/
updateResponseDisplayOutput(response) {
if (!response) {
return;
}
if (typeof this.renderHook !== 'function') {
response.displayOutput = response.rawOutput;
return;
}
try {
const payload = this.buildResponsePayload(response);
const result = this.renderHook(payload);
if (result && typeof result.then === 'function') {
console.warn('[AIAssistant] onRender should be synchronous');
response.displayOutput = response.rawOutput;
return;
}
response.displayOutput = typeof result === 'string' ? result : response.rawOutput;
} catch (e) {
console.warn('[AIAssistant] onRender error:', e);
response.displayOutput = response.rawOutput;
}
},
/**
* 关闭弹窗
*/
closeAssistant() {
if (this.closing) {
return;
}
this.closing = true;
this.pendingAutoSubmit = false;
this.clearAutoSubmitTimer();
this.resetInputHistoryNavigation();
this.clearPendingImages();
this.showModal = false;
setTimeout(() => {
this.closing = false;
}, 300);
},
/**
* 页面引导启动时收起浮窗,避免遮挡目标元素
*/
/**
* 滚动结果区域到底部
*/
scrollResponsesToBottom() {
this.$nextTick(() => {
const container = this.$refs.responseContainer;
if (container && container.scrollHeight) {
container.scrollTop = container.scrollHeight;
}
});
},
/**
* 判断是否需要保持滚动到底部
*/
shouldStickToBottom(threshold = 20) {
const container = this.$refs.responseContainer;
if (!container) {
return true;
}
const currentBottom = container.scrollTop + container.clientHeight;
const distance = container.scrollHeight - currentBottom;
if (Number.isNaN(distance)) {
return true;
}
return distance <= threshold;
},
// ==================== 会话管理方法 ====================
/**
* 加载会话列表(按 sessionKey 桶过滤;浮窗固定 'global' 桶,专用入口用各自的桶)
*/
async loadSessionStore(sessionKey) {
try {
const {data} = await this.$store.dispatch("call", {
url: 'assistant/session/list',
data: {session_key: sessionKey || this.currentSessionKey},
});
if (Array.isArray(data)) {
data.forEach(session => {
if (Array.isArray(session.responses)) {
session.responses.forEach(r => {
if (r.status === 'streaming' || r.status === 'waiting') {
r.status = 'error';
r.error = r.error || this.$L('会话中断');
}
// feedbackLoading 为瞬时态,恢复时强制复位,避免历史脏数据卡死反馈按钮
r.feedbackLoading = false;
});
}
// 缓存服务端返回的图片URL映射
if (session.images) {
Object.assign(this.serverImageMap, session.images);
}
});
this.sessionStore = data;
} else {
this.sessionStore = [];
}
} catch (e) {
console.warn('[AIAssistant] 加载会话失败:', e);
this.sessionStore = [];
}
this.sessionStoreLoaded = true;
},
/**
* 持久化前归一未完成态(streaming/waiting)为 error,避免服务端残留卡死状态
*/
sanitizeResponsesForPersist(responses) {
return (responses || []).map(r => {
// feedbackLoading 为瞬时 UI 态,不持久化,避免重载后卡死反馈按钮
const {feedbackLoading, ...rest} = r;
if (rest.status === 'streaming' || rest.status === 'waiting') {
return {...rest, status: 'error', error: rest.error || this.$L('会话中断')};
}
return rest;
});
},
/**
* 持久化当前场景的会话数据
*/
async saveSessionStore(sessionId = this.currentSessionId) {
if (!sessionId) return;
const session = this.sessionStore.find(s => s.id === sessionId);
if (!session) return;
// 收集本次需要上传的新图片(在 imageCache 中有 base64 但 serverImageMap 中没有的)
const newImages = [];
const imageIds = this.extractImageIdsFromSession(session);
for (const imageId of imageIds) {
if (!this.serverImageMap[imageId] && this.imageCache[imageId]) {
newImages.push({
imageId,
dataUrl: this.imageCache[imageId],
});
}
}
try {
const {data} = await this.$store.dispatch("call", {
url: 'assistant/session/save',
method: 'post',
data: {
session_key: this.currentSessionKey,
session_id: session.id,
title: session.title || '',
data: this.sanitizeResponsesForPersist(session.responses),
new_images: newImages,
},
});
// 更新服务端图片映射
if (data?.image_urls) {
Object.assign(this.serverImageMap, data.image_urls);
}
} catch (e) {
console.warn('[AIAssistant] 保存会话失败:', e);
}
},
/**
* 生成会话 ID
*/
generateSessionId() {
return `session-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
},
/**
* 根据首次输入生成会话标题
*/
generateSessionTitle(responses) {
if (!responses || responses.length === 0) {
return this.$L('新会话');
}
const firstPrompt = responses.find(r => r.role !== 'system' && r.prompt)?.prompt || '';
if (!firstPrompt) {
return this.$L('新会话');
}
// 过滤图片占位符,只保留文本
const textOnly = this.parsePromptContent(firstPrompt).text;
if (!textOnly) {
return this.$L('新会话');
}
// 截取前20个字符作为标题
const title = textOnly.trim().substring(0, 20);
return title.length < textOnly.trim().length ? `${title}...` : title;
},
/**
* 获取当前场景的会话列表
*/
getSessionList() {
return this.sessionStore || [];
},
/**
* 初始化会话
* sessionKey 传值即启用会话管理(浮窗用 'global',专用入口用各自 key,如 'chat-message')
* 切换 sessionKey 时切换桶并保存当前会话
* 不再按 scene_key 自动恢复:同一桶内打开时保留当前 session_id,没有则建新会话
*/
async initSession(sessionKey) {
// 专用入口未传 sessionKey 的兼容路径(不启用会话管理)
if (!sessionKey) {
this.sessionEnabled = false;
this.currentSessionKey = 'default';
this.currentSessionId = null;
this.responses = [];
this.sessionStoreLoaded = false;
return;
}
// 切到不同的 sessionKey 桶(浮窗 ↔ 专用入口):先保存当前会话
const switchingBucket = this.sessionEnabled && this.currentSessionKey !== sessionKey;
if (switchingBucket && this.responses.length > 0) {
this.saveCurrentSession();
}
this.sessionEnabled = true;
if (switchingBucket || this.currentSessionKey !== sessionKey || !this.sessionStoreLoaded) {
this.currentSessionKey = sessionKey;
await this.loadSessionStore(sessionKey);
// 切桶后清空当前会话指针,下面再决定新建/恢复
if (switchingBucket) {
this.currentSessionId = null;
this.responses = [];
}
}
// 已有当前会话则保留;否则建一个空的新会话
if (!this.currentSessionId) {
this.currentSessionId = this.generateSessionId();
this.responses = [];
}
},
/**
* 创建新会话
*/
createNewSession(autoSaveCurrent = true) {
// 保存当前会话
if (autoSaveCurrent && this.responses.length > 0) {
this.saveCurrentSession();
}
// 创建新会话
this.currentSessionId = this.generateSessionId();
this.responses = [];
},
/**
* 保存当前会话到存储
*/
saveCurrentSession() {
if (!this.sessionEnabled || !this.currentSessionId || this.responses.length === 0) {
return;
}
// 确保 sessionStore 是数组
if (!Array.isArray(this.sessionStore)) {
this.sessionStore = [];
}
const existingIndex = this.sessionStore.findIndex(s => s.id === this.currentSessionId);
const sessionData = {
id: this.currentSessionId,
title: this.generateSessionTitle(this.responses),
responses: JSON.parse(JSON.stringify(this.responses)),
createdAt: existingIndex > -1 ? this.sessionStore[existingIndex].createdAt : Date.now(),
updatedAt: Date.now(),
};
if (existingIndex > -1) {
this.sessionStore.splice(existingIndex, 1, sessionData);
} else {
this.sessionStore.unshift(sessionData);
}
// 限制每个场景的会话数量
if (this.sessionStore.length > this.maxSessionsPerKey) {
this.sessionStore.splice(this.maxSessionsPerKey);
}
this.saveSessionStoreDebounced(this.currentSessionId);
},
/**
* 防抖保存指定 sessionId 的会话
*/
saveSessionStoreDebounced(sessionId) {
if (!sessionId) return;
let fn = this.saveSessionStoreDebouncedMap[sessionId];
if (!fn) {
fn = debounce(() => {
this.saveSessionStore(sessionId);
}, 2000);
this.saveSessionStoreDebouncedMap[sessionId] = fn;
}
fn();
},
/**
* 加载指定会话
*/
loadSession(sessionId) {
const sessions = this.getSessionList();
const session = sessions.find(s => s.id === sessionId);
if (session) {
// 先保存当前会话
if (this.currentSessionId !== sessionId && this.responses.length > 0) {
this.saveCurrentSession();
}
this.currentSessionId = session.id;
this.responses = JSON.parse(JSON.stringify(session.responses));
this.syncResponseSeed();
this.scrollResponsesToBottom();
}
},
/**
* 同步 responseSeed 以避免与已有响应 localId 冲突
*/
syncResponseSeed() {
if (this.responses.length === 0) {
return;
}
const maxLocalId = this.responses.reduce((max, r) => {
return Math.max(max, r.localId || 0);
}, 0);
if (maxLocalId >= this.responseSeed) {
this.responseSeed = maxLocalId + 1;
}
},
/**
* 删除指定会话
*/
/**
* 第一次点击删除进入「确认删除」态不立即删除3 秒后自动复位
*/
askDeleteSession(sessionId) {
this.confirmingDeleteId = sessionId;
clearTimeout(this.confirmingDeleteTimer);
this.confirmingDeleteTimer = setTimeout(() => {
this.resetDeleteConfirm();
}, 3000);
},
/**
* 复位「确认删除」态
*/
resetDeleteConfirm() {
this.confirmingDeleteId = null;
clearTimeout(this.confirmingDeleteTimer);
this.confirmingDeleteTimer = null;
},
/**
* 历史下拉显隐变化时复位「确认删除」态
*/
onHistoryVisibleChange() {
this.resetDeleteConfirm();
},
deleteSession(sessionId) {
this.resetDeleteConfirm();
const index = this.sessionStore.findIndex(s => s.id === sessionId);
if (index > -1) {
const session = this.sessionStore[index];
this.clearSessionImageCache(session);
this.sessionStore.splice(index, 1);
this.$store.dispatch("call", {
url: 'assistant/session/delete',
method: 'post',
data: {
session_key: this.currentSessionKey,
session_id: sessionId,
},
}).catch(e => console.warn('[AIAssistant] 删除会话失败:', e));
if (this.currentSessionId === sessionId) {
this.createNewSession(false);
}
}
},
/**
* 清空当前场景的所有历史会话
*/
clearSessionHistory() {
$A.modalConfirm({
title: this.$L('清空历史会话'),
content: this.$L('确定要清空所有历史会话吗?'),
onOk: () => {
this.serverImageMap = {};
this.imageCache = {};
this.sessionStore = [];
this.$store.dispatch("call", {
url: 'assistant/session/delete',
method: 'post',
data: {
session_key: this.currentSessionKey,
clear_all: true,
},
}).catch(e => console.warn('[AIAssistant] 清空会话失败:', e));
this.createNewSession(false);
}
});
},
/**
* 格式化会话时间显示
*/
/**
* 格式化单条回复时间(规则参考 happy-next formatMessageTime
* - 今天HH:mm
* - 本周内(周一为起点):周几 HH:mm
* - 今年内MM-DD HH:mm
* - 更早YYYY-MM-DD
*/
formatResponseTime(timestamp) {
if (!timestamp) {
return '';
}
const now = $A.daytz();
const time = $A.dayjs(timestamp);
const hm = time.format('HH:mm');
// 今天
if (now.format('YYYY-MM-DD') === time.format('YYYY-MM-DD')) {
return hm;
}
// 本周内(周一 00:00 起点,且不晚于当前)
const weekStart = now.subtract((now.day() + 6) % 7, 'day').startOf('day');
if (time.valueOf() >= weekStart.valueOf() && time.valueOf() <= now.valueOf()) {
const map = {zh: 'zh-CN', 'zh-CHT': 'zh-TW'};
const lang = getLanguage();
const locale = map[lang] || lang || 'en';
const weekday = new Intl.DateTimeFormat(locale, {weekday: 'short'}).format(time.toDate());
return `${weekday} ${hm}`;
}
// 今年内
if (now.year() === time.year()) {
return time.format('MM-DD HH:mm');
}
return time.format('YYYY-MM-DD');
},
formatSessionTime(timestamp) {
const now = $A.daytz();
const time = $A.dayjs(timestamp);
if (now.format('YYYY-MM-DD') === time.format('YYYY-MM-DD')) {
return this.$L('今天') + ' ' + time.format('HH:mm');
}
if (now.subtract(1, 'day').format('YYYY-MM-DD') === time.format('YYYY-MM-DD')) {
return this.$L('昨天') + ' ' + time.format('HH:mm');
}
if (now.year() === time.year()) {
return time.format('MM-DD HH:mm');
}
return time.format('YYYY-MM-DD HH:mm');
},
// ==================== 输入历史(上下键切换)====================
/**
* 加载输入历史
*/
async loadInputHistory() {
try {
const history = await $A.IDBValue(this.inputHistoryCacheKey);
if (Array.isArray(history)) {
this.inputHistoryList = history;
} else {
this.inputHistoryList = [];
}
} catch (e) {
this.inputHistoryList = [];
}
this.inputHistoryIndex = this.inputHistoryList.length;
this.inputHistoryCurrent = '';
},
/**
* 保存输入到历史
*/
persistInputHistory(content) {
const trimmed = (content || '').trim();
if (!trimmed) {
return;
}
const history = Array.isArray(this.inputHistoryList) ? [...this.inputHistoryList] : [];
// 如果和最后一条相同,不重复添加
if (history[history.length - 1] === trimmed) {
this.inputHistoryIndex = history.length;
this.inputHistoryCurrent = '';
return;
}
// 如果已存在,移动到末尾
const existIndex = history.indexOf(trimmed);
if (existIndex !== -1) {
history.splice(existIndex, 1);
}
history.push(trimmed);
// 限制最大条数
if (history.length > this.inputHistoryLimit) {
history.splice(0, history.length - this.inputHistoryLimit);
}
this.inputHistoryList = history;
this.inputHistoryIndex = history.length;
this.inputHistoryCurrent = '';
$A.IDBSet(this.inputHistoryCacheKey, history).catch(() => {});
},
/**
* 重置历史导航状态
*/
resetInputHistoryNavigation() {
this.inputHistoryIndex = this.inputHistoryList.length;
this.inputHistoryCurrent = '';
},
/**
* 导航输入历史
* @param {string} direction - 'up' 或 'down'
* @returns {boolean} - 是否允许默认行为
*/
navigateInputHistory(direction) {
if (!this.inputHistoryList.length) {
return true;
}
const textarea = this.$refs.inputRef?.$el?.querySelector('textarea');
if (!textarea) {
return true;
}
const cursorPos = textarea.selectionStart;
const cursorEnd = textarea.selectionEnd;
const value = this.inputValue || '';
// 如果有选中文本,允许默认行为
if (cursorPos !== cursorEnd) {
return true;
}
if (direction === 'up') {
// 只有光标在第一行时才切换历史
const beforeCursor = value.substring(0, cursorPos);
if (beforeCursor.includes('\n')) {
return true;
}
// 保存当前输入
if (this.inputHistoryIndex === this.inputHistoryList.length) {
this.inputHistoryCurrent = value;
}
if (this.inputHistoryIndex > 0) {
this.inputHistoryIndex--;
this.inputValue = this.inputHistoryList[this.inputHistoryIndex] || '';
this.$nextTick(() => {
const ta = this.$refs.inputRef?.$el?.querySelector('textarea');
ta?.setSelectionRange(0, 0);
});
return false;
}
} else if (direction === 'down') {
// 只有光标在最后一行时才切换历史
const afterCursor = value.substring(cursorPos);
if (afterCursor.includes('\n')) {
return true;
}
if (this.inputHistoryIndex >= this.inputHistoryList.length) {
return true;
}
if (this.inputHistoryIndex < this.inputHistoryList.length - 1) {
this.inputHistoryIndex++;
this.inputValue = this.inputHistoryList[this.inputHistoryIndex] || '';
} else {
this.inputHistoryIndex = this.inputHistoryList.length;
this.inputValue = this.inputHistoryCurrent || '';
}
this.$nextTick(() => {
const ta = this.$refs.inputRef?.$el?.querySelector('textarea');
if (ta) {
const len = (this.inputValue || '').length;
ta.setSelectionRange(len, len);
}
});
return false;
}
return true;
},
// ==================== 编辑历史问题 ====================
/**
* 开始编辑历史问题
*/
/**
* 切换用户消息的展开状态:默认最多两行,点击后显示全部,再次点击收起
*/
toggleQuestionExpand(localId) {
if (localId === undefined || localId === null) {
return;
}
const idx = this.expandedQuestionIds.indexOf(localId);
if (idx === -1) {
this.expandedQuestionIds.push(localId);
} else {
this.expandedQuestionIds.splice(idx, 1);
}
},
startEditQuestion(index) {
if (index < 0 || index >= this.responses.length) {
return;
}
if (this.loadIng > 0) {
return;
}
this.editingIndex = index;
this.editingValue = this.responses[index].prompt || '';
this.$nextTick(() => {
// ref 在 v-for 中会变成数组
const inputRef = this.$refs.editInputRef;
const input = Array.isArray(inputRef) ? inputRef[0] : inputRef;
if (input && typeof input.focus === 'function') {
input.focus();
}
});
},
/**
* 取消编辑
*/
cancelEditQuestion() {
this.editingIndex = -1;
this.editingValue = '';
},
/**
* 编辑器键盘事件
*/
onEditKeydown(e) {
if (e.key === 'Escape') {
e.preventDefault();
this.cancelEditQuestion();
} else if (e.key === 'Enter' && !e.shiftKey && !this.isComposing) {
e.preventDefault();
this.submitEditedQuestion();
}
},
/**
* 提交编辑后的问题
*/
async submitEditedQuestion() {
if (this.editingIndex < 0 || this.loadIng > 0) {
return;
}
const newPrompt = (this.editingValue || '').trim();
if (!newPrompt) {
$A.messageWarning('请输入问题');
return;
}
// 删除从编辑位置开始的所有响应
this.responses.splice(this.editingIndex);
// 重置编辑状态
this.editingIndex = -1;
this.editingValue = '';
// 发送新问题
await this._doSendQuestion(newPrompt);
},
// ==================== z-index 管理 ====================
/**
* 更新 z-index 确保在最顶层
*/
updateTopZIndex() {
this.topZIndex = (window.modalTransferIndex || 1000) + 1000;
},
/**
* 启动 z-index 刷新定时器
* @param {number} interval - 刷新间隔(毫秒)
*/
startZIndexTimer(interval) {
this.stopZIndexTimer();
this.updateTopZIndex();
this.zIndexTimer = setInterval(() => {
this.updateTopZIndex();
}, interval);
},
/**
* 停止 z-index 刷新定时器
*/
stopZIndexTimer() {
if (this.zIndexTimer) {
clearInterval(this.zIndexTimer);
this.zIndexTimer = null;
}
},
// ==================== 图片上传相关 ====================
/**
* 触发图片选择
*/
triggerImageSelect() {
if (this.$refs.imageInput) {
this.$refs.imageInput.click();
}
},
/**
* 处理图片选择
*/
async onImageSelect(event) {
const files = event.target.files;
await this.handleImageFiles(files);
event.target.value = '';
},
/**
* 通用图片文件处理方法
* @param {FileList|File[]} files - 文件列表
*/
async handleImageFiles(files) {
if (!files || files.length === 0) {
return;
}
const remainingSlots = this.maxImages - this.pendingImages.length;
if (remainingSlots <= 0) {
$A.messageWarning(`最多上传 ${this.maxImages} 张图片`);
return;
}
const filesToProcess = Array.from(files).slice(0, remainingSlots);
for (const file of filesToProcess) {
if (!file.type.startsWith('image/')) {
continue;
}
try {
const dataUrl = await this.compressImageForAI(file);
this.pendingImages.push({
id: ++this.imageIdSeed,
dataUrl,
file,
});
} catch (e) {
console.warn('[AIAssistant] 图片压缩失败:', e);
}
}
},
/**
* 处理拖放进入
*/
onDragEnter(event) {
// 检查是否包含文件
if (event.dataTransfer?.types?.includes('Files')) {
this.dragCounter++;
this.isDragging = true;
}
},
/**
* 处理拖放离开
*/
onDragLeave() {
this.dragCounter--;
if (this.dragCounter <= 0) {
this.dragCounter = 0;
this.isDragging = false;
}
},
/**
* 处理拖放放置
*/
async onDrop(event) {
this.dragCounter = 0;
this.isDragging = false;
const files = event.dataTransfer?.files;
if (files && files.length > 0) {
const imageFiles = Array.from(files).filter(f => f.type.startsWith('image/'));
await this.handleImageFiles(imageFiles);
}
},
/**
* 处理粘贴图片
*/
async onPaste(event) {
const items = event.clipboardData?.items;
if (!items) {
return;
}
const imageFiles = [];
for (const item of items) {
if (item.type.startsWith('image/')) {
const file = item.getAsFile();
if (file) {
imageFiles.push(file);
}
}
}
if (imageFiles.length > 0) {
event.preventDefault();
await this.handleImageFiles(imageFiles);
}
},
/**
* 压缩图片用于 AI 视觉分析
* @param {File} file - 图片文件
* @returns {Promise<string>} - Base64 data URL (image/jpeg)
*/
async compressImageForAI(file) {
const dataUrl = await this.fileToDataUrl(file);
return this.resizeDataUrl(dataUrl, 1568, true);
},
/**
* File 转 dataUrl
*/
fileToDataUrl(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = () => reject(new Error('文件读取失败'));
reader.readAsDataURL(file);
});
},
/**
* 移除待发送的图片
*/
removeImage(id) {
const index = this.pendingImages.findIndex(img => img.id === id);
if (index !== -1) {
this.pendingImages.splice(index, 1);
}
},
/**
* 清空待发送的图片
*/
clearPendingImages() {
this.pendingImages = [];
},
// ==================== 图片缓存管理 ====================
/**
* 生成图片缓存 ID
*/
generateImageCacheId() {
const timestamp = Date.now();
const random = Math.random().toString(36).substr(2, 6);
return `${timestamp}_${random}`;
},
/**
* 保存图片到独立缓存
*/
saveImageToCache(imageId, dataUrl) {
this.imageCache[imageId] = dataUrl;
},
/**
* 压缩 dataUrl 图片到指定尺寸
* @param {string} dataUrl - Base64 图片
* @param {number} maxSize - 最大边长
* @param {boolean} forceCompress - 是否强制质量压缩(即使尺寸不变)
* @returns {Promise<string>} - 压缩后的 dataUrl
*/
resizeDataUrl(dataUrl, maxSize, forceCompress = false) {
return new Promise((resolve, reject) => {
// 参数校验
if (!dataUrl || typeof dataUrl !== 'string') {
reject(new Error('无效的图片数据'));
return;
}
const img = new Image();
img.onload = () => {
let {width, height} = img;
const needResize = width > maxSize || height > maxSize;
// 如果不需要缩放且不强制压缩,直接返回原图
if (!needResize && !forceCompress) {
resolve(dataUrl);
return;
}
// 计算缩放比例
if (needResize) {
const ratio = Math.min(maxSize / width, maxSize / height);
width = Math.round(width * ratio);
height = Math.round(height * ratio);
}
// Canvas 压缩
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#FFFFFF';
ctx.fillRect(0, 0, width, height);
ctx.drawImage(img, 0, 0, width, height);
resolve(canvas.toDataURL('image/jpeg', 0.8));
};
img.onerror = () => reject(new Error('图片加载失败'));
img.src = dataUrl;
});
},
/**
* 从缓存获取图片
*/
async getImageFromCache(imageId) {
// 1. 先检查内存缓存
if (this.imageCache[imageId]) {
return this.imageCache[imageId];
}
// 2. 检查服务端URL映射
if (this.serverImageMap[imageId]) {
return this.serverImageMap[imageId];
}
return null;
},
/**
* 删除单个图片缓存
*/
deleteImageCache(imageId) {
delete this.imageCache[imageId];
delete this.serverImageMap[imageId];
},
/**
* 从会话中提取所有图片 ID
*/
extractImageIdsFromSession(session) {
const imageIds = [];
if (!session?.responses) return imageIds;
for (const response of session.responses) {
if (response.role === 'system') continue;
if (response.prompt) {
const parsed = this.parsePromptContent(response.prompt);
for (const img of parsed.images) {
imageIds.push(img.imageId);
}
}
}
return imageIds;
},
/**
* 清理会话相关的图片缓存
*/
clearSessionImageCache(session) {
const imageIds = this.extractImageIdsFromSession(session);
for (const imageId of imageIds) {
this.deleteImageCache(imageId);
}
},
/**
* 处理多模态内容用于存储
* 将图片存储到独立缓存,返回带占位符的纯文本
* @param {string|Array} content - 消息内容(字符串或多模态数组)
* @returns {Promise<string>} - 带占位符的纯文本
*/
async processContentForStorage(content) {
// 如果是字符串,直接返回
if (typeof content === 'string') {
return content;
}
// 如果不是数组,转为字符串
if (!Array.isArray(content)) {
return String(content);
}
// 处理多模态数组
const parts = [];
for (const item of content) {
if (item.type === 'text') {
parts.push(item.text || '');
} else if (item.type === 'image_url' && item.image_url?.url) {
// 生成图片 ID 并存储
const imageId = this.generateImageCacheId();
await this.saveImageToCache(imageId, item.image_url.url);
parts.push(`[IMG:${imageId}]`);
}
}
return parts.join(' ');
},
/**
* 解析 prompt 内容,分离图片和文字
* @param {string} text - 带占位符的文本
* @returns {Object} - {images: [{imageId}], text: string}
*/
parsePromptContent(text) {
const result = {images: [], text: ''};
if (!text || typeof text !== 'string') {
result.text = text || '';
return result;
}
const regex = /\[IMG:([^\]]+)\]/g;
const textParts = [];
let lastIndex = 0;
let match;
while ((match = regex.exec(text)) !== null) {
// 收集图片
result.images.push({imageId: match[1]});
// 收集占位符前的文本
if (match.index > lastIndex) {
textParts.push(text.slice(lastIndex, match.index));
}
lastIndex = match.index + match[0].length;
}
// 收集剩余文本
if (lastIndex < text.length) {
textParts.push(text.slice(lastIndex));
}
result.text = textParts.join('').trim();
return result;
},
},
}
</script>
<style lang="scss">
.ai-assistant-header {
display: flex;
align-items: center;
margin: -11px 24px -10px 0;
height: 38px;
.ai-assistant-header-title {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
color: #303133;
padding-right: 12px;
gap: 8px;
> i {
font-size: 18px;
}
> span {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 18px;
font-weight: 500;
}
}
.ai-assistant-header-actions {
display: flex;
align-items: center;
gap: 6px;
.ai-assistant-header-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 6px;
cursor: pointer;
transition: background-color 0.2s;
&:hover {
background-color: rgba(0, 0, 0, 0.06);
}
> i {
font-size: 18px;
}
}
}
}
.ai-assistant-content {
display: flex;
flex-direction: column;
position: relative;
&.ai-assistant-content-dragging {
&::before {
content: '';
position: absolute;
inset: 8px;
border: 2px dashed #2d8cf0;
border-radius: 8px;
background-color: rgba(45, 140, 240, 0.05);
pointer-events: none;
z-index: 10;
}
}
.ai-assistant-drop-overlay {
position: absolute;
inset: 8px;
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(255, 255, 255, 0.9);
border-radius: 8px;
z-index: 11;
pointer-events: none;
}
.ai-assistant-drop-hint {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
color: #2d8cf0;
.taskfont {
font-size: 32px;
}
span {
font-size: 14px;
}
}
.ai-assistant-welcome,
.ai-assistant-output {
flex: 1;
min-height: 0;
padding: 12px 24px;
margin-bottom: 12px;
border-radius: 0;
background: #f8f9fb;
border: 0;
overflow-y: auto;
}
.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-apply {
position: sticky;
top: 0;
right: 0;
z-index: 1;
display: flex;
justify-content: flex-end;
align-items: center;
height: 26px;
color: #999;
gap: 4px;
}
.ai-assistant-output-icon {
font-size: 16px;
color: #52c41a;
}
.ai-assistant-apply-btn {
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
border-radius: 4px;
height: 26px;
padding: 0 8px;
}
.ai-assistant-output-status {
color: #52c41a;
}
.ai-assistant-output-error {
color: #ff4d4f;
}
.ai-assistant-output-meta {
display: flex;
align-items: center;
height: 24px;
margin-top: -24px;
}
.ai-assistant-output-model {
max-width: 50%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
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-wrap {
margin-top: 8px;
}
.ai-assistant-output-question {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 12px;
color: #666;
line-height: 1.4;
.ai-assistant-output-question-images {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.ai-assistant-output-question-content {
display: flex;
align-items: flex-start;
gap: 4px;
}
.ai-assistant-output-question-text {
flex: 1;
min-width: 0;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
cursor: pointer;
&.is-expanded {
-webkit-line-clamp: unset;
overflow: visible;
}
}
.ai-assistant-output-question-edit {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
color: #777;
border-radius: 4px;
margin-top: -2px;
cursor: pointer;
opacity: 0;
transition: opacity 0.2s, color 0.2s, background-color 0.2s;
svg {
width: 14px;
height: 14px;
}
&:hover {
color: #444;
background-color: rgba(0, 0, 0, 0.06);
}
}
&:hover {
.ai-assistant-output-question-edit {
opacity: 1;
}
}
}
.ai-assistant-question-editor {
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px;
background: #fff;
border: 1px solid #e8e8e8;
border-radius: 13px;
.ivu-input {
color: #333;
background-color: transparent;
border: 0;
border-radius: 0;
box-shadow: none;
padding: 0 2px;
resize: none;
font-size: 12px;
&:hover,
&:focus {
border-color: transparent;
box-shadow: none;
}
}
.ai-assistant-question-editor-btns {
display: flex;
justify-content: flex-end;
gap: 8px;
.ivu-btn {
height: 26px;
font-size: 12px;
padding: 0 9px;
border-radius: 13px;
}
}
}
.ai-assistant-output-placeholder {
line-height: 22px;
margin-top: 12px;
font-size: 13px;
color: #999;
padding: 6px 10px;
border-radius: 6px;
background: rgba(0, 0, 0, 0.02);
}
.ai-assistant-output-feedback {
margin-top: 8px;
display: flex;
justify-content: flex-end;
gap: 3px;
.ai-assistant-feedback-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border-radius: 6px;
color: #999;
cursor: pointer;
transition: all 0.2s;
svg {
width: 14px;
height: 14px;
}
&:hover {
color: #666;
background: rgba(0, 0, 0, 0.05);
}
&.active {
color: var(--primary-color, #8bcf70);
background: rgba(139, 207, 112, 0.08);
}
&.ai-assistant-feedback-btn-down {
&.active {
color: #f56c6c;
background: rgba(245, 108, 108, 0.08);
}
}
}
.ai-assistant-output-time {
display: inline-flex;
align-items: center;
margin-left: 4px;
font-size: 12px;
color: #bbb;
white-space: nowrap;
}
}
.ai-assistant-output-markdown {
margin-top: 12px;
font-size: 13px;
min-height: 34px;
.apply-reasoning {
margin: 0 0 12px 0;
padding: 0 0 0 13px;
line-height: 26px;
position: relative;
&:before {
content: "";
position: absolute;
top: 0;
left: 0;
bottom: 0;
width: 2px;
background-color: var(--apply-reasoning-before-bg);
}
.reasoning-label {
margin-bottom: 4px;
opacity: 0.9;
}
.reasoning-content {
opacity: 0.5;
> p:last-child {
margin-bottom: 0;
}
}
}
}
}
.ai-assistant-input {
padding: 4px 16px 16px;
display: flex;
flex-direction: column;
gap: 12px;
.ivu-input {
color: #333333;
background-color: transparent;
border: 0;
border-radius: 0;
box-shadow: none;
padding: 0 8px;
resize: none;
&:hover,
&:focus {
border-color: transparent;
box-shadow: none;
}
}
.ivu-select-selection {
background-color: transparent;
border: 0;
border-radius: 0;
box-shadow: none;
padding: 0 0 0 8px;
}
}
.ai-assistant-footer {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
.ai-assistant-footer-models {
text-align: left;
.ivu-select-disabled {
.ivu-select-selection {
background-color: transparent;
}
}
.ivu-select-selection {
border: 0;
box-shadow: none;
.ivu-select-placeholder,
.ivu-select-selected-value {
padding-left: 0;
opacity: 0.8;
}
}
}
.ai-assistant-footer-btns {
flex: 1;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
}
.ai-assistant-image-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 50%;
cursor: pointer;
transition: background-color 0.2s;
color: #666;
&:hover {
background-color: rgba(0, 0, 0, 0.05);
color: #333;
}
.taskfont {
font-size: 18px;
}
}
}
.ai-assistant-images {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 8px;
background: rgba(0, 0, 0, 0.02);
border-radius: 8px;
margin-top: -4px;
.ai-assistant-image-item {
position: relative;
width: 60px;
height: 60px;
border-radius: 6px;
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
.ai-assistant-image-remove {
position: absolute;
top: 2px;
right: 2px;
width: 18px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.5);
border-radius: 50%;
cursor: pointer;
opacity: 0;
transition: opacity 0.2s;
.taskfont {
font-size: 12px;
color: #fff;
}
}
&:hover .ai-assistant-image-remove {
opacity: 1;
}
}
}
.ai-assistant-history-menu {
width: 260px;
max-height: 320px;
overflow-y: auto;
.ivu-dropdown-item {
&.active {
background-color: rgba(45, 140, 240, 0.1);
}
&:hover {
background-color: rgba(0, 0, 0, 0.04);
}
}
.history-item {
display: flex;
flex-direction: column;
gap: 2px;
.history-item-content {
flex: 1;
min-width: 0;
display: flex;
gap: 8px;
line-height: 20px;
align-items: center;
.history-item-title {
flex: 1;
min-width: 0;
font-size: 13px;
color: #303133;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.history-item-delete {
flex-shrink: 0;
display: none;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 4px;
margin-right: -2px;
transition: opacity 0.2s, background-color 0.2s;
cursor: pointer;
&:hover {
background-color: rgba(0, 0, 0, 0.08);
}
> i {
font-size: 12px;
color: #909399;
}
&.history-item-delete-confirm {
display: flex;
color: #fff;
background-color: #f56c6c;
opacity: 1;
&:hover {
background-color: #e15a5a;
}
> svg {
width: 13px;
height: 13px;
}
}
}
}
.history-item-time {
font-size: 11px;
color: #909399;
}
&:hover {
.history-item-content {
.history-item-delete {
display: flex;
}
}
}
// 触摸设备无 hover删除按钮常显
@media (pointer: coarse) {
.history-item-content {
.history-item-delete {
display: flex;
opacity: 0.6;
}
}
}
}
.history-clear {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: #F56C6C;
}
}
.ai-assistant-chat-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(55, 55, 55, 0.6);
}
.ai-assistant-chat {
position: fixed;
width: 460px;
height: 600px;
min-width: 380px;
max-width: 800px;
min-height: 400px;
max-height: 900px;
background-color: #ffffff;
box-shadow: 0 10px 30px 0 rgba(0, 0, 0, 0.12);
border-radius: 16px;
overflow: hidden;
display: flex;
flex-direction: column;
// 调整大小控制点基础样式
.ai-assistant-resize-handle {
position: absolute;
z-index: 10;
}
// 四边控制点
.ai-assistant-resize-n {
top: 0;
left: 8px;
right: 8px;
height: 6px;
cursor: n-resize;
}
.ai-assistant-resize-s {
bottom: 0;
left: 8px;
right: 8px;
height: 6px;
cursor: s-resize;
}
.ai-assistant-resize-e {
top: 8px;
right: 0;
bottom: 8px;
width: 6px;
cursor: e-resize;
}
.ai-assistant-resize-w {
top: 8px;
left: 0;
bottom: 8px;
width: 6px;
cursor: w-resize;
}
// 四角控制点
.ai-assistant-resize-ne {
top: 0;
right: 0;
width: 12px;
height: 12px;
cursor: ne-resize;
}
.ai-assistant-resize-nw {
top: 0;
left: 0;
width: 12px;
height: 12px;
cursor: nw-resize;
}
.ai-assistant-resize-se {
bottom: 0;
right: 0;
width: 12px;
height: 12px;
cursor: se-resize;
}
.ai-assistant-resize-sw {
bottom: 0;
left: 0;
width: 12px;
height: 12px;
cursor: sw-resize;
}
.ai-assistant-fullscreen {
position: absolute;
top: 11px;
right: 48px;
z-index: 1;
width: 28px;
height: 28px;
padding: 4px;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
svg {
width: 100%;
height: 100%;
stroke: #777;
transition: stroke 0.2s;
}
&:hover {
background-color: rgba(0, 0, 0, 0.06);
svg {
stroke: #444;
}
}
}
.ai-assistant-close {
position: absolute;
top: 6px;
right: 10px;
z-index: 1;
font-size: 38px;
color: #999;
cursor: pointer;
transition: all 0.2s;
border-radius: 50%;
overflow: hidden;
&:hover {
color: #444;
transform: rotate(-90deg);
}
}
.ai-assistant-drag-handle {
cursor: move;
user-select: none;
}
.ai-assistant-header {
margin: 6px 82px 6px 16px;
.ai-assistant-header-title {
> span {
font-size: 17px;
}
}
}
.ai-assistant-content {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
.ai-assistant-welcome {
display: flex;
flex-direction: column;
align-items: center;
.ai-assistant-welcome-icon {
flex-shrink: 0;
margin-top: auto;
display: flex;
align-items: center;
justify-content: center;
width: 52px;
height: 52px;
border-radius: 50%;
background: #8bcf70;
margin-bottom: 24px;
svg {
width: 28px;
height: 28px;
fill: #fff;
}
}
.ai-assistant-welcome-title {
font-size: 16px;
margin-bottom: 24px;
font-weight: 500;
color: #303133;
}
.ai-assistant-welcome-prompts {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 12px;
max-width: 100%;
padding: 0 8px;
margin-bottom: auto;
}
.ai-assistant-prompt-card {
min-width: 0;
overflow: hidden;
display: flex;
align-items: center;
gap: 8px;
background: #fff;
border: 1px solid #e8e8e8;
border-radius: 8px;
color: #303133;
cursor: pointer;
transition: all 0.2s;
padding: 8px 12px;
font-size: 13px;
&:hover {
border-color: #8bcf70;
box-shadow: 0 2px 8px rgba(139, 207, 112, 0.15);
}
&:active {
transform: scale(0.98);
}
.ai-assistant-prompt-icon {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
flex-shrink: 0;
svg {
width: 100%;
height: 100%;
stroke: #8bcf70;
}
}
> span:last-child {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
}
}
.ai-assistant-input {
padding: 4px 12px 12px;
}
// 全屏状态
&.is-fullscreen {
top: 12px;
left: 12px;
right: 12px;
bottom: 12px;
width: auto;
height: auto;
max-width: none;
max-height: none;
.ai-assistant-drag-handle {
cursor: default;
}
}
// 移动端沉浸式:参考全局搜索,顶部留 status-bar + 46px,顶部圆角
&.is-mobile-fullscreen {
top: calc(var(--status-bar-height, 0px) + 46px);
left: 0;
right: 0;
bottom: 0;
border-radius: 18px 18px 0 0;
box-shadow: none;
.ai-assistant-header {
margin-right: 52px;
}
.ai-assistant-input {
padding-bottom: calc(12px + var(--navigation-bar-height, 0px));
}
}
}
.ai-assistant-assist {
width: 0;
height: 0;
opacity: 0;
display: none;
visibility: hidden;
pointer-events: none;
}
.ai-assistant-modal {
--apply-reasoning-before-bg: #e1e1e1;
.ivu-modal {
transition: width 0.3s, max-width 0.3s;
.ivu-modal-header {
border-bottom: none !important;
}
.ivu-modal-body {
padding: 0 !important;
}
}
.ai-assistant-content {
max-height: calc(var(--window-height) - var(--status-bar-height) - var(--navigation-bar-height) - 266px);
@media (height <= 900px) {
max-height: calc(var(--window-height) - var(--status-bar-height) - var(--navigation-bar-height) - 136px);
}
}
}
body.window-portrait {
.ai-assistant-content {
.ai-assistant-output-feedback {
gap: 6px;
.ai-assistant-feedback-btn {
svg {
width: 15px;
height: 15px;
}
}
}
}
}
body.dark-mode-reverse {
.ai-assistant-content {
.ai-assistant-welcome,
.ai-assistant-output {
background-color: #f5f5f5;
}
.ai-assistant-prompt-card {
background: #fff;
border-color: #d9d9d9;
&:hover {
background: rgba(102, 126, 234, 0.06);
}
}
}
.ai-assistant-chat {
background-color: #e9e9e9;
box-shadow: none;
}
.ai-assistant-modal {
--apply-reasoning-before-bg: #4e4e56;
}
}
</style>