feat(ai-assistant): 添加聊天窗口模式和页面上下文感知

- 新增 chat 显示模式,支持可拖拽的悬浮聊天窗口
  - 新增 page-context.js,根据当前路由提供针对性系统提示词
  - 优化浮动按钮:添加淡入淡出动画、修复右键菜单拖动问题、更新配色
  - 重构 Modal 为独立组件,支持 modal/chat 双模式切换
  - 恢复会话时自动滚动到底部
This commit is contained in:
kuaifan 2026-01-15 15:06:38 +00:00
parent 32ffecb905
commit 70ad8c394a
4 changed files with 929 additions and 252 deletions

View File

@ -1,18 +1,22 @@
<template> <template>
<div <transition name="fade">
v-show="visible" <div
ref="floatBtn" v-if="visible"
class="ai-float-button no-dark-content" ref="floatBtn"
:style="btnStyle" class="ai-float-button no-dark-content"
@mousedown.stop.prevent="onMouseDown"> :style="btnStyle"
<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"> @mousedown.stop.prevent="onMouseDown">
<path d="M385.80516777 713.87417358c-12.76971517 0-24.13100586-7.79328205-28.82575409-19.62404756l-48.91927648-123.9413531c-18.40341303-46.75969229-55.77360888-84.0359932-102.53330118-102.53330117l-123.94135309-48.91927649c-11.83076552-4.69474822-19.62404757-16.05603892-19.62404757-28.8257541s7.79328205-24.13100586 19.62404757-28.82575407l123.94135309-48.91927649c46.75969229-18.40341303 84.0359932-55.77360888 102.53330118-102.53330119l48.91927648-123.94135308c4.69474822-11.83076552 16.05603892-19.62404757 28.8257541-19.62404757s24.13100586 7.79328205 28.82575408 19.62404757l48.91927648 123.94135308c18.40341303 46.75969229 55.77360888 84.0359932 102.53330118 102.53330119l123.94135309 48.91927649c11.83076552 4.69474822 19.62404757 16.05603892 19.62404757 28.82575407 0 12.76971517-7.79328205 24.13100586-19.62404757 28.8257541l-123.94135309 48.91927649c-46.75969229 18.40341303-84.0359932 55.77360888-102.53330118 102.53330117l-48.91927648 123.9413531c-4.69474822 11.83076552-16.14993388 19.62404757-28.82575408 19.62404756zM177.45224165 390.12433614l50.89107073 20.0935224c62.62794129 24.69437565 112.67395736 74.74039171 137.368333 137.36833299l20.09352239 50.89107073 20.0935224-50.89107073c24.69437565-62.62794129 74.74039171-112.67395736 137.368333-137.36833299l50.89107072-20.0935224-50.89107073-20.09352239c-62.62794129-24.69437565-112.67395736-74.74039171-137.36833299-137.36833301l-20.09352239-50.89107074-20.0935224 50.89107074c-24.69437565 62.62794129-74.74039171 112.67395736-137.368333 137.36833301l-50.89107073 20.09352239zM771.33789183 957.62550131c-12.76971517 0-24.13100586-7.79328205-28.82575409-19.62404758l-26.6661699-67.6043744c-8.63833672-21.87752672-26.10280012-39.34199011-47.98032684-47.98032684l-67.60437441-26.6661699c-11.83076552-4.69474822-19.62404757-16.05603892-19.62404757-28.82575409s7.79328205-24.13100586 19.62404757-28.82575409l67.60437441-26.6661699c21.87752672-8.63833672 39.34199011-26.10280012 47.98032684-47.98032685l26.6661699-67.6043744c4.69474822-11.83076552 16.05603892-19.62404757 28.82575409-19.62404757s24.13100586 7.79328205 28.82575409 19.62404757l26.66616991 67.6043744c8.63833672 21.87752672 26.10280012 39.34199011 47.98032684 47.98032685l67.6043744 26.6661699c11.83076552 4.69474822 19.62404757 16.05603892 19.62404757 28.82575409s-7.79328205 24.13100586-19.62404757 28.82575409l-67.6043744 26.6661699c-21.87752672 8.63833672-39.34199011 26.10280012-47.98032684 47.98032684l-26.66616991 67.6043744c-4.69474822 11.83076552-16.14993388 19.62404757-28.82575409 19.62404758z m-75.58544639-190.70067281c33.61439727 14.83540438 60.75004201 41.87715415 75.49155143 75.49155143 14.83540438-33.61439727 41.87715415-60.75004201 75.49155142-75.49155143-33.61439727-14.83540438-60.75004201-41.87715415-75.49155142-75.49155143-14.74150942 33.61439727-41.87715415 60.75004201-75.49155143 75.49155143z"/> <svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
</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"/>
</div> </svg>
</div>
</transition>
</template> </template>
<script> <script>
import emitter from "../../store/events"; import emitter from "../../store/events";
import {withLanguagePreferencePrompt} from "../../utils/ai";
import {getPageContext} from "./page-context";
export default { export default {
name: 'AIAssistantFloatButton', name: 'AIAssistantFloatButton',
@ -86,6 +90,7 @@ export default {
window.removeEventListener('resize', this.onResize); window.removeEventListener('resize', this.onResize);
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);
}, },
methods: { methods: {
@ -143,6 +148,9 @@ export default {
* 鼠标按下 * 鼠标按下
*/ */
onMouseDown(e) { onMouseDown(e) {
//
if (e.button !== 0) return;
this.record = { this.record = {
time: Date.now(), time: Date.now(),
startLeft: this.left, startLeft: this.left,
@ -154,6 +162,16 @@ export default {
document.addEventListener('mousemove', this.onMouseMove); document.addEventListener('mousemove', this.onMouseMove);
document.addEventListener('mouseup', this.onMouseUp); document.addEventListener('mouseup', this.onMouseUp);
document.addEventListener('contextmenu', this.onContextMenu);
},
/**
* 右键菜单弹出时取消拖动
*/
onContextMenu() {
if (this.dragging) {
this.onMouseUp();
}
}, },
/** /**
@ -179,6 +197,7 @@ export default {
onMouseUp() { onMouseUp() {
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);
const moveDistance = Math.abs(this.left - this.record.startLeft) + Math.abs(this.top - this.record.startTop); const moveDistance = Math.abs(this.left - this.record.startLeft) + Math.abs(this.top - this.record.startTop);
const duration = Date.now() - this.record.time; const duration = Date.now() - this.record.time;
@ -218,10 +237,33 @@ export default {
*/ */
onClick() { onClick() {
emitter.emit('openAIAssistant', { emitter.emit('openAIAssistant', {
displayMode: 'chat',
sessionKey: 'global', sessionKey: 'global',
resumeSession: 300, resumeSession: 300,
showApplyButton: false,
onBeforeSend: this.handleBeforeSend,
}); });
}, },
/**
* 处理发送前的上下文准备
* 在发送时动态获取当前页面上下文确保上下文与用户当前所在页面一致
* @param context - 对话历史上下文
* @returns {(string|*)[][]}
*/
handleBeforeSend(context = []) {
const {systemPrompt} = getPageContext(this.$store);
const prepared = [
['system', withLanguagePreferencePrompt(systemPrompt)],
];
if (context.length > 0) {
prepared.push(...context);
}
return prepared;
}
}, },
}; };
</script> </script>
@ -231,8 +273,8 @@ export default {
position: fixed; position: fixed;
z-index: 1000; z-index: 1000;
border-radius: 50%; border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background: #8bcf70;
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4); box-shadow: 0 4px 12px lch(77 53.3 131.54 / 0.4);
cursor: pointer; cursor: pointer;
display: flex; display: flex;
align-items: center; align-items: center;
@ -242,7 +284,7 @@ export default {
&:hover { &:hover {
transform: scale(1.08); transform: scale(1.08);
box-shadow: 0 6px 16px rgba(102, 126, 234, 0.5); box-shadow: 0 6px 16px lch(77 53.3 131.54 / 0.5);
} }
&:active { &:active {

View File

@ -1,10 +1,8 @@
<template> <template>
<Modal <AssistantModal
v-model="showModal" v-model="showModal"
:width="shouldCreateNewSession ? '440px' : '600px'" :displayMode="displayMode"
:mask-closable="false" :shouldCreateNewSession="shouldCreateNewSession">
:footer-hide="true"
class-name="ai-assistant-modal">
<div slot="header" class="ai-assistant-header"> <div slot="header" class="ai-assistant-header">
<div class="ai-assistant-header-title"> <div class="ai-assistant-header-title">
<i class="taskfont">&#xe8a1;</i> <i class="taskfont">&#xe8a1;</i>
@ -90,6 +88,17 @@
</div> </div>
</div> </div>
</div> </div>
<div v-else-if="displayMode === 'chat'" class="ai-assistant-welcome" @click="onFocus">
<div class="ai-assistant-welcome-icon">
<i class="taskfont">&#xe8a1;</i>
</div>
<div class="ai-assistant-welcome-title">
欢迎使用 AI 助手
</div>
<div class="ai-assistant-welcome-swiper">
<!-- Swiper 容器 -->
</div>
</div>
<div class="ai-assistant-input"> <div class="ai-assistant-input">
<Input <Input
v-model="inputValue" v-model="inputValue"
@ -131,7 +140,7 @@
</div> </div>
</div> </div>
</div> </div>
</Modal> </AssistantModal>
</template> </template>
<script> <script>
@ -141,14 +150,16 @@ import {SSEClient} from "../../utils";
import {AIBotMap, AIModelNames} from "../../utils/ai"; import {AIBotMap, AIModelNames} 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";
export default { export default {
name: 'AIAssistant', name: 'AIAssistant',
components: {DialogMarkdown}, components: {AssistantModal, DialogMarkdown},
floatButtonInstance: null, floatButtonInstance: null,
data() { data() {
return { return {
// //
displayMode: 'modal',
showModal: false, showModal: false,
closing: false, closing: false,
loadIng: 0, loadIng: 0,
@ -232,6 +243,13 @@ export default {
}, },
}, },
methods: { methods: {
/**
* 获取输入框焦点事件
*/
onFocus() {
this.$refs.inputRef?.focus();
},
/** /**
* 挂载浮动按钮到 body * 挂载浮动按钮到 body
*/ */
@ -264,6 +282,25 @@ export default {
if (!$A.isJson(params)) { if (!$A.isJson(params)) {
params = {}; params = {};
} }
const newDisplayMode = params.displayMode === 'chat' ? 'chat' : 'modal';
let timeout = 0;
if (this.showModal && this.displayMode === 'chat' && newDisplayMode === 'modal') {
this.showModal = false;
timeout = 50;
}
setTimeout(() => {
this.doOpenAssistant(params, newDisplayMode);
}, timeout);
},
/**
* 实际执行打开助手的逻辑
*/
doOpenAssistant(params, displayMode) {
//
this.displayMode = displayMode;
this.inputValue = params.value || ''; this.inputValue = params.value || '';
this.inputPlaceholder = params.placeholder || null; this.inputPlaceholder = params.placeholder || null;
this.inputRows = params.rows || null; this.inputRows = params.rows || null;
@ -289,7 +326,7 @@ export default {
this.$nextTick(() => { this.$nextTick(() => {
this.scheduleAutoSubmit(); this.scheduleAutoSubmit();
this.scrollResponsesToBottom(); this.scrollResponsesToBottom();
this.$refs.inputRef.focus(); this.onFocus();
}); });
}, },
@ -1103,6 +1140,7 @@ export default {
this.currentSessionId = session.id; this.currentSessionId = session.id;
this.responses = JSON.parse(JSON.stringify(session.responses)); this.responses = JSON.parse(JSON.stringify(session.responses));
this.syncResponseSeed(); this.syncResponseSeed();
this.scrollResponsesToBottom();
} }
}, },
@ -1174,262 +1212,251 @@ export default {
</script> </script>
<style lang="scss"> <style lang="scss">
.ai-assistant-modal {
--apply-reasoning-before-bg: #e1e1e1;
.ivu-modal {
transition: width 0.3s, max-width 0.3s;
.ivu-modal-header {
border-bottom: none !important;
}
.ivu-modal-body {
padding: 0 !important;
}
}
.ai-assistant-header { .ai-assistant-header {
display: flex;
align-items: center;
margin: -11px 24px -10px 0;
height: 38px;
.ai-assistant-header-title {
flex: 1;
min-width: 0;
display: flex; display: flex;
align-items: center; align-items: center;
margin: -11px 24px -10px 0; color: #303133;
height: 38px; padding-right: 12px;
gap: 8px;
.ai-assistant-header-title { > i {
font-size: 18px;
}
> span {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
display: flex; overflow: hidden;
align-items: center; text-overflow: ellipsis;
color: #303133; white-space: nowrap;
padding-right: 12px; font-size: 18px;
gap: 8px; font-weight: 500;
> i {
font-size: 18px;
}
> span {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 18px;
font-weight: 500;
}
}
.ai-assistant-header-actions {
display: flex;
align-items: center;
gap: 4px;
.ai-assistant-header-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 6px;
cursor: pointer;
transition: background-color 0.2s;
&:hover {
background-color: rgba(0, 0, 0, 0.06);
}
> i {
font-size: 18px;
}
}
} }
} }
.ai-assistant-header-actions {
.ai-assistant-content {
display: flex; display: flex;
flex-direction: column; align-items: center;
max-height: calc(var(--window-height) - var(--status-bar-height) - var(--navigation-bar-height) - 266px); gap: 4px;
@media (height <= 900px) {
max-height: calc(var(--window-height) - var(--status-bar-height) - var(--navigation-bar-height) - 136px);
}
.ai-assistant-output { .ai-assistant-header-btn {
flex: 1;
min-height: 0;
padding: 12px 24px;
margin-bottom: 12px;
border-radius: 0;
background: #f8f9fb;
border: 0;
overflow-y: auto;
}
.ai-assistant-output-item + .ai-assistant-output-item {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid rgba(0, 0, 0, 0.05);
}
.ai-assistant-output-apply {
position: sticky;
top: 0;
right: 0;
z-index: 1;
display: flex;
justify-content: flex-end;
align-items: center;
height: 26px;
color: #999;
gap: 4px;
}
.ai-assistant-output-icon {
font-size: 16px;
color: #52c41a;
}
.ai-assistant-apply-btn {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 13px; width: 28px;
border-radius: 4px; height: 28px;
height: 26px;
padding: 0 8px;
}
.ai-assistant-output-status {
color: #52c41a;
}
.ai-assistant-output-error {
color: #ff4d4f;
}
.ai-assistant-output-meta {
display: flex;
align-items: center;
height: 24px;
margin-top: -24px;
}
.ai-assistant-output-model {
max-width: 50%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 12px;
font-weight: 600;
color: #2f54eb;
background: rgba(47, 84, 235, 0.08);
border-radius: 4px;
padding: 2px 8px;
}
.ai-assistant-output-question {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
font-size: 12px;
color: #666;
line-height: 1.4;
margin-top: 8px;
}
.ai-assistant-output-placeholder {
margin-top: 12px;
font-size: 13px;
color: #999;
padding: 8px;
border-radius: 6px; border-radius: 6px;
background: rgba(0, 0, 0, 0.02); cursor: pointer;
} transition: background-color 0.2s;
.ai-assistant-output-markdown {
margin-top: 12px;
font-size: 13px;
.apply-reasoning {
margin: 0 0 12px 0;
padding: 0 0 0 13px;
line-height: 26px;
position: relative;
&:before {
content: "";
position: absolute;
top: 0;
left: 0;
bottom: 0;
width: 2px;
background-color: var(--apply-reasoning-before-bg);
}
.reasoning-label {
margin-bottom: 4px;
opacity: 0.9;
}
.reasoning-content {
opacity: 0.5;
> p:last-child {
margin-bottom: 0;
}
}
}
}
}
.ai-assistant-input {
padding: 4px 16px 16px;
display: flex;
flex-direction: column;
gap: 12px;
.ivu-input {
background-color: transparent;
border: 0;
border-radius: 0;
box-shadow: none;
padding: 0 8px;
resize: none;
&:hover { &:hover {
border-color: transparent; background-color: rgba(0, 0, 0, 0.06);
}
> i {
font-size: 18px;
} }
} }
}
}
.ivu-select-selection { .ai-assistant-content {
background-color: transparent; display: flex;
border: 0; flex-direction: column;
border-radius: 0; max-height: calc(var(--window-height) - var(--status-bar-height) - var(--navigation-bar-height) - 266px);
box-shadow: none; @media (height <= 900px) {
padding: 0 0 0 8px; max-height: calc(var(--window-height) - var(--status-bar-height) - var(--navigation-bar-height) - 136px);
}
.ai-assistant-welcome,
.ai-assistant-output {
flex: 1;
min-height: 0;
padding: 12px 24px;
margin-bottom: 12px;
border-radius: 0;
background: #f8f9fb;
border: 0;
overflow-y: auto;
}
.ai-assistant-output-item + .ai-assistant-output-item {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid rgba(0, 0, 0, 0.05);
}
.ai-assistant-output-apply {
position: sticky;
top: 0;
right: 0;
z-index: 1;
display: flex;
justify-content: flex-end;
align-items: center;
height: 26px;
color: #999;
gap: 4px;
}
.ai-assistant-output-icon {
font-size: 16px;
color: #52c41a;
}
.ai-assistant-apply-btn {
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
border-radius: 4px;
height: 26px;
padding: 0 8px;
}
.ai-assistant-output-status {
color: #52c41a;
}
.ai-assistant-output-error {
color: #ff4d4f;
}
.ai-assistant-output-meta {
display: flex;
align-items: center;
height: 24px;
margin-top: -24px;
}
.ai-assistant-output-model {
max-width: 50%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 12px;
font-weight: 600;
color: #2f54eb;
background: rgba(47, 84, 235, 0.08);
border-radius: 4px;
padding: 2px 8px;
}
.ai-assistant-output-question {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
font-size: 12px;
color: #666;
line-height: 1.4;
margin-top: 8px;
}
.ai-assistant-output-placeholder {
margin-top: 12px;
font-size: 13px;
color: #999;
padding: 8px;
border-radius: 6px;
background: rgba(0, 0, 0, 0.02);
}
.ai-assistant-output-markdown {
margin-top: 12px;
font-size: 13px;
.apply-reasoning {
margin: 0 0 12px 0;
padding: 0 0 0 13px;
line-height: 26px;
position: relative;
&:before {
content: "";
position: absolute;
top: 0;
left: 0;
bottom: 0;
width: 2px;
background-color: var(--apply-reasoning-before-bg);
}
.reasoning-label {
margin-bottom: 4px;
opacity: 0.9;
}
.reasoning-content {
opacity: 0.5;
> p:last-child {
margin-bottom: 0;
}
}
}
}
}
.ai-assistant-input {
padding: 4px 16px 16px;
display: flex;
flex-direction: column;
gap: 12px;
.ivu-input {
background-color: transparent;
border: 0;
border-radius: 0;
box-shadow: none;
padding: 0 8px;
resize: none;
&:hover {
border-color: transparent;
} }
} }
.ai-assistant-footer { .ivu-select-selection {
display: flex; background-color: transparent;
justify-content: space-between; border: 0;
flex-wrap: wrap; border-radius: 0;
gap: 12px; box-shadow: none;
.ai-assistant-footer-models { padding: 0 0 0 8px;
text-align: left; }
.ivu-select-disabled { }
.ivu-select-selection {
background-color: transparent; .ai-assistant-footer {
} display: flex;
} justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
.ai-assistant-footer-models {
text-align: left;
.ivu-select-disabled {
.ivu-select-selection { .ivu-select-selection {
border: 0; background-color: transparent;
box-shadow: none;
.ivu-select-placeholder,
.ivu-select-selected-value {
padding-left: 0;
opacity: 0.8;
}
} }
} }
.ai-assistant-footer-btns { .ivu-select-selection {
flex: 1; border: 0;
display: flex; box-shadow: none;
justify-content: flex-end; .ivu-select-placeholder,
.ivu-select-selected-value {
padding-left: 0;
opacity: 0.8;
}
} }
} }
.ai-assistant-footer-btns {
flex: 1;
display: flex;
justify-content: flex-end;
}
} }
.ai-assistant-history-menu { .ai-assistant-history-menu {
@ -1507,6 +1534,93 @@ export default {
color: #F56C6C; color: #F56C6C;
} }
} }
.ai-assistant-chat {
position: fixed;
right: 24px;
bottom: 24px;
width: 460px;
height: 80vh;
min-width: 380px;
max-width: 600px;
max-height: 640px;
background-color: #ffffff;
box-shadow: 0 10px 30px 0 rgba(0, 0, 0, 0.12);
border-radius: 16px;
overflow: hidden;
display: flex;
flex-direction: column;
.ai-assistant-close {
position: absolute;
top: 6px;
right: 10px;
z-index: 1;
font-size: 38px;
color: #999;
cursor: pointer;
transition: all 0.2s;
&:hover {
color: #444;
transform: rotate(-90deg);
}
}
.ai-assistant-drag-handle {
cursor: move;
user-select: none;
}
.ai-assistant-header {
margin: 6px 48px 6px 16px;
.ai-assistant-header-title {
> span {
font-size: 17px;
}
}
}
.ai-assistant-content {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
.ai-assistant-welcome {
.ai-assistant-welcome-icon {
margin-top: 12px;
i {
font-size: 24px;
}
}
.ai-assistant-welcome-title {
margin-top: 12px;
}
.ai-assistant-welcome-swiper {
margin-top: 24px;
}
}
}
.ai-assistant-input {
padding: 4px 12px 12px;
}
}
.ai-assistant-modal {
--apply-reasoning-before-bg: #e1e1e1;
.ivu-modal {
transition: width 0.3s, max-width 0.3s;
.ivu-modal-header {
border-bottom: none !important;
}
.ivu-modal-body {
padding: 0 !important;
}
}
}
body.dark-mode-reverse { body.dark-mode-reverse {
.ai-assistant-modal { .ai-assistant-modal {
--apply-reasoning-before-bg: #4e4e56; --apply-reasoning-before-bg: #4e4e56;

View File

@ -0,0 +1,295 @@
<template>
<div v-if="displayMode === 'chat'" v-transfer-dom :data-transfer="true">
<transition name="fade">
<div
v-if="visible"
ref="chatWindow"
class="ai-assistant-chat"
:style="chatStyle">
<Icon class="ai-assistant-close" type="ios-close" @click="onClose"/>
<div
class="ai-assistant-drag-handle"
@mousedown.stop.prevent="onMouseDown">
<slot name="header"></slot>
</div>
<slot></slot>
</div>
</transition>
</div>
<Modal
v-else
v-model="visible"
:width="shouldCreateNewSession ? '440px' : '600px'"
:mask-closable="false"
:footer-hide="true"
class-name="ai-assistant-modal">
<template #header>
<slot name="header"></slot>
</template>
<slot></slot>
</Modal>
</template>
<script>
import TransferDom from "../../directives/transfer-dom";
export default {
name: 'AssistantModal',
directives: {TransferDom},
props: {
value: {
type: Boolean,
default: false
},
displayMode: {
type: String,
default: 'modal'
},
shouldCreateNewSession: {
type: Boolean,
default: false
}
},
data() {
return {
//
position: {
x: 24, //
y: 24, //
fromRight: true, // true: , false:
fromBottom: true, // true: , false:
},
dragging: false,
positionLoaded: false,
cacheKey: 'aiAssistant.chatPosition',
windowSize: {
width: 460,
height: 640,
},
record: {},
};
},
computed: {
visible: {
get() {
return this.value;
},
set(val) {
this.$emit('input', val);
}
},
clientWidth() {
return this.windowWidth || document.documentElement.clientWidth;
},
clientHeight() {
return this.windowHeight || document.documentElement.clientHeight;
},
// left
left() {
if (this.position.fromRight) {
return this.clientWidth - this.windowSize.width - this.position.x;
}
return this.position.x;
},
// top
top() {
if (this.position.fromBottom) {
return this.clientHeight - this.windowSize.height - this.position.y;
}
return this.position.y;
},
chatStyle() {
if (!this.positionLoaded) {
return {
opacity: 0,
};
}
return {
left: `${this.left}px`,
top: `${this.top}px`,
};
},
},
watch: {
visible(val) {
if (val && this.displayMode === 'chat') {
this.$nextTick(() => {
this.updateWindowSize();
});
}
},
},
mounted() {
this.loadPosition();
window.addEventListener('resize', this.onResize);
},
beforeDestroy() {
window.removeEventListener('resize', this.onResize);
document.removeEventListener('mousemove', this.onMouseMove);
document.removeEventListener('mouseup', this.onMouseUp);
document.removeEventListener('contextmenu', this.onContextMenu);
},
methods: {
/**
* 更新窗口实际尺寸
*/
updateWindowSize() {
const el = this.$refs.chatWindow;
if (el) {
this.windowSize = {
width: el.offsetWidth,
height: el.offsetHeight,
};
}
},
/**
* 加载保存的位置
*/
async loadPosition() {
try {
const saved = await $A.IDBString(this.cacheKey);
if (saved) {
const pos = JSON.parse(saved);
if (pos && typeof pos.x === 'number' && typeof pos.y === 'number') {
this.position = pos;
this.$nextTick(() => {
this.checkBounds();
this.positionLoaded = true;
});
return;
}
}
} catch (e) {
// ignore
}
//
this.position = {x: 24, y: 24, fromRight: true, fromBottom: true};
this.positionLoaded = true;
},
/**
* 保存位置
*/
savePosition() {
$A.IDBSave(this.cacheKey, JSON.stringify(this.position));
},
/**
* 根据当前 left/top 更新 position 对象
*/
updatePositionFromCoords(left, top) {
const centerX = left + this.windowSize.width / 2;
const centerY = top + this.windowSize.height / 2;
//
const fromRight = centerX >= this.clientWidth / 2;
const fromBottom = centerY >= this.clientHeight / 2;
//
const x = fromRight ? (this.clientWidth - this.windowSize.width - left) : left;
const y = fromBottom ? (this.clientHeight - this.windowSize.height - top) : top;
this.position = {x, y, fromRight, fromBottom};
},
/**
* 鼠标按下
*/
onMouseDown(e) {
//
if (e.button !== 0) return;
this.updateWindowSize();
this.record = {
time: Date.now(),
startLeft: this.left,
startTop: this.top,
offsetX: e.clientX - this.left,
offsetY: e.clientY - this.top,
};
this.dragging = true;
document.addEventListener('mousemove', this.onMouseMove);
document.addEventListener('mouseup', this.onMouseUp);
document.addEventListener('contextmenu', this.onContextMenu);
},
/**
* 右键菜单弹出时取消拖动
*/
onContextMenu() {
if (this.dragging) {
this.onMouseUp();
}
},
/**
* 鼠标移动
*/
onMouseMove(e) {
if (!this.dragging) return;
const minMargin = 12;
let newLeft = e.clientX - this.record.offsetX;
let newTop = e.clientY - this.record.offsetY;
// 12px
newLeft = Math.max(minMargin, Math.min(newLeft, this.clientWidth - this.windowSize.width - minMargin));
newTop = Math.max(minMargin, Math.min(newTop, this.clientHeight - this.windowSize.height - minMargin));
this.updatePositionFromCoords(newLeft, newTop);
},
/**
* 鼠标松开
*/
onMouseUp() {
document.removeEventListener('mousemove', this.onMouseMove);
document.removeEventListener('mouseup', this.onMouseUp);
document.removeEventListener('contextmenu', this.onContextMenu);
this.savePosition();
this.dragging = false;
},
/**
* 检查边界仅在加载和窗口变化时调用
*/
checkBounds() {
const minMargin = 12;
// 12px
const maxX = this.clientWidth - this.windowSize.width - minMargin;
const maxY = this.clientHeight - this.windowSize.height - minMargin;
this.position.x = Math.max(minMargin, Math.min(this.position.x, maxX));
this.position.y = Math.max(minMargin, Math.min(this.position.y, maxY));
},
/**
* 窗口大小改变
*/
onResize() {
this.$nextTick(() => {
this.updateWindowSize();
this.checkBounds();
});
},
onClose() {
this.$emit('input', false);
}
}
};
</script>

View File

@ -0,0 +1,226 @@
/**
* AI 助手页面上下文配置
*
* 根据当前路由和应用状态 AI 助手提供针对性的系统提示词
* 原则诚实描述 AI 能力边界不承诺做不到的事情
*/
/**
* 获取当前页面的 AI 上下文
* @param {Object} store - Vuex store 实例
* @returns {Object} { systemPrompt, title }
*/
export function getPageContext(store) {
const routeName = store.state.routeName;
const contextMap = {
'manage-dashboard': getDashboardContext,
'manage-project': getProjectContext,
'manage-messenger': getMessengerContext,
'manage-calendar': getCalendarContext,
'manage-file': getFileContext,
};
const getContext = contextMap[routeName];
if (getContext) {
return getContext(store);
}
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) {
if (overdueCount > 0) lines.push(`- 逾期任务:${overdueCount}`);
if (todayCount > 0) lines.push(`- 今日到期:${todayCount}`);
if (todoCount > 0) lines.push(`- 待办任务:${todoCount}`);
if (assistCount > 0) lines.push(`- 协助任务:${assistCount}`);
} else {
lines.push('- 暂无待办任务');
}
lines.push(
'',
'你可以帮助用户:',
'- 回答任务管理和时间规划相关问题',
'- 根据用户描述的任务情况给出优先级建议',
'- 协助用户整理和规划工作思路',
);
return {
title: '工作助手',
systemPrompt: lines.join('\n'),
};
}
/**
* 项目详情上下文
*/
function getProjectContext(store) {
const project = store.getters.projectData || {};
const columns = store.state.cacheColumns || [];
const tasks = store.state.cacheTasks || [];
const lines = [
'你是一个项目管理助手。用户正在查看项目详情页面。',
];
if (project.id) {
// 项目基本信息
lines.push('', '当前项目:');
lines.push(`- 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}`);
}
}
lines.push(
'',
'你可以帮助用户:',
'- 协助拆解用户描述的需求为具体任务',
'- 回答项目管理方法和最佳实践问题',
'- 根据用户提供的信息给出排期建议',
);
return {
title: '项目助手',
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);
const lines = [
'你是一个沟通协作助手。用户正在使用消息功能。',
];
if (dialog) {
const dialogType = dialog.type === 'group' ? '群聊' : '私聊';
lines.push('', '当前对话:');
lines.push(`- dialog_id${dialog.id}`);
lines.push(`- 类型:${dialogType}`);
if (dialog.name) {
lines.push(`- 名称:${dialog.name}`);
}
}
lines.push(
'',
'你可以帮助用户:',
'- 润色和优化用户输入的消息内容',
'- 根据用户描述的场景生成得体的回复',
'- 翻译消息内容',
'',
'请保持回复简洁、专业、得体。',
);
return {
title: '沟通助手',
systemPrompt: lines.join('\n'),
};
}
/**
* 日历上下文
*/
function getCalendarContext() {
const lines = [
'你是一个时间管理助手。用户正在查看日历。',
'',
'你可以帮助用户:',
'- 回答时间管理和日程规划相关问题',
'- 根据用户描述的安排给出优化建议',
'- 协助用户合理安排会议和任务时间',
];
return {
title: '日程助手',
systemPrompt: lines.join('\n'),
};
}
/**
* 文件管理上下文
*/
function getFileContext() {
const lines = [
'你是一个文件管理助手。用户正在查看文件管理页面。',
'',
'你可以帮助用户:',
'- 回答文件整理和分类相关问题',
'- 根据用户描述建议文件命名和组织方式',
];
return {
title: '文件助手',
systemPrompt: lines.join('\n'),
};
}
/**
* 默认上下文通用
*/
function getDefaultContext() {
const lines = [
'你是 DooTask 的 AI 助手。',
'',
'你可以帮助用户:',
'- 回答任务管理和项目协作相关问题',
'- 提供工作效率和方法论建议',
'- 协助处理各种工作事务',
];
return {
title: 'AI 助手',
systemPrompt: lines.join('\n'),
};
}