mirror of
https://github.com/kuaifan/dootask.git
synced 2026-06-11 18:02:22 +00:00
feat(ai-assistant): AI 浮窗/浮按钮移动端适配与流式不中断
This commit is contained in:
parent
20c3fa91fb
commit
f6067d1bd5
@ -12,7 +12,8 @@
|
||||
class="ai-float-button"
|
||||
:class="btnClass"
|
||||
: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">
|
||||
<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>
|
||||
import {mapState} from "vuex";
|
||||
import emitter from "../../store/events";
|
||||
import {withLanguagePreferencePrompt} from "../../utils/ai";
|
||||
import {getPageContext, getSceneKey} from "./page-context";
|
||||
import {createOperationModule} from "./operation-module";
|
||||
|
||||
export default {
|
||||
@ -48,8 +47,9 @@ export default {
|
||||
btnSize: 44,
|
||||
collapsedHeight: 48, // 收起时的高度
|
||||
collapseThreshold: 12, // 触发收起的边缘距离阈值
|
||||
collapseDelay: 1000, // 收起延迟(毫秒)
|
||||
collapseTimer: null, // 收起定时器
|
||||
collapseDelayDesktop: 1000, // 桌面端收起延迟(毫秒)
|
||||
collapseDelayMobile: 5000, // 移动端收起延迟(毫秒)
|
||||
collapseTimer: null, // 收起定时器
|
||||
record: {},
|
||||
// 前端操作模块
|
||||
operationModule: null,
|
||||
@ -68,7 +68,6 @@ export default {
|
||||
return this.aiInstalled &&
|
||||
this.userId > 0 &&
|
||||
this.positionLoaded &&
|
||||
!this.windowPortrait &&
|
||||
this.routeName !== 'login' &&
|
||||
!this.$parent?.showModal;
|
||||
},
|
||||
@ -163,6 +162,9 @@ export default {
|
||||
document.removeEventListener('mousemove', this.onMouseMove);
|
||||
document.removeEventListener('mouseup', this.onMouseUp);
|
||||
document.removeEventListener('contextmenu', this.onContextMenu);
|
||||
document.removeEventListener('touchmove', this.onTouchMove);
|
||||
document.removeEventListener('touchend', this.onTouchEnd);
|
||||
document.removeEventListener('touchcancel', this.onTouchEnd);
|
||||
this.clearCollapseTimer();
|
||||
this.destroyOperationModule();
|
||||
},
|
||||
@ -182,6 +184,7 @@ export default {
|
||||
this.$nextTick(() => {
|
||||
this.checkBounds();
|
||||
this.positionLoaded = true;
|
||||
this.scheduleCollapse();
|
||||
});
|
||||
return;
|
||||
}
|
||||
@ -189,9 +192,12 @@ export default {
|
||||
} catch (e) {
|
||||
// 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.scheduleCollapse();
|
||||
},
|
||||
|
||||
/**
|
||||
@ -226,6 +232,8 @@ export default {
|
||||
// 只响应鼠标左键
|
||||
if (e.button !== 0) return;
|
||||
|
||||
this.clearCollapseTimer();
|
||||
|
||||
// 收起状态下点击直接打开 AI 助手
|
||||
if (this.collapsed) {
|
||||
this.onClick();
|
||||
@ -298,6 +306,96 @@ export default {
|
||||
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() {
|
||||
this.clearCollapseTimer();
|
||||
// 检测是否靠近边缘
|
||||
if (this.position.x <= this.collapseThreshold) {
|
||||
// 移动端松手即视为贴边,跳过 collapseThreshold 检查
|
||||
if (this.windowPortrait || this.position.x <= this.collapseThreshold) {
|
||||
const delay = this.windowPortrait ? this.collapseDelayMobile : this.collapseDelayDesktop;
|
||||
this.collapseTimer = setTimeout(() => {
|
||||
this.position.collapsed = true;
|
||||
this.savePosition();
|
||||
}, this.collapseDelay);
|
||||
}, delay);
|
||||
}
|
||||
},
|
||||
|
||||
@ -369,19 +468,20 @@ export default {
|
||||
* 点击按钮
|
||||
*/
|
||||
onClick() {
|
||||
const routeParams = this.$route?.params || {};
|
||||
const sceneKey = getSceneKey(this.$store, routeParams);
|
||||
|
||||
// 启用前端操作模块
|
||||
this.enableOperationModule();
|
||||
|
||||
emitter.emit('openAIAssistant', {
|
||||
displayMode: 'chat',
|
||||
sessionKey: 'global',
|
||||
sceneKey,
|
||||
resumeSession: 86400,
|
||||
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();
|
||||
},
|
||||
|
||||
/**
|
||||
* 处理发送前的上下文准备
|
||||
* 在发送时动态获取当前页面上下文,确保上下文与用户当前所在页面一致
|
||||
* @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;
|
||||
$collapsed-width: 12px;
|
||||
$collapsed-height: 48px;
|
||||
$snap-dur: 0.8s;
|
||||
$snap-ease: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
|
||||
.ai-float-button-wrapper {
|
||||
position: fixed;
|
||||
display: flex;
|
||||
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 向左移动
|
||||
&.is-right {
|
||||
@ -493,8 +571,9 @@ $collapsed-height: 48px;
|
||||
}
|
||||
|
||||
&.is-dragging {
|
||||
transition: none;
|
||||
.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;
|
||||
align-items: 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;
|
||||
flex-shrink: 0;
|
||||
|
||||
|
||||
@ -62,11 +62,11 @@
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="responses.length"
|
||||
v-if="visibleResponses.length"
|
||||
ref="responseContainer"
|
||||
class="ai-assistant-output">
|
||||
<div
|
||||
v-for="response in responses"
|
||||
v-for="response in visibleResponses"
|
||||
:key="response.localId"
|
||||
class="ai-assistant-output-item">
|
||||
<div class="ai-assistant-output-apply">
|
||||
@ -235,11 +235,12 @@ import Vue from "vue";
|
||||
import {debounce} from "lodash";
|
||||
import emitter from "../../store/events";
|
||||
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 FloatButton from "./float-button.vue";
|
||||
import AssistantModal from "./modal.vue";
|
||||
import PromptImage from "./prompt-image.vue";
|
||||
import {buildWeakPrompt, renderWeakPromptText} from "./page-context";
|
||||
import {getWelcomePrompts} from "./welcome-prompts";
|
||||
|
||||
export default {
|
||||
@ -272,6 +273,8 @@ export default {
|
||||
applyHook: null,
|
||||
beforeSendHook: null,
|
||||
renderHook: null,
|
||||
// 运行时附加 system 内容(每次发送时调用,返回 string 则追加;不入库)
|
||||
dynamicSystemHook: null,
|
||||
|
||||
// 模型选择
|
||||
inputModel: '',
|
||||
@ -297,7 +300,6 @@ export default {
|
||||
sessionStore: [],
|
||||
currentSessionKey: 'default',
|
||||
currentSessionId: null,
|
||||
currentSceneKey: null,
|
||||
sessionCacheKeyPrefix: 'aiAssistant.sessions',
|
||||
maxSessionsPerKey: 20,
|
||||
sessionStoreLoaded: false,
|
||||
@ -336,9 +338,7 @@ export default {
|
||||
this.refreshWelcomePromptsDebounced = debounce(() => {
|
||||
this.displayWelcomePrompts = getWelcomePrompts(this.$store, this.$route?.params || {});
|
||||
}, 100);
|
||||
this.saveSessionStoreDebounced = debounce(() => {
|
||||
this.saveSessionStore();
|
||||
}, 2000);
|
||||
this.saveSessionStoreDebouncedMap = {};
|
||||
},
|
||||
mounted() {
|
||||
emitter.on('openAIAssistant', this.onOpenAIAssistant);
|
||||
@ -359,8 +359,12 @@ export default {
|
||||
selectedModelOption({modelMap, inputModel}) {
|
||||
return modelMap[inputModel] || null;
|
||||
},
|
||||
// 渲染时跳过 role==='system' 的弱提示词条目
|
||||
visibleResponses() {
|
||||
return this.responses.filter(r => r.role !== 'system');
|
||||
},
|
||||
shouldCreateNewSession() {
|
||||
return this.responses.length === 0;
|
||||
return this.visibleResponses.length === 0;
|
||||
},
|
||||
currentSessionList() {
|
||||
return this.sessionStore || [];
|
||||
@ -479,6 +483,7 @@ export default {
|
||||
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;
|
||||
@ -487,12 +492,11 @@ export default {
|
||||
this.renderHook = params.onRender || null;
|
||||
this.pendingAutoSubmit = !!params.autoSubmit;
|
||||
|
||||
// 会话管理
|
||||
await this.initSession(params.sessionKey, params.sceneKey, params.resumeSession);
|
||||
// 会话管理(不再按场景分桶,统一对话池)
|
||||
await this.initSession(params.sessionKey);
|
||||
|
||||
this.showModal = true;
|
||||
this.fetchModelOptions();
|
||||
this.clearActiveSSEClients();
|
||||
this.clearAutoSubmitTimer();
|
||||
this.$nextTick(() => {
|
||||
this.scheduleAutoSubmit();
|
||||
@ -711,6 +715,11 @@ export default {
|
||||
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);
|
||||
|
||||
@ -803,6 +812,9 @@ export default {
|
||||
|
||||
/**
|
||||
* 汇总当前会话的基础上下文
|
||||
* 浮窗普通对话:context[0] 永远是默认系统提示词;之后可选地追加一条
|
||||
* 动态附加 system(operationSessionId 等运行时信息,不入库);再后是历史
|
||||
* 消息(其中 role==='system' 的弱提示词原样穿插);末尾是本次用户消息。
|
||||
*/
|
||||
async collectBaseContext(prompt) {
|
||||
const pushEntry = (context, role, value) => {
|
||||
@ -822,12 +834,35 @@ export default {
|
||||
}
|
||||
};
|
||||
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);
|
||||
@ -844,6 +879,58 @@ export default {
|
||||
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) {
|
||||
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}`));
|
||||
this.registerSSEClient(sse);
|
||||
this.registerSSEClient(sse, owner);
|
||||
sse.subscribe(['append', 'replace', 'done'], (type, event) => {
|
||||
switch (type) {
|
||||
case 'append':
|
||||
case 'replace':
|
||||
this.handleStreamChunk(responseEntry, type, event);
|
||||
this.handleStreamChunk(owner, type, event);
|
||||
break;
|
||||
case 'done':
|
||||
// 检查 done 事件是否携带错误信息
|
||||
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();
|
||||
this.handleStreamDone(owner, sse, event);
|
||||
break;
|
||||
}
|
||||
}, () => {
|
||||
// SSE 连接失败(重试次数用完)时的回调
|
||||
if (responseEntry && ['streaming', 'waiting'].includes(responseEntry.status)) {
|
||||
this.markResponseError(responseEntry, this.$L('连接失败,请重试'));
|
||||
}
|
||||
this.releaseSSEClient(sse);
|
||||
this.saveCurrentSession();
|
||||
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);
|
||||
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 片段
|
||||
*/
|
||||
handleStreamChunk(responseEntry, type, event) {
|
||||
if (!responseEntry) {
|
||||
handleStreamChunk(owner, type, event) {
|
||||
const target = this.locateStreamTarget(owner);
|
||||
const entry = target.entry;
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
const stickToBottom = this.shouldStickToBottom();
|
||||
const stickToBottom = target.isCurrent && this.shouldStickToBottom();
|
||||
const payload = this.parseStreamPayload(event);
|
||||
const chunk = this.resolveStreamContent(payload);
|
||||
if (type === 'replace') {
|
||||
responseEntry.rawOutput = chunk;
|
||||
entry.rawOutput = chunk;
|
||||
} else {
|
||||
responseEntry.rawOutput += chunk;
|
||||
entry.rawOutput += chunk;
|
||||
}
|
||||
this.updateResponseDisplayOutput(responseEntry);
|
||||
responseEntry.status = 'streaming';
|
||||
if (stickToBottom) {
|
||||
this.updateResponseDisplayOutput(entry);
|
||||
entry.status = 'streaming';
|
||||
if (target.isCurrent && stickToBottom) {
|
||||
this.scrollResponsesToBottom();
|
||||
}
|
||||
},
|
||||
@ -1027,20 +1174,25 @@ export default {
|
||||
},
|
||||
|
||||
/**
|
||||
* 将 SSE 客户端加入活跃列表,方便后续清理
|
||||
* 将 SSE 客户端加入活跃列表,记录归属以便定向清理
|
||||
*/
|
||||
registerSSEClient(sse) {
|
||||
registerSSEClient(sse, owner = {}) {
|
||||
if (!sse) {
|
||||
return;
|
||||
}
|
||||
this.activeSSEClients.push(sse);
|
||||
this.activeSSEClients.push({
|
||||
sse,
|
||||
sessionKey: owner.sessionKey,
|
||||
sessionId: owner.sessionId,
|
||||
localId: owner.localId,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 从活跃列表移除 SSE 客户端并执行注销
|
||||
*/
|
||||
releaseSSEClient(sse) {
|
||||
const index = this.activeSSEClients.indexOf(sse);
|
||||
const index = this.activeSSEClients.findIndex(item => item.sse === sse);
|
||||
if (index > -1) {
|
||||
this.activeSSEClients.splice(index, 1);
|
||||
}
|
||||
@ -1051,15 +1203,47 @@ export default {
|
||||
* 关闭所有活跃的 SSE 连接
|
||||
*/
|
||||
clearActiveSSEClients() {
|
||||
this.activeSSEClients.forEach(sse => {
|
||||
this.activeSSEClients.forEach(item => {
|
||||
try {
|
||||
sse.unsunscribe();
|
||||
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);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 清除自动提交定时器
|
||||
*/
|
||||
@ -1238,11 +1422,9 @@ export default {
|
||||
this.closing = true;
|
||||
this.pendingAutoSubmit = false;
|
||||
this.clearAutoSubmitTimer();
|
||||
this.clearActiveSSEClients();
|
||||
this.resetInputHistoryNavigation();
|
||||
this.clearPendingImages();
|
||||
this.showModal = false;
|
||||
this.responses = [];
|
||||
setTimeout(() => {
|
||||
this.closing = false;
|
||||
}, 300);
|
||||
@ -1279,22 +1461,30 @@ export default {
|
||||
// ==================== 会话管理方法 ====================
|
||||
|
||||
/**
|
||||
* 加载指定场景的会话数据
|
||||
* 加载会话列表(按 sessionKey 桶过滤;浮窗固定 'global' 桶,专用入口用各自的桶)
|
||||
*/
|
||||
async loadSessionStore(sessionKey) {
|
||||
try {
|
||||
const {data} = await this.$store.dispatch("call", {
|
||||
url: 'assistant/session/list',
|
||||
data: {session_key: sessionKey},
|
||||
data: {session_key: sessionKey || this.currentSessionKey},
|
||||
});
|
||||
if (Array.isArray(data)) {
|
||||
this.sessionStore = data;
|
||||
// 缓存服务端返回的图片URL映射
|
||||
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) {
|
||||
Object.assign(this.serverImageMap, session.images);
|
||||
}
|
||||
});
|
||||
this.sessionStore = data;
|
||||
} else {
|
||||
this.sessionStore = [];
|
||||
}
|
||||
@ -1305,12 +1495,24 @@ export default {
|
||||
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() {
|
||||
if (!this.currentSessionId) return;
|
||||
const session = this.sessionStore.find(s => s.id === this.currentSessionId);
|
||||
async saveSessionStore(sessionId = this.currentSessionId) {
|
||||
if (!sessionId) return;
|
||||
const session = this.sessionStore.find(s => s.id === sessionId);
|
||||
if (!session) return;
|
||||
|
||||
// 收集本次需要上传的新图片(在 imageCache 中有 base64 但 serverImageMap 中没有的)
|
||||
@ -1332,9 +1534,8 @@ export default {
|
||||
data: {
|
||||
session_key: this.currentSessionKey,
|
||||
session_id: session.id,
|
||||
scene_key: session.sceneKey || '',
|
||||
title: session.title || '',
|
||||
data: session.responses || [],
|
||||
data: this.sanitizeResponsesForPersist(session.responses),
|
||||
new_images: newImages,
|
||||
},
|
||||
});
|
||||
@ -1362,7 +1563,7 @@ export default {
|
||||
if (!responses || responses.length === 0) {
|
||||
return this.$L('新会话');
|
||||
}
|
||||
const firstPrompt = responses.find(r => r.prompt)?.prompt || '';
|
||||
const firstPrompt = responses.find(r => r.role !== 'system' && r.prompt)?.prompt || '';
|
||||
if (!firstPrompt) {
|
||||
return this.$L('新会话');
|
||||
}
|
||||
@ -1385,52 +1586,43 @@ export default {
|
||||
|
||||
/**
|
||||
* 初始化会话
|
||||
* @param {string} sessionKey - 会话场景标识,不传则不启用会话管理
|
||||
* @param {string} sceneKey - 场景标识,用于判断是否恢复会话
|
||||
* @param {number} resumeTimeout - 恢复超时时间(秒),默认1天
|
||||
* sessionKey 传值即启用会话管理(浮窗用 'global',专用入口用各自 key,如 'chat-message')
|
||||
* 切换 sessionKey 时切换桶并保存当前会话
|
||||
* 不再按 scene_key 自动恢复:同一桶内打开时保留当前 session_id,没有则建新会话
|
||||
*/
|
||||
async initSession(sessionKey, sceneKey = null, resumeTimeout = 86400) {
|
||||
// 保存当前会话
|
||||
if (this.responses.length > 0) {
|
||||
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 = !!sessionKey;
|
||||
this.currentSceneKey = sceneKey;
|
||||
this.sessionEnabled = true;
|
||||
|
||||
if (this.sessionEnabled) {
|
||||
// 如果切换到不同的场景,需要加载新场景的数据
|
||||
if (this.currentSessionKey !== sessionKey || !this.sessionStoreLoaded) {
|
||||
this.currentSessionKey = sessionKey;
|
||||
await this.loadSessionStore(sessionKey);
|
||||
if (switchingBucket || this.currentSessionKey !== sessionKey || !this.sessionStoreLoaded) {
|
||||
this.currentSessionKey = sessionKey;
|
||||
await this.loadSessionStore(sessionKey);
|
||||
// 切桶后清空当前会话指针,下面再决定新建/恢复
|
||||
if (switchingBucket) {
|
||||
this.currentSessionId = null;
|
||||
this.responses = [];
|
||||
}
|
||||
}
|
||||
|
||||
// 如果传入了 sceneKey,从历史中查找相同场景的最新会话
|
||||
if (sceneKey) {
|
||||
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,创建新会话
|
||||
// 已有当前会话则保留;否则建一个空的新会话
|
||||
if (!this.currentSessionId) {
|
||||
this.currentSessionId = this.generateSessionId();
|
||||
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,
|
||||
title: this.generateSessionTitle(this.responses),
|
||||
responses: JSON.parse(JSON.stringify(this.responses)),
|
||||
sceneKey: this.currentSceneKey,
|
||||
createdAt: existingIndex > -1 ? this.sessionStore[existingIndex].createdAt : Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
@ -1481,7 +1672,22 @@ export default {
|
||||
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.currentSessionId = session.id;
|
||||
this.currentSceneKey = session.sceneKey || null;
|
||||
this.responses = JSON.parse(JSON.stringify(session.responses));
|
||||
this.syncResponseSeed();
|
||||
this.scrollResponsesToBottom();
|
||||
@ -1547,7 +1752,7 @@ export default {
|
||||
clearSessionHistory() {
|
||||
$A.modalConfirm({
|
||||
title: this.$L('清空历史会话'),
|
||||
content: this.$L('确定要清空当前场景的所有历史会话吗?'),
|
||||
content: this.$L('确定要清空所有历史会话吗?'),
|
||||
onOk: () => {
|
||||
this.serverImageMap = {};
|
||||
this.imageCache = {};
|
||||
@ -2052,6 +2257,7 @@ export default {
|
||||
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) {
|
||||
@ -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 {
|
||||
position: fixed;
|
||||
width: 460px;
|
||||
@ -2916,6 +3131,24 @@ export 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 {
|
||||
@ -2935,6 +3168,31 @@ export default {
|
||||
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 {
|
||||
|
||||
@ -1,13 +1,19 @@
|
||||
<template>
|
||||
<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">
|
||||
<div
|
||||
v-if="visible"
|
||||
ref="chatWindow"
|
||||
class="ai-assistant-chat"
|
||||
:class="{'is-fullscreen': isFullscreen}"
|
||||
:class="{'is-fullscreen': effectiveFullscreen, 'is-mobile-fullscreen': isMobile}"
|
||||
: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">
|
||||
<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"/>
|
||||
@ -26,7 +32,7 @@
|
||||
</div>
|
||||
<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-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>
|
||||
@ -43,9 +49,10 @@
|
||||
v-else
|
||||
v-model="visible"
|
||||
:width="shouldCreateNewSession ? '440px' : '600px'"
|
||||
:fullscreen="isMobile"
|
||||
:mask-closable="false"
|
||||
:footer-hide="true"
|
||||
class-name="ai-assistant-modal">
|
||||
:class-name="isMobile ? 'ai-assistant-modal is-mobile-fullscreen' : 'ai-assistant-modal'">
|
||||
<template #header>
|
||||
<slot name="header"></slot>
|
||||
</template>
|
||||
@ -139,6 +146,14 @@ export default {
|
||||
return this.windowHeight;
|
||||
},
|
||||
|
||||
isMobile() {
|
||||
return this.windowWidth < 576;
|
||||
},
|
||||
|
||||
effectiveFullscreen() {
|
||||
return this.isFullscreen || this.isMobile;
|
||||
},
|
||||
|
||||
// 计算实际的 left 值
|
||||
left() {
|
||||
if (this.position.fromRight) {
|
||||
@ -163,7 +178,7 @@ export default {
|
||||
};
|
||||
}
|
||||
// 全屏时不应用自定义尺寸和位置
|
||||
if (this.isFullscreen) {
|
||||
if (this.effectiveFullscreen) {
|
||||
return {
|
||||
zIndex: this.zIndex,
|
||||
};
|
||||
@ -284,7 +299,7 @@ export default {
|
||||
*/
|
||||
onDragMouseDown(e) {
|
||||
// 只响应鼠标左键,全屏时禁用拖动
|
||||
if (e.button !== 0 || this.isFullscreen) return;
|
||||
if (e.button !== 0 || this.effectiveFullscreen) return;
|
||||
|
||||
this.updateWindowSize();
|
||||
this.record = {
|
||||
@ -302,6 +317,7 @@ export default {
|
||||
* 切换全屏
|
||||
*/
|
||||
toggleFullscreen() {
|
||||
if (this.isMobile) return;
|
||||
this.isFullscreen = !this.isFullscreen;
|
||||
},
|
||||
|
||||
|
||||
@ -1,409 +1,167 @@
|
||||
/**
|
||||
* AI 助手页面上下文配置
|
||||
* AI 助手页面上下文(弱提示词工厂)
|
||||
*
|
||||
* 设计原则:
|
||||
* - 提供当前页面/场景的上下文数据
|
||||
* - 传递实体 ID 和关键信息(让 AI 能调用 MCP 工具或理解场景)
|
||||
* - 不限定 AI 的能力范围
|
||||
* 只输出"页面类型 / 实体 id / 实体名称 / 对话类型"四类核心字段;
|
||||
* 详细数据(描述、统计、成员等)由 AI 通过工具(MCP)自取。
|
||||
*
|
||||
* - buildWeakPrompt(store, routeParams) → {contextKey, pageLabel, entity} | null
|
||||
* - renderWeakPromptText(weak, switching) → "[当前页面|页面切换] 页面类型(实体_id=xx,名称:xx)"
|
||||
*/
|
||||
|
||||
/**
|
||||
* 获取当前页面的 AI 上下文
|
||||
* @param {Object} store - Vuex store 实例
|
||||
* @param {Object} routeParams - 路由参数
|
||||
* @returns {Object} { systemPrompt }
|
||||
* 根据当前页面 / 弹窗构建弱提示词数据
|
||||
* @returns {{contextKey: string, pageLabel: string, entity: Object|null}|null}
|
||||
*/
|
||||
export function getPageContext(store, routeParams = {}) {
|
||||
// 优先检测弹窗场景
|
||||
export function buildWeakPrompt(store, routeParams = {}) {
|
||||
// 弹窗优先(任务详情 / 对话详情)
|
||||
const taskId = store.state.taskId;
|
||||
if (taskId > 0) {
|
||||
return getSingleTaskContext(store, { taskId });
|
||||
return buildTaskWeak(store, taskId);
|
||||
}
|
||||
|
||||
const dialogModalShow = store.state.dialogModalShow;
|
||||
const dialogId = store.state.dialogId;
|
||||
if (dialogModalShow && dialogId > 0) {
|
||||
return getSingleDialogContext(store, { dialogId });
|
||||
return buildDialogWeak(store, dialogId);
|
||||
}
|
||||
|
||||
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) {
|
||||
case 'manage-project': {
|
||||
const project = store.getters.projectData;
|
||||
if (project?.id) {
|
||||
parts.push(`project:${project.id}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'manage-messenger': {
|
||||
const dialogId = store.state.dialogId;
|
||||
if (dialogId) {
|
||||
parts.push(`dialog:${dialogId}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'manage-dashboard':
|
||||
return weak('dashboard', '工作仪表盘');
|
||||
case 'manage-project':
|
||||
return buildProjectWeak(store);
|
||||
case 'manage-messenger':
|
||||
return buildMessengerWeak(store);
|
||||
case 'manage-calendar':
|
||||
return weak('calendar', '日历页');
|
||||
case 'manage-file':
|
||||
return weak('file-list', '文件列表页');
|
||||
case 'single-task':
|
||||
case 'single-task-content': {
|
||||
if (routeParams.taskId) {
|
||||
parts.push(`task:${routeParams.taskId}`);
|
||||
}
|
||||
break;
|
||||
case 'single-task-content':
|
||||
return buildTaskWeak(store, routeParams.taskId);
|
||||
case 'single-dialog':
|
||||
return buildDialogWeak(store, routeParams.dialogId);
|
||||
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 (routeParams.dialogId) {
|
||||
parts.push(`dialog:${routeParams.dialogId}`);
|
||||
}
|
||||
break;
|
||||
if (entity.name) {
|
||||
segments.push(`名称:${entity.name}`);
|
||||
}
|
||||
case 'single-file': {
|
||||
if (routeParams.codeOrFileId) {
|
||||
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;
|
||||
if (entity.dialogType) {
|
||||
segments.push(`对话类型:${entity.dialogType}`);
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join('/');
|
||||
const detail = segments.length ? `(${segments.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);
|
||||
}
|
||||
|
||||
11
resources/assets/js/utils/ai.js
vendored
11
resources/assets/js/utils/ai.js
vendored
@ -140,6 +140,16 @@ const SEARCH_AI_SYSTEM_PROMPT = `你是一个智能搜索助手,帮助用户
|
||||
3. 以清晰的格式呈现给用户
|
||||
4. 如有需要,可以进行多次搜索以获取更全面的结果`;
|
||||
|
||||
/**
|
||||
* 浮窗 AI 助手默认系统提示词(基础人设)
|
||||
* 始终位于上下文最前面,配合后续按需注入的"页面弱提示词"使用
|
||||
*/
|
||||
const BASE_ASSISTANT_SYSTEM_PROMPT = `你是 DooTask(任务 / 项目 / 消息 / 日历 / 文件 / 汇报)的 AI 助手。
|
||||
|
||||
- 回答简洁、可执行
|
||||
- 对话中以 [当前页面] / [页面切换] 开头的 system 消息标记用户当前场景;涉及具体任务、项目、成员、统计或最新状态时调用工具获取,不要根据页面标题或历史对话臆测细节
|
||||
- 不确定就直说不知道`;
|
||||
|
||||
/**
|
||||
* 系统条件性提示块占位符
|
||||
* 后端会将此占位符替换为:用户上下文 + 资源格式指南
|
||||
@ -228,6 +238,7 @@ export {
|
||||
REPORT_AI_SYSTEM_PROMPT,
|
||||
REPORT_ANALYSIS_SYSTEM_PROMPT,
|
||||
SEARCH_AI_SYSTEM_PROMPT,
|
||||
BASE_ASSISTANT_SYSTEM_PROMPT,
|
||||
withLanguagePreferencePrompt,
|
||||
AIModelNames,
|
||||
AINormalizeJsonContent,
|
||||
|
||||
4
resources/assets/sass/dark.scss
vendored
4
resources/assets/sass/dark.scss
vendored
@ -827,4 +827,8 @@ body.dark-mode-reverse {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ai-assistant-chat-mask {
|
||||
background-color: rgba(230, 230, 230, 0.6);
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user