feat(ai-assistant): AI 浮窗/浮按钮移动端适配与流式不中断

This commit is contained in:
kuaifan 2026-06-10 04:05:09 +00:00
parent 20c3fa91fb
commit f6067d1bd5
6 changed files with 662 additions and 536 deletions

View File

@ -12,7 +12,8 @@
class="ai-float-button" class="ai-float-button"
:class="btnClass" :class="btnClass"
:style="btnStyle" :style="btnStyle"
@mousedown.stop.prevent="onMouseDown"> @mousedown.stop.prevent="onMouseDown"
@touchstart.stop="onTouchStart">
<!-- 完整图标 --> <!-- 完整图标 -->
<svg class="ai-float-button-icon no-dark-content" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"> <svg class="ai-float-button-icon 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"/> <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"/>
@ -25,8 +26,6 @@
<script> <script>
import {mapState} from "vuex"; import {mapState} from "vuex";
import emitter from "../../store/events"; import emitter from "../../store/events";
import {withLanguagePreferencePrompt} from "../../utils/ai";
import {getPageContext, getSceneKey} from "./page-context";
import {createOperationModule} from "./operation-module"; import {createOperationModule} from "./operation-module";
export default { export default {
@ -48,8 +47,9 @@ export default {
btnSize: 44, btnSize: 44,
collapsedHeight: 48, // collapsedHeight: 48, //
collapseThreshold: 12, // collapseThreshold: 12, //
collapseDelay: 1000, // collapseDelayDesktop: 1000, //
collapseTimer: null, // collapseDelayMobile: 5000, //
collapseTimer: null, //
record: {}, record: {},
// //
operationModule: null, operationModule: null,
@ -68,7 +68,6 @@ export default {
return this.aiInstalled && return this.aiInstalled &&
this.userId > 0 && this.userId > 0 &&
this.positionLoaded && this.positionLoaded &&
!this.windowPortrait &&
this.routeName !== 'login' && this.routeName !== 'login' &&
!this.$parent?.showModal; !this.$parent?.showModal;
}, },
@ -163,6 +162,9 @@ export default {
document.removeEventListener('mousemove', this.onMouseMove); document.removeEventListener('mousemove', this.onMouseMove);
document.removeEventListener('mouseup', this.onMouseUp); document.removeEventListener('mouseup', this.onMouseUp);
document.removeEventListener('contextmenu', this.onContextMenu); document.removeEventListener('contextmenu', this.onContextMenu);
document.removeEventListener('touchmove', this.onTouchMove);
document.removeEventListener('touchend', this.onTouchEnd);
document.removeEventListener('touchcancel', this.onTouchEnd);
this.clearCollapseTimer(); this.clearCollapseTimer();
this.destroyOperationModule(); this.destroyOperationModule();
}, },
@ -182,6 +184,7 @@ export default {
this.$nextTick(() => { this.$nextTick(() => {
this.checkBounds(); this.checkBounds();
this.positionLoaded = true; this.positionLoaded = true;
this.scheduleCollapse();
}); });
return; return;
} }
@ -189,9 +192,12 @@ export default {
} catch (e) { } catch (e) {
// ignore // ignore
} }
// // :,
this.position = {x: 24, y: 24, fromRight: true, fromBottom: true, collapsed: false}; this.position = this.windowPortrait
? {x: 12, y: Math.max(Math.round(this.clientHeight / 4), 100), fromRight: true, fromBottom: true, collapsed: false}
: {x: 24, y: 100, fromRight: true, fromBottom: true, collapsed: false};
this.positionLoaded = true; this.positionLoaded = true;
this.scheduleCollapse();
}, },
/** /**
@ -226,6 +232,8 @@ export default {
// //
if (e.button !== 0) return; if (e.button !== 0) return;
this.clearCollapseTimer();
// AI // AI
if (this.collapsed) { if (this.collapsed) {
this.onClick(); this.onClick();
@ -298,6 +306,96 @@ export default {
this.scheduleCollapse(); this.scheduleCollapse();
}, },
/**
* 触摸开始
*/
onTouchStart(e) {
if (e.touches.length !== 1) return;
this.clearCollapseTimer();
const touch = e.touches[0];
const btnRect = this.$refs.floatBtn.getBoundingClientRect();
this.record = {
time: Date.now(),
startLeft: btnRect.left,
startTop: btnRect.top,
offsetX: touch.clientX - btnRect.left,
offsetY: touch.clientY - btnRect.top,
startCollapsed: this.collapsed,
};
this.dragging = true;
document.addEventListener('touchmove', this.onTouchMove, {passive: false});
document.addEventListener('touchend', this.onTouchEnd);
document.addEventListener('touchcancel', this.onTouchEnd);
},
/**
* 触摸移动
*/
onTouchMove(e) {
if (!this.dragging || e.touches.length !== 1) return;
e.preventDefault();
const touch = e.touches[0];
// ,便
if (this.position.collapsed) {
this.position.collapsed = false;
}
let newLeft = touch.clientX - this.record.offsetX;
let newTop = touch.clientY - this.record.offsetY;
//
const minVisible = this.btnSize / 3;
const maxOverflow = this.btnSize - minVisible;
newLeft = Math.max(-maxOverflow, Math.min(newLeft, this.clientWidth - minVisible));
newTop = Math.max(-maxOverflow, Math.min(newTop, this.clientHeight - minVisible));
this.updatePositionFromCoords(newLeft, newTop);
},
/**
* 触摸松开
*/
onTouchEnd() {
document.removeEventListener('touchmove', this.onTouchMove);
document.removeEventListener('touchend', this.onTouchEnd);
document.removeEventListener('touchcancel', this.onTouchEnd);
if (!this.dragging) return;
const btnRect = this.$refs.floatBtn.getBoundingClientRect();
const moveDistance = Math.abs(btnRect.left - this.record.startLeft) + Math.abs(btnRect.top - this.record.startTop);
const duration = Date.now() - this.record.time;
this.dragging = false;
// 5px 200ms
if (moveDistance < 5 && duration < 200) {
if (this.record.startCollapsed) {
// :, 12, 5s
this.position.collapsed = false;
this.position.x = 12;
this.scheduleCollapse();
} else {
this.onClick();
}
this.savePosition();
return;
}
// < 12 , 12px 5s
if (this.position.x < 12) {
this.position.collapsed = true;
} else {
this.position.x = 12;
this.scheduleCollapse();
}
this.savePosition();
},
/** /**
* 鼠标进入 * 鼠标进入
*/ */
@ -325,12 +423,13 @@ export default {
*/ */
scheduleCollapse() { scheduleCollapse() {
this.clearCollapseTimer(); this.clearCollapseTimer();
// // , collapseThreshold
if (this.position.x <= this.collapseThreshold) { if (this.windowPortrait || this.position.x <= this.collapseThreshold) {
const delay = this.windowPortrait ? this.collapseDelayMobile : this.collapseDelayDesktop;
this.collapseTimer = setTimeout(() => { this.collapseTimer = setTimeout(() => {
this.position.collapsed = true; this.position.collapsed = true;
this.savePosition(); this.savePosition();
}, this.collapseDelay); }, delay);
} }
}, },
@ -369,19 +468,20 @@ export default {
* 点击按钮 * 点击按钮
*/ */
onClick() { onClick() {
const routeParams = this.$route?.params || {};
const sceneKey = getSceneKey(this.$store, routeParams);
// //
this.enableOperationModule(); this.enableOperationModule();
emitter.emit('openAIAssistant', { emitter.emit('openAIAssistant', {
displayMode: 'chat', displayMode: 'chat',
sessionKey: 'global', sessionKey: 'global',
sceneKey,
resumeSession: 86400,
showApplyButton: false, showApplyButton: false,
onBeforeSend: this.handleBeforeSend, // system(operationSessionId ,)
onDynamicSystem: () => {
if (this.operationSessionId) {
return `页面操作会话 operation_session_id=${this.operationSessionId}\n当调用需要绑定当前页面操作会话的工具时传入该 ID普通回答不要提及它不要修改或臆造它。`;
}
return null;
},
}); });
}, },
@ -392,33 +492,6 @@ export default {
this.disableOperationModule(); this.disableOperationModule();
}, },
/**
* 处理发送前的上下文准备
* 在发送时动态获取当前页面上下文确保上下文与用户当前所在页面一致
* @param context - 对话历史上下文
* @returns {(string|*)[][]}
*/
handleBeforeSend(context = []) {
const routeParams = this.$route?.params || {};
const {systemPrompt} = getPageContext(this.$store, routeParams);
//
let operationContext = '';
if (this.operationSessionId) {
operationContext = `\n\n页面操作会话 session_id: ${this.operationSessionId}`;
}
const prepared = [
['system', withLanguagePreferencePrompt(systemPrompt + operationContext)],
];
if (context.length > 0) {
prepared.push(...context);
}
return prepared;
},
/** /**
* 初始化操作模块 * 初始化操作模块
*/ */
@ -476,11 +549,16 @@ export default {
$btn-size: 44px; $btn-size: 44px;
$collapsed-width: 12px; $collapsed-width: 12px;
$collapsed-height: 48px; $collapsed-height: 48px;
$snap-dur: 0.8s;
$snap-ease: cubic-bezier(0.16, 1, 0.3, 1);
.ai-float-button-wrapper { .ai-float-button-wrapper {
position: fixed; position: fixed;
display: flex; display: flex;
align-items: center; align-items: center;
transition: left $snap-dur $snap-ease, top $snap-dur $snap-ease, width $snap-dur $snap-ease;
will-change: left, top;
transform: translateZ(0);
// transform // transform
&.is-right { &.is-right {
@ -493,8 +571,9 @@ $collapsed-height: 48px;
} }
&.is-dragging { &.is-dragging {
transition: none;
.ai-float-button { .ai-float-button {
transition: none !important; transition: box-shadow 0.2s, width $snap-dur $snap-ease, height $snap-dur $snap-ease, border-radius 0.25s ease-out !important;
} }
} }
} }
@ -509,7 +588,7 @@ $collapsed-height: 48px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
transition: transform 0.25s ease-out, box-shadow 0.2s, width 0.25s ease-out, height 0.25s ease-out, border-radius 0.25s ease-out; transition: transform $snap-dur $snap-ease, box-shadow 0.2s, width $snap-dur $snap-ease, height $snap-dur $snap-ease, border-radius 0.25s ease-out;
user-select: none; user-select: none;
flex-shrink: 0; flex-shrink: 0;

View File

@ -62,11 +62,11 @@
</div> </div>
</div> </div>
<div <div
v-if="responses.length" v-if="visibleResponses.length"
ref="responseContainer" ref="responseContainer"
class="ai-assistant-output"> class="ai-assistant-output">
<div <div
v-for="response in responses" v-for="response in visibleResponses"
:key="response.localId" :key="response.localId"
class="ai-assistant-output-item"> class="ai-assistant-output-item">
<div class="ai-assistant-output-apply"> <div class="ai-assistant-output-apply">
@ -235,11 +235,12 @@ import Vue from "vue";
import {debounce} from "lodash"; import {debounce} from "lodash";
import emitter from "../../store/events"; import emitter from "../../store/events";
import {SSEClient} from "../../utils"; import {SSEClient} from "../../utils";
import {AIBotMap, AIModelNames} from "../../utils/ai"; import {AIBotMap, AIModelNames, BASE_ASSISTANT_SYSTEM_PROMPT, withLanguagePreferencePrompt} from "../../utils/ai";
import DialogMarkdown from "../../pages/manage/components/DialogMarkdown.vue"; import DialogMarkdown from "../../pages/manage/components/DialogMarkdown.vue";
import FloatButton from "./float-button.vue"; import FloatButton from "./float-button.vue";
import AssistantModal from "./modal.vue"; import AssistantModal from "./modal.vue";
import PromptImage from "./prompt-image.vue"; import PromptImage from "./prompt-image.vue";
import {buildWeakPrompt, renderWeakPromptText} from "./page-context";
import {getWelcomePrompts} from "./welcome-prompts"; import {getWelcomePrompts} from "./welcome-prompts";
export default { export default {
@ -272,6 +273,8 @@ export default {
applyHook: null, applyHook: null,
beforeSendHook: null, beforeSendHook: null,
renderHook: null, renderHook: null,
// system (, string ;)
dynamicSystemHook: null,
// //
inputModel: '', inputModel: '',
@ -297,7 +300,6 @@ export default {
sessionStore: [], sessionStore: [],
currentSessionKey: 'default', currentSessionKey: 'default',
currentSessionId: null, currentSessionId: null,
currentSceneKey: null,
sessionCacheKeyPrefix: 'aiAssistant.sessions', sessionCacheKeyPrefix: 'aiAssistant.sessions',
maxSessionsPerKey: 20, maxSessionsPerKey: 20,
sessionStoreLoaded: false, sessionStoreLoaded: false,
@ -336,9 +338,7 @@ export default {
this.refreshWelcomePromptsDebounced = debounce(() => { this.refreshWelcomePromptsDebounced = debounce(() => {
this.displayWelcomePrompts = getWelcomePrompts(this.$store, this.$route?.params || {}); this.displayWelcomePrompts = getWelcomePrompts(this.$store, this.$route?.params || {});
}, 100); }, 100);
this.saveSessionStoreDebounced = debounce(() => { this.saveSessionStoreDebouncedMap = {};
this.saveSessionStore();
}, 2000);
}, },
mounted() { mounted() {
emitter.on('openAIAssistant', this.onOpenAIAssistant); emitter.on('openAIAssistant', this.onOpenAIAssistant);
@ -359,8 +359,12 @@ export default {
selectedModelOption({modelMap, inputModel}) { selectedModelOption({modelMap, inputModel}) {
return modelMap[inputModel] || null; return modelMap[inputModel] || null;
}, },
// role==='system'
visibleResponses() {
return this.responses.filter(r => r.role !== 'system');
},
shouldCreateNewSession() { shouldCreateNewSession() {
return this.responses.length === 0; return this.visibleResponses.length === 0;
}, },
currentSessionList() { currentSessionList() {
return this.sessionStore || []; return this.sessionStore || [];
@ -479,6 +483,7 @@ export default {
this.inputMaxlength = params.maxlength || null; this.inputMaxlength = params.maxlength || null;
this.applyHook = params.onApply || null; this.applyHook = params.onApply || null;
this.beforeSendHook = params.onBeforeSend || null; this.beforeSendHook = params.onBeforeSend || null;
this.dynamicSystemHook = params.onDynamicSystem || null;
this.modalTitle = params.title || null; this.modalTitle = params.title || null;
this.applyButtonText = params.applyButtonText || null; this.applyButtonText = params.applyButtonText || null;
this.submitButtonText = params.submitButtonText || null; this.submitButtonText = params.submitButtonText || null;
@ -487,12 +492,11 @@ export default {
this.renderHook = params.onRender || null; this.renderHook = params.onRender || null;
this.pendingAutoSubmit = !!params.autoSubmit; this.pendingAutoSubmit = !!params.autoSubmit;
// // (,)
await this.initSession(params.sessionKey, params.sceneKey, params.resumeSession); await this.initSession(params.sessionKey);
this.showModal = true; this.showModal = true;
this.fetchModelOptions(); this.fetchModelOptions();
this.clearActiveSSEClients();
this.clearAutoSubmitTimer(); this.clearAutoSubmitTimer();
this.$nextTick(() => { this.$nextTick(() => {
this.scheduleAutoSubmit(); this.scheduleAutoSubmit();
@ -711,6 +715,11 @@ export default {
this.loadIng++; this.loadIng++;
let responseEntry = null; let responseEntry = null;
try { try {
// ( beforeSendHook):(role:system,UI )
// (ChatInput/TaskAdd/ReportEdit ) beforeSendHook context,
if (this.sessionEnabled && !this.beforeSendHook) {
this.injectWeakPromptIfNeeded();
}
const baseContext = await this.collectBaseContext(prompt); const baseContext = await this.collectBaseContext(prompt);
const context = await this.buildPayloadData(baseContext); const context = await this.buildPayloadData(baseContext);
@ -803,6 +812,9 @@ export default {
/** /**
* 汇总当前会话的基础上下文 * 汇总当前会话的基础上下文
* 浮窗普通对话:context[0] 永远是默认系统提示词;之后可选地追加一条
* 动态附加 system(operationSessionId 等运行时信息,不入库);再后是历史
* 消息(其中 role==='system' 的弱提示词原样穿插);末尾是本次用户消息
*/ */
async collectBaseContext(prompt) { async collectBaseContext(prompt) {
const pushEntry = (context, role, value) => { const pushEntry = (context, role, value) => {
@ -822,12 +834,35 @@ export default {
} }
}; };
const context = []; 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 windowSize = Number(this.contextWindowSize) || 0;
const recentResponses = windowSize > 0 const recentResponses = windowSize > 0
? this.responses.slice(-windowSize) ? this.responses.slice(-windowSize)
: this.responses; : this.responses;
// base64 // base64
for (const item of recentResponses) { for (const item of recentResponses) {
if (item.role === 'system') {
// system
pushEntry(context, 'system', item.content);
continue;
}
if (item.prompt) { if (item.prompt) {
const restoredPrompt = await this.restorePromptImages(item.prompt); const restoredPrompt = await this.restorePromptImages(item.prompt);
pushEntry(context, 'human', restoredPrompt); pushEntry(context, 'human', restoredPrompt);
@ -844,6 +879,58 @@ export default {
return context; 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,
});
},
/** /**
* 构建当前提问内容支持多模态 * 构建当前提问内容支持多模态
*/ */
@ -941,57 +1028,117 @@ export default {
if (!streamKey) { if (!streamKey) {
throw new Error('获取 stream_key 失败'); throw new Error('获取 stream_key 失败');
} }
this.clearActiveSSEClients(); 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}`)); const sse = new SSEClient($A.mainUrl(`ai/invoke/stream/${streamKey}`));
this.registerSSEClient(sse); this.registerSSEClient(sse, owner);
sse.subscribe(['append', 'replace', 'done'], (type, event) => { sse.subscribe(['append', 'replace', 'done'], (type, event) => {
switch (type) { switch (type) {
case 'append': case 'append':
case 'replace': case 'replace':
this.handleStreamChunk(responseEntry, type, event); this.handleStreamChunk(owner, type, event);
break; break;
case 'done': case 'done':
// done this.handleStreamDone(owner, sse, event);
const donePayload = this.parseStreamPayload(event);
if (donePayload && donePayload.error) {
this.markResponseError(responseEntry, donePayload.error);
} else if (responseEntry && responseEntry.status !== 'error') {
responseEntry.status = 'completed';
}
this.releaseSSEClient(sse);
//
this.saveCurrentSession();
break; break;
} }
}, () => { }, () => {
// SSE this.handleStreamFailed(owner, sse);
if (responseEntry && ['streaming', 'waiting'].includes(responseEntry.status)) {
this.markResponseError(responseEntry, this.$L('连接失败,请重试'));
}
this.releaseSSEClient(sse);
this.saveCurrentSession();
}); });
return 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);
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);
},
/**
* 流式失败重试用尽
*/
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 片段 * 处理 SSE 片段
*/ */
handleStreamChunk(responseEntry, type, event) { handleStreamChunk(owner, type, event) {
if (!responseEntry) { const target = this.locateStreamTarget(owner);
const entry = target.entry;
if (!entry) {
return; return;
} }
const stickToBottom = this.shouldStickToBottom(); const stickToBottom = target.isCurrent && this.shouldStickToBottom();
const payload = this.parseStreamPayload(event); const payload = this.parseStreamPayload(event);
const chunk = this.resolveStreamContent(payload); const chunk = this.resolveStreamContent(payload);
if (type === 'replace') { if (type === 'replace') {
responseEntry.rawOutput = chunk; entry.rawOutput = chunk;
} else { } else {
responseEntry.rawOutput += chunk; entry.rawOutput += chunk;
} }
this.updateResponseDisplayOutput(responseEntry); this.updateResponseDisplayOutput(entry);
responseEntry.status = 'streaming'; entry.status = 'streaming';
if (stickToBottom) { if (target.isCurrent && stickToBottom) {
this.scrollResponsesToBottom(); this.scrollResponsesToBottom();
} }
}, },
@ -1027,20 +1174,25 @@ export default {
}, },
/** /**
* SSE 客户端加入活跃列表方便后续清理 * SSE 客户端加入活跃列表记录归属以便定向清理
*/ */
registerSSEClient(sse) { registerSSEClient(sse, owner = {}) {
if (!sse) { if (!sse) {
return; return;
} }
this.activeSSEClients.push(sse); this.activeSSEClients.push({
sse,
sessionKey: owner.sessionKey,
sessionId: owner.sessionId,
localId: owner.localId,
});
}, },
/** /**
* 从活跃列表移除 SSE 客户端并执行注销 * 从活跃列表移除 SSE 客户端并执行注销
*/ */
releaseSSEClient(sse) { releaseSSEClient(sse) {
const index = this.activeSSEClients.indexOf(sse); const index = this.activeSSEClients.findIndex(item => item.sse === sse);
if (index > -1) { if (index > -1) {
this.activeSSEClients.splice(index, 1); this.activeSSEClients.splice(index, 1);
} }
@ -1051,15 +1203,47 @@ export default {
* 关闭所有活跃的 SSE 连接 * 关闭所有活跃的 SSE 连接
*/ */
clearActiveSSEClients() { clearActiveSSEClients() {
this.activeSSEClients.forEach(sse => { this.activeSSEClients.forEach(item => {
try { try {
sse.unsunscribe(); item.sse.unsunscribe();
} catch (e) { } catch (e) {
} }
}); });
this.activeSSEClients = []; 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);
}
},
/** /**
* 清除自动提交定时器 * 清除自动提交定时器
*/ */
@ -1238,11 +1422,9 @@ export default {
this.closing = true; this.closing = true;
this.pendingAutoSubmit = false; this.pendingAutoSubmit = false;
this.clearAutoSubmitTimer(); this.clearAutoSubmitTimer();
this.clearActiveSSEClients();
this.resetInputHistoryNavigation(); this.resetInputHistoryNavigation();
this.clearPendingImages(); this.clearPendingImages();
this.showModal = false; this.showModal = false;
this.responses = [];
setTimeout(() => { setTimeout(() => {
this.closing = false; this.closing = false;
}, 300); }, 300);
@ -1279,22 +1461,30 @@ export default {
// ==================== ==================== // ==================== ====================
/** /**
* 加载指定场景的会话数据 * 加载会话列表( sessionKey 桶过滤;浮窗固定 'global' ,专用入口用各自的桶)
*/ */
async loadSessionStore(sessionKey) { async loadSessionStore(sessionKey) {
try { try {
const {data} = await this.$store.dispatch("call", { const {data} = await this.$store.dispatch("call", {
url: 'assistant/session/list', url: 'assistant/session/list',
data: {session_key: sessionKey}, data: {session_key: sessionKey || this.currentSessionKey},
}); });
if (Array.isArray(data)) { if (Array.isArray(data)) {
this.sessionStore = data;
// URL
data.forEach(session => { 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('会话中断');
}
});
}
// URL
if (session.images) { if (session.images) {
Object.assign(this.serverImageMap, session.images); Object.assign(this.serverImageMap, session.images);
} }
}); });
this.sessionStore = data;
} else { } else {
this.sessionStore = []; this.sessionStore = [];
} }
@ -1305,12 +1495,24 @@ export default {
this.sessionStoreLoaded = true; this.sessionStoreLoaded = true;
}, },
/**
* 持久化前归一未完成态(streaming/waiting) error,避免服务端残留卡死状态
*/
sanitizeResponsesForPersist(responses) {
return (responses || []).map(r => {
if (r.status === 'streaming' || r.status === 'waiting') {
return {...r, status: 'error', error: r.error || this.$L('会话中断')};
}
return r;
});
},
/** /**
* 持久化当前场景的会话数据 * 持久化当前场景的会话数据
*/ */
async saveSessionStore() { async saveSessionStore(sessionId = this.currentSessionId) {
if (!this.currentSessionId) return; if (!sessionId) return;
const session = this.sessionStore.find(s => s.id === this.currentSessionId); const session = this.sessionStore.find(s => s.id === sessionId);
if (!session) return; if (!session) return;
// imageCache base64 serverImageMap // imageCache base64 serverImageMap
@ -1332,9 +1534,8 @@ export default {
data: { data: {
session_key: this.currentSessionKey, session_key: this.currentSessionKey,
session_id: session.id, session_id: session.id,
scene_key: session.sceneKey || '',
title: session.title || '', title: session.title || '',
data: session.responses || [], data: this.sanitizeResponsesForPersist(session.responses),
new_images: newImages, new_images: newImages,
}, },
}); });
@ -1362,7 +1563,7 @@ export default {
if (!responses || responses.length === 0) { if (!responses || responses.length === 0) {
return this.$L('新会话'); return this.$L('新会话');
} }
const firstPrompt = responses.find(r => r.prompt)?.prompt || ''; const firstPrompt = responses.find(r => r.role !== 'system' && r.prompt)?.prompt || '';
if (!firstPrompt) { if (!firstPrompt) {
return this.$L('新会话'); return this.$L('新会话');
} }
@ -1385,52 +1586,43 @@ export default {
/** /**
* 初始化会话 * 初始化会话
* @param {string} sessionKey - 会话场景标识不传则不启用会话管理 * sessionKey 传值即启用会话管理(浮窗用 'global',专用入口用各自 key, 'chat-message')
* @param {string} sceneKey - 场景标识用于判断是否恢复会话 * 切换 sessionKey 时切换桶并保存当前会话
* @param {number} resumeTimeout - 恢复超时时间默认1天 * 不再按 scene_key 自动恢复:同一桶内打开时保留当前 session_id,没有则建新会话
*/ */
async initSession(sessionKey, sceneKey = null, resumeTimeout = 86400) { async initSession(sessionKey) {
// // sessionKey ()
if (this.responses.length > 0) { 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.saveCurrentSession();
} }
this.sessionEnabled = !!sessionKey; this.sessionEnabled = true;
this.currentSceneKey = sceneKey;
if (this.sessionEnabled) { if (switchingBucket || this.currentSessionKey !== sessionKey || !this.sessionStoreLoaded) {
// this.currentSessionKey = sessionKey;
if (this.currentSessionKey !== sessionKey || !this.sessionStoreLoaded) { await this.loadSessionStore(sessionKey);
this.currentSessionKey = sessionKey; // ,/
await this.loadSessionStore(sessionKey); if (switchingBucket) {
this.currentSessionId = null;
this.responses = [];
} }
}
// sceneKey // ;
if (sceneKey) { if (!this.currentSessionId) {
const sessions = this.getSessionList();
//
const matchedSession = sessions.find(s => s.sceneKey === sceneKey);
if (matchedSession) {
const elapsed = (Date.now() - matchedSession.updatedAt) / 1000;
//
if (elapsed <= resumeTimeout) {
this.currentSessionId = matchedSession.id;
this.responses = JSON.parse(JSON.stringify(matchedSession.responses));
this.syncResponseSeed();
return;
}
}
}
// sceneKey
this.currentSessionId = this.generateSessionId(); this.currentSessionId = this.generateSessionId();
this.responses = []; this.responses = [];
} else {
this.currentSessionKey = 'default';
this.currentSessionId = null;
this.currentSceneKey = null;
this.responses = [];
this.sessionStoreLoaded = false;
} }
}, },
@ -1465,7 +1657,6 @@ export default {
id: this.currentSessionId, id: this.currentSessionId,
title: this.generateSessionTitle(this.responses), title: this.generateSessionTitle(this.responses),
responses: JSON.parse(JSON.stringify(this.responses)), responses: JSON.parse(JSON.stringify(this.responses)),
sceneKey: this.currentSceneKey,
createdAt: existingIndex > -1 ? this.sessionStore[existingIndex].createdAt : Date.now(), createdAt: existingIndex > -1 ? this.sessionStore[existingIndex].createdAt : Date.now(),
updatedAt: Date.now(), updatedAt: Date.now(),
}; };
@ -1481,7 +1672,22 @@ export default {
this.sessionStore.splice(this.maxSessionsPerKey); this.sessionStore.splice(this.maxSessionsPerKey);
} }
this.saveSessionStoreDebounced(); 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();
}, },
/** /**
@ -1496,7 +1702,6 @@ export default {
this.saveCurrentSession(); this.saveCurrentSession();
} }
this.currentSessionId = session.id; this.currentSessionId = session.id;
this.currentSceneKey = session.sceneKey || null;
this.responses = JSON.parse(JSON.stringify(session.responses)); this.responses = JSON.parse(JSON.stringify(session.responses));
this.syncResponseSeed(); this.syncResponseSeed();
this.scrollResponsesToBottom(); this.scrollResponsesToBottom();
@ -1547,7 +1752,7 @@ export default {
clearSessionHistory() { clearSessionHistory() {
$A.modalConfirm({ $A.modalConfirm({
title: this.$L('清空历史会话'), title: this.$L('清空历史会话'),
content: this.$L('确定要清空当前场景的所有历史会话吗?'), content: this.$L('确定要清空所有历史会话吗?'),
onOk: () => { onOk: () => {
this.serverImageMap = {}; this.serverImageMap = {};
this.imageCache = {}; this.imageCache = {};
@ -2052,6 +2257,7 @@ export default {
const imageIds = []; const imageIds = [];
if (!session?.responses) return imageIds; if (!session?.responses) return imageIds;
for (const response of session.responses) { for (const response of session.responses) {
if (response.role === 'system') continue;
if (response.prompt) { if (response.prompt) {
const parsed = this.parsePromptContent(response.prompt); const parsed = this.parsePromptContent(response.prompt);
for (const img of parsed.images) { for (const img of parsed.images) {
@ -2664,6 +2870,15 @@ export default {
} }
} }
.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 { .ai-assistant-chat {
position: fixed; position: fixed;
width: 460px; width: 460px;
@ -2916,6 +3131,24 @@ export default {
cursor: default; 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-modal { .ai-assistant-modal {
@ -2935,6 +3168,31 @@ export default {
max-height: calc(var(--window-height) - var(--status-bar-height) - var(--navigation-bar-height) - 136px); max-height: calc(var(--window-height) - var(--status-bar-height) - var(--navigation-bar-height) - 136px);
} }
} }
// fullscreen Modal:, status-bar + 46px,
&.is-mobile-fullscreen {
.ivu-modal-content {
margin-top: calc(var(--status-bar-height, 0px) + 46px);
margin-bottom: 0;
border-top-left-radius: 18px !important;
border-top-right-radius: 18px !important;
}
.ivu-modal-body {
display: flex;
flex-direction: column;
}
.ai-assistant-header {
margin-right: 24px;
}
.ai-assistant-content {
flex: 1;
min-height: 0;
max-height: none;
}
.ai-assistant-input {
padding-bottom: calc(12px + var(--navigation-bar-height, 0px));
}
}
} }
body.dark-mode-reverse { body.dark-mode-reverse {

View File

@ -1,13 +1,19 @@
<template> <template>
<div v-if="displayMode === 'chat'" v-transfer-dom :data-transfer="true"> <div v-if="displayMode === 'chat'" v-transfer-dom :data-transfer="true">
<transition name="fade">
<div
v-if="visible && isMobile"
class="ai-assistant-chat-mask"
:style="{zIndex: zIndex - 1}"></div>
</transition>
<transition name="fade"> <transition name="fade">
<div <div
v-if="visible" v-if="visible"
ref="chatWindow" ref="chatWindow"
class="ai-assistant-chat" class="ai-assistant-chat"
:class="{'is-fullscreen': isFullscreen}" :class="{'is-fullscreen': effectiveFullscreen, 'is-mobile-fullscreen': isMobile}"
:style="chatStyle"> :style="chatStyle">
<div class="ai-assistant-fullscreen" @click="toggleFullscreen"> <div v-if="!isMobile" class="ai-assistant-fullscreen" @click="toggleFullscreen">
<svg v-if="isFullscreen" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg v-if="isFullscreen" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="4 10 10 10 10 4"/><polyline points="14 4 14 10 20 10"/> <polyline points="4 10 10 10 10 4"/><polyline points="14 4 14 10 20 10"/>
<polyline points="10 20 10 14 4 14"/><polyline points="20 14 14 14 14 20"/> <polyline points="10 20 10 14 4 14"/><polyline points="20 14 14 14 14 20"/>
@ -26,7 +32,7 @@
</div> </div>
<slot></slot> <slot></slot>
<!-- 调整大小的控制点 --> <!-- 调整大小的控制点 -->
<template v-if="!isFullscreen"> <template v-if="!effectiveFullscreen">
<div class="ai-assistant-resize-handle ai-assistant-resize-n" @mousedown.stop.prevent="onResizeMouseDown($event, 'n')"></div> <div class="ai-assistant-resize-handle ai-assistant-resize-n" @mousedown.stop.prevent="onResizeMouseDown($event, 'n')"></div>
<div class="ai-assistant-resize-handle ai-assistant-resize-s" @mousedown.stop.prevent="onResizeMouseDown($event, 's')"></div> <div class="ai-assistant-resize-handle ai-assistant-resize-s" @mousedown.stop.prevent="onResizeMouseDown($event, 's')"></div>
<div class="ai-assistant-resize-handle ai-assistant-resize-e" @mousedown.stop.prevent="onResizeMouseDown($event, 'e')"></div> <div class="ai-assistant-resize-handle ai-assistant-resize-e" @mousedown.stop.prevent="onResizeMouseDown($event, 'e')"></div>
@ -43,9 +49,10 @@
v-else v-else
v-model="visible" v-model="visible"
:width="shouldCreateNewSession ? '440px' : '600px'" :width="shouldCreateNewSession ? '440px' : '600px'"
:fullscreen="isMobile"
:mask-closable="false" :mask-closable="false"
:footer-hide="true" :footer-hide="true"
class-name="ai-assistant-modal"> :class-name="isMobile ? 'ai-assistant-modal is-mobile-fullscreen' : 'ai-assistant-modal'">
<template #header> <template #header>
<slot name="header"></slot> <slot name="header"></slot>
</template> </template>
@ -139,6 +146,14 @@ export default {
return this.windowHeight; return this.windowHeight;
}, },
isMobile() {
return this.windowWidth < 576;
},
effectiveFullscreen() {
return this.isFullscreen || this.isMobile;
},
// left // left
left() { left() {
if (this.position.fromRight) { if (this.position.fromRight) {
@ -163,7 +178,7 @@ export default {
}; };
} }
// //
if (this.isFullscreen) { if (this.effectiveFullscreen) {
return { return {
zIndex: this.zIndex, zIndex: this.zIndex,
}; };
@ -284,7 +299,7 @@ export default {
*/ */
onDragMouseDown(e) { onDragMouseDown(e) {
// //
if (e.button !== 0 || this.isFullscreen) return; if (e.button !== 0 || this.effectiveFullscreen) return;
this.updateWindowSize(); this.updateWindowSize();
this.record = { this.record = {
@ -302,6 +317,7 @@ export default {
* 切换全屏 * 切换全屏
*/ */
toggleFullscreen() { toggleFullscreen() {
if (this.isMobile) return;
this.isFullscreen = !this.isFullscreen; this.isFullscreen = !this.isFullscreen;
}, },

View File

@ -1,409 +1,167 @@
/** /**
* AI 助手页面上下文配置 * AI 助手页面上下文弱提示词工厂
* *
* 设计原则 * 只输出"页面类型 / 实体 id / 实体名称 / 对话类型"四类核心字段
* - 提供当前页面/场景的上下文数据 * 详细数据描述统计成员等 AI 通过工具MCP自取
* - 传递实体 ID 和关键信息 AI 能调用 MCP 工具或理解场景 *
* - 不限定 AI 的能力范围 * - buildWeakPrompt(store, routeParams) {contextKey, pageLabel, entity} | null
* - renderWeakPromptText(weak, switching) "[当前页面|页面切换] 页面类型(实体_id=xx,名称:xx)"
*/ */
/** /**
* 获取当前页面的 AI 上下文 * 根据当前页面 / 弹窗构建弱提示词数据
* @param {Object} store - Vuex store 实例 * @returns {{contextKey: string, pageLabel: string, entity: Object|null}|null}
* @param {Object} routeParams - 路由参数
* @returns {Object} { systemPrompt }
*/ */
export function getPageContext(store, routeParams = {}) { export function buildWeakPrompt(store, routeParams = {}) {
// 优先检测弹窗场景 // 弹窗优先(任务详情 / 对话详情)
const taskId = store.state.taskId; const taskId = store.state.taskId;
if (taskId > 0) { if (taskId > 0) {
return getSingleTaskContext(store, { taskId }); return buildTaskWeak(store, taskId);
} }
const dialogModalShow = store.state.dialogModalShow; const dialogModalShow = store.state.dialogModalShow;
const dialogId = store.state.dialogId; const dialogId = store.state.dialogId;
if (dialogModalShow && dialogId > 0) { if (dialogModalShow && dialogId > 0) {
return getSingleDialogContext(store, { dialogId }); return buildDialogWeak(store, dialogId);
} }
const routeName = store.state.routeName; const routeName = store.state.routeName;
const contextMap = {
// 主要管理页面
'manage-dashboard': getDashboardContext,
'manage-project': getProjectContext,
'manage-messenger': getMessengerContext,
'manage-calendar': getCalendarContext,
'manage-file': getFileContext,
// 独立页面
'single-task': getSingleTaskContext,
'single-task-content': getSingleTaskContext,
'single-dialog': getSingleDialogContext,
'single-file': getSingleFileContext,
'single-file-task': getSingleFileTaskContext,
'single-report-edit': getSingleReportEditContext,
'single-report-detail': getSingleReportDetailContext,
};
const getContext = contextMap[routeName];
if (getContext) {
return getContext(store, routeParams);
}
return getDefaultContext();
}
/**
* 仪表盘上下文
*/
function getDashboardContext(store) {
const dashboardTask = store.getters.dashboardTask || {};
const assistTask = store.getters.assistTask || [];
const overdueCount = dashboardTask.overdue_count || 0;
const todayCount = dashboardTask.today_count || 0;
const todoCount = dashboardTask.todo_count || 0;
const assistCount = assistTask.length || 0;
const lines = ['用户正在查看工作仪表盘。'];
if (overdueCount > 0 || todayCount > 0 || todoCount > 0 || assistCount > 0) {
lines.push('', '任务概况:');
if (overdueCount > 0) lines.push(`- 逾期任务:${overdueCount}`);
if (todayCount > 0) lines.push(`- 今日到期:${todayCount}`);
if (todoCount > 0) lines.push(`- 待办任务:${todoCount}`);
if (assistCount > 0) lines.push(`- 协助任务:${assistCount}`);
}
return {
systemPrompt: lines.join('\n'),
};
}
/**
* 项目详情上下文
*/
function getProjectContext(store) {
const project = store.getters.projectData || {};
const columns = store.state.cacheColumns || [];
const tasks = store.state.cacheTasks || [];
if (!project.id) {
return {
systemPrompt: '用户正在查看项目列表。',
};
}
const lines = [
'用户正在查看项目详情页面。',
'',
'当前项目:',
`- project_id${project.id}`,
];
if (project.name) {
lines.push(`- 名称:${project.name}`);
}
if (project.desc) {
const desc = project.desc.length > 200 ? project.desc.substring(0, 200) + '...' : project.desc;
lines.push(`- 描述:${desc}`);
}
// 任务统计
const projectTasks = tasks.filter(t => t.project_id === project.id);
if (projectTasks.length > 0) {
const completedCount = projectTasks.filter(t => t.complete_at).length;
const overdueCount = projectTasks.filter(t => !t.complete_at && t.end_at && new Date(t.end_at) < new Date()).length;
lines.push('', '任务统计:');
lines.push(`- 总任务:${projectTasks.length}`);
lines.push(`- 已完成:${completedCount}`);
if (overdueCount > 0) {
lines.push(`- 已逾期:${overdueCount}`);
}
}
// 看板列
const projectColumns = columns.filter(c => c.project_id === project.id);
if (projectColumns.length > 0) {
const columnNames = projectColumns.map(c => c.name).join('、');
lines.push('', `看板列:${columnNames}`);
}
return {
systemPrompt: lines.join('\n'),
};
}
/**
* 消息对话上下文
*/
function getMessengerContext(store) {
const dialogId = store.state.dialogId;
const dialogs = store.state.cacheDialogs || [];
const dialog = dialogs.find(d => d.id === dialogId);
if (!dialog) {
return {
systemPrompt: '用户正在查看消息列表。',
};
}
const dialogType = dialog.type === 'group' ? '群聊' : '私聊';
const lines = [
'用户正在使用消息功能。',
'',
'当前对话:',
`- dialog_id${dialog.id}`,
`- 类型:${dialogType}`,
];
if (dialog.name) {
lines.push(`- 名称:${dialog.name}`);
}
return {
systemPrompt: lines.join('\n'),
};
}
/**
* 日历上下文
*/
function getCalendarContext() {
return {
systemPrompt: '用户正在查看日历。',
};
}
/**
* 文件管理上下文
*/
function getFileContext() {
return {
systemPrompt: '用户正在查看文件管理页面。',
};
}
/**
* 单任务页面上下文
*/
function getSingleTaskContext(store, routeParams) {
const taskId = routeParams.taskId;
if (!taskId) {
return {
systemPrompt: '用户正在查看任务页面。',
};
}
return {
systemPrompt: [
'用户正在查看任务详情页面。',
'',
'当前任务:',
`- task_id${taskId}`,
].join('\n'),
};
}
/**
* 单对话页面上下文
*/
function getSingleDialogContext(store, routeParams) {
const dialogId = routeParams.dialogId;
if (!dialogId) {
return {
systemPrompt: '用户正在查看对话页面。',
};
}
return {
systemPrompt: [
'用户正在查看对话窗口。',
'',
'当前对话:',
`- dialog_id${dialogId}`,
].join('\n'),
};
}
/**
* 单文件页面上下文
*/
function getSingleFileContext(store, routeParams) {
const fileId = routeParams.codeOrFileId;
if (!fileId) {
return {
systemPrompt: '用户正在查看文件页面。',
};
}
return {
systemPrompt: [
'用户正在查看文件。',
'',
'当前文件:',
`- file_id${fileId}`,
].join('\n'),
};
}
/**
* 任务附件文件页面上下文
*/
function getSingleFileTaskContext(store, routeParams) {
const fileId = routeParams.fileId;
if (!fileId) {
return {
systemPrompt: '用户正在查看文件页面。',
};
}
return {
systemPrompt: [
'用户正在查看任务附件。',
'',
'当前文件:',
`- file_id${fileId}`,
].join('\n'),
};
}
/**
* 工作汇报编辑页面上下文
*/
function getSingleReportEditContext(store, routeParams) {
const reportId = routeParams.reportEditId;
if (!reportId) {
return {
systemPrompt: '用户正在编辑工作汇报。',
};
}
return {
systemPrompt: [
'用户正在编辑工作汇报。',
'',
'当前汇报:',
`- report_id${reportId}`,
].join('\n'),
};
}
/**
* 工作汇报详情页面上下文
*/
function getSingleReportDetailContext(store, routeParams) {
const reportId = routeParams.reportDetailId;
if (!reportId) {
return {
systemPrompt: '用户正在查看工作汇报。',
};
}
return {
systemPrompt: [
'用户正在查看工作汇报。',
'',
'当前汇报:',
`- report_id${reportId}`,
].join('\n'),
};
}
/**
* 默认上下文
*/
function getDefaultContext() {
return {
systemPrompt: '',
};
}
/**
* 获取当前场景的唯一标识
* 用于判断打开 AI 助手时是否需要新建会话
* 场景相同则恢复上次会话场景不同则新建会话
*
* @param {Object} store - Vuex store 实例
* @param {Object} routeParams - 路由参数
* @returns {string} 场景标识格式如 "routeName/entityType:entityId"
*/
export function getSceneKey(store, routeParams = {}) {
// 优先检测弹窗场景
const taskId = store.state.taskId;
if (taskId > 0) {
return `modal-task/task:${taskId}`;
}
const dialogModalShow = store.state.dialogModalShow;
const dialogId = store.state.dialogId;
if (dialogModalShow && dialogId > 0) {
return `modal-dialog/dialog:${dialogId}`;
}
const routeName = store.state.routeName;
const parts = [routeName || 'unknown'];
switch (routeName) { switch (routeName) {
case 'manage-project': { case 'manage-dashboard':
const project = store.getters.projectData; return weak('dashboard', '工作仪表盘');
if (project?.id) { case 'manage-project':
parts.push(`project:${project.id}`); return buildProjectWeak(store);
} case 'manage-messenger':
break; return buildMessengerWeak(store);
} case 'manage-calendar':
case 'manage-messenger': { return weak('calendar', '日历页');
const dialogId = store.state.dialogId; case 'manage-file':
if (dialogId) { return weak('file-list', '文件列表页');
parts.push(`dialog:${dialogId}`);
}
break;
}
case 'single-task': case 'single-task':
case 'single-task-content': { case 'single-task-content':
if (routeParams.taskId) { return buildTaskWeak(store, routeParams.taskId);
parts.push(`task:${routeParams.taskId}`); case 'single-dialog':
} return buildDialogWeak(store, routeParams.dialogId);
break; case 'single-file':
return buildFileWeak(routeParams.codeOrFileId);
case 'single-file-task':
return buildFileWeak(routeParams.fileId);
case 'single-report-edit':
return buildReportWeak(routeParams.reportEditId, '工作汇报编辑');
case 'single-report-detail':
return buildReportWeak(routeParams.reportDetailId, '工作汇报详情');
default:
return null;
}
}
/**
* 渲染弱提示词文本
* @param {Object|null} weak - buildWeakPrompt 返回值
* @param {boolean} switching - true=[页面切换] / false=[当前页面]
* @returns {string}
*/
export function renderWeakPromptText(weak, switching) {
if (!weak) {
return '';
}
const prefix = switching ? '[页面切换]' : '[当前页面]';
const segments = [];
const entity = weak.entity;
if (entity) {
if (entity.id !== undefined && entity.id !== null && entity.id !== '') {
segments.push(`${entity.type}_id=${entity.id}`);
} }
case 'single-dialog': { if (entity.name) {
if (routeParams.dialogId) { segments.push(`名称:${entity.name}`);
parts.push(`dialog:${routeParams.dialogId}`);
}
break;
} }
case 'single-file': { if (entity.dialogType) {
if (routeParams.codeOrFileId) { segments.push(`对话类型:${entity.dialogType}`);
parts.push(`file:${routeParams.codeOrFileId}`);
}
break;
}
case 'single-file-task': {
if (routeParams.fileId) {
parts.push(`file:${routeParams.fileId}`);
}
break;
}
case 'single-report-edit': {
if (routeParams.reportEditId) {
parts.push(`report:${routeParams.reportEditId}`);
}
break;
}
case 'single-report-detail': {
if (routeParams.reportDetailId) {
parts.push(`report:${routeParams.reportDetailId}`);
}
break;
} }
} }
const detail = segments.length ? `(${segments.join(',')})` : '';
return parts.join('/'); return `${prefix} ${weak.pageLabel}${detail}`;
}
// ===== 内部构造器 =====
function weak(contextKey, pageLabel, entity = null) {
return {contextKey, pageLabel, entity};
}
function buildTaskWeak(store, taskId) {
const id = Number(taskId);
if (!id) {
return weak('task', '任务详情页');
}
const task = (store.state.cacheTasks || []).find(t => t.id === id);
return weak(`task:${id}`, '任务详情页', {
type: 'task',
id,
name: task?.name || '',
});
}
function buildDialogWeak(store, dialogId) {
const id = Number(dialogId);
if (!id) {
return weak('dialog', '对话页');
}
const dialog = (store.state.cacheDialogs || []).find(d => d.id === id);
return weak(`dialog:${id}`, '对话页', {
type: 'dialog',
id,
name: dialog?.name || '',
dialogType: mapDialogType(dialog?.type),
});
}
function buildProjectWeak(store) {
const project = store.getters.projectData;
if (!project?.id) {
return weak('project-list', '项目列表页');
}
return weak(`project:${project.id}`, '项目详情页', {
type: 'project',
id: project.id,
name: project.name || '',
});
}
function buildMessengerWeak(store) {
const dialogId = store.state.dialogId;
if (!dialogId) {
return weak('messenger', '消息列表页');
}
return buildDialogWeak(store, dialogId);
}
function buildFileWeak(fileId) {
if (!fileId) {
return weak('file', '文件页');
}
return weak(`file:${fileId}`, '文件页', {
type: 'file',
id: fileId,
name: '',
});
}
function buildReportWeak(reportId, label) {
if (!reportId) {
return weak('report', label);
}
return weak(`report:${reportId}`, label, {
type: 'report',
id: reportId,
name: '',
});
}
function mapDialogType(type) {
if (!type) {
return '';
}
if (type === 'group') return '群聊';
if (type === 'user') return '私聊';
return String(type);
} }

View File

@ -140,6 +140,16 @@ const SEARCH_AI_SYSTEM_PROMPT = `你是一个智能搜索助手,帮助用户
3. 以清晰的格式呈现给用户 3. 以清晰的格式呈现给用户
4. 如有需要可以进行多次搜索以获取更全面的结果`; 4. 如有需要可以进行多次搜索以获取更全面的结果`;
/**
* 浮窗 AI 助手默认系统提示词基础人设
* 始终位于上下文最前面配合后续按需注入的"页面弱提示词"使用
*/
const BASE_ASSISTANT_SYSTEM_PROMPT = `你是 DooTask任务 / 项目 / 消息 / 日历 / 文件 / 汇报)的 AI 助手。
- 回答简洁可执行
- 对话中以 [当前页面] / [页面切换] 开头的 system 消息标记用户当前场景涉及具体任务项目成员统计或最新状态时调用工具获取不要根据页面标题或历史对话臆测细节
- 不确定就直说不知道`;
/** /**
* 系统条件性提示块占位符 * 系统条件性提示块占位符
* 后端会将此占位符替换为用户上下文 + 资源格式指南 * 后端会将此占位符替换为用户上下文 + 资源格式指南
@ -228,6 +238,7 @@ export {
REPORT_AI_SYSTEM_PROMPT, REPORT_AI_SYSTEM_PROMPT,
REPORT_ANALYSIS_SYSTEM_PROMPT, REPORT_ANALYSIS_SYSTEM_PROMPT,
SEARCH_AI_SYSTEM_PROMPT, SEARCH_AI_SYSTEM_PROMPT,
BASE_ASSISTANT_SYSTEM_PROMPT,
withLanguagePreferencePrompt, withLanguagePreferencePrompt,
AIModelNames, AIModelNames,
AINormalizeJsonContent, AINormalizeJsonContent,

View File

@ -827,4 +827,8 @@ body.dark-mode-reverse {
} }
} }
} }
.ai-assistant-chat-mask {
background-color: rgba(230, 230, 230, 0.6);
}
} }