feat: Enhance AI Assistant with session management and improved UI

- Added session management capabilities to the AI Assistant, allowing users to create, load, and delete sessions.
- Improved modal UI with a new header for session actions and a footer for model selection.
- Updated input handling to support dynamic loading of session data and improved response formatting.
- Enhanced search functionality in various components to utilize the AI Assistant for generating content based on user input.
This commit is contained in:
kuaifan 2025-12-31 08:40:19 +00:00
parent fe7a2a0e73
commit 986c4871df
7 changed files with 596 additions and 69 deletions

View File

@ -1,11 +1,52 @@
<template>
<Modal
v-model="showModal"
:title="$L('AI 助手')"
:mask-closable="false"
:closable="false"
:width="shouldCreateNewSession ? '420px' : '600px'"
:mask-closable="false"
:footer-hide="true"
class-name="ai-assistant-modal">
<div slot="header" class="ai-assistant-header">
<div class="ai-assistant-header-title">
<i class="taskfont">&#xe8a1;</i>
<span>{{ modalTitle || $L('AI 助手') }}</span>
</div>
<div class="ai-assistant-header-actions">
<div v-if="sessionEnabled && (responses.length > 0 || hasSessionHistory)" class="ai-assistant-header-btn" :title="$L('新建会话')" @click="createNewSession()">
<i class="taskfont">&#xe6f2;</i>
</div>
<Dropdown
v-if="sessionEnabled && hasSessionHistory"
trigger="click"
placement="bottom-end"
:transfer="true">
<div class="ai-assistant-header-btn" :title="$L('历史会话')">
<i class="taskfont">&#xe6e8;</i>
</div>
<DropdownMenu slot="list" class="ai-assistant-history-menu">
<DropdownItem
v-for="session in currentSessionList"
:key="session.id"
:class="{'active': session.id === currentSessionId}"
@click.native="loadSession(session.id)">
<div class="history-item">
<div class="history-item-content">
<span class="history-item-title">{{ session.title }}</span>
<span class="history-item-time">{{ formatSessionTime(session.updatedAt) }}</span>
</div>
<div class="history-item-delete" @click.stop="deleteSession(session.id)">
<i class="taskfont">&#xe6e5;</i>
</div>
</div>
</DropdownItem>
<DropdownItem divided @click.native="clearSessionHistory">
<div class="history-clear">
{{ $L('清空历史记录') }}
</div>
</DropdownItem>
</DropdownMenu>
</Dropdown>
</div>
</div>
<div class="ai-assistant-content">
<div
v-if="responses.length"
@ -21,6 +62,7 @@
</template>
<template v-else-if="response.rawOutput">
<Button
v-if="showApplyButton"
type="primary"
size="small"
:loading="response.applyLoading"
@ -31,7 +73,7 @@
</template>
<template v-else>
<Icon type="ios-sync" class="ai-assistant-output-icon icon-loading"/>
<span class="ai-assistant-output-status">{{ $L('生成中...') }}</span>
<span class="ai-assistant-output-status">{{ loadingText || $L('生成中...') }}</span>
</template>
</div>
<div class="ai-assistant-output-meta">
@ -41,7 +83,8 @@
<DialogMarkdown
v-if="response.rawOutput"
class="ai-assistant-output-markdown no-dark-content"
:text="response.displayOutput || response.rawOutput"/>
:text="response.displayOutput || response.rawOutput"
@click="onContentClick"/>
<div v-else class="ai-assistant-output-placeholder">
{{ response.status === 'error' ? (response.error || $L('发送失败')) : $L('等待 AI 回复...') }}
</div>
@ -52,36 +95,36 @@
v-model="inputValue"
type="textarea"
:placeholder="inputPlaceholder || $L('请输入你的问题...')"
:rows="inputRows || 2"
:autosize="inputAutosize || {minRows:2, maxRows:6}"
:rows="inputRows || 1"
:autosize="inputAutosize || {minRows:1, maxRows:6}"
:maxlength="inputMaxlength || 500" />
</div>
</div>
<div slot="footer" class="ai-assistant-footer">
<div class="ai-assistant-footer-models">
<Select
v-model="inputModel"
:placeholder="$L('选择模型')"
:loading="modelsLoading"
:disabled="modelsLoading || modelGroups.length === 0"
:not-found-text="$L('暂无可用模型')"
transfer>
<OptionGroup
v-for="group in modelGroups"
:key="group.type"
:label="group.label">
<Option
v-for="option in group.options"
:key="option.id"
:value="option.id">
{{ option.label }}
</Option>
</OptionGroup>
</Select>
</div>
<div class="ai-assistant-footer-btns">
<Button type="text" @click="showModal=false">{{ $L('取消') }}</Button>
<Button type="primary" :loading="loadIng > 0" @click="onSubmit">{{ submitButtonText || $L('确定') }}</Button>
<div class="ai-assistant-footer">
<div class="ai-assistant-footer-models">
<Select
v-model="inputModel"
:placeholder="$L('选择模型')"
:loading="modelsLoading"
:disabled="modelsLoading || modelGroups.length === 0"
:not-found-text="$L('暂无可用模型')"
transfer>
<OptionGroup
v-for="group in modelGroups"
:key="group.type"
:label="group.label">
<Option
v-for="option in group.options"
:key="option.id"
:value="option.id">
{{ option.label }}
</Option>
</OptionGroup>
</Select>
</div>
<div class="ai-assistant-footer-btns">
<Button v-if="submitButtonText" type="primary" shape="circle" icon="md-arrow-up" :loading="loadIng > 0" @click="onSubmit">{{ submitButtonText }}</Button>
<Button v-else type="primary" shape="circle" icon="md-arrow-up" :loading="loadIng > 0" @click="onSubmit"></Button>
</div>
</div>
</div>
</div>
</Modal>
@ -104,8 +147,11 @@ export default {
loadIng: 0,
pendingAutoSubmit: false,
autoSubmitTimer: null,
modalTitle: null,
applyButtonText: null,
submitButtonText: null,
showApplyButton: true,
loadingText: null,
//
inputValue: '',
@ -134,11 +180,20 @@ export default {
maxResponses: 50,
contextWindowSize: 10,
activeSSEClients: [],
//
sessionEnabled: false,
sessionStore: {},
currentSessionKey: 'default',
currentSessionId: null,
sessionCacheKey: 'aiAssistant.sessions',
maxSessionsPerKey: 20,
}
},
mounted() {
emitter.on('openAIAssistant', this.onOpenAIAssistant);
this.loadCachedModel();
this.loadSessionStore();
},
beforeDestroy() {
emitter.off('openAIAssistant', this.onOpenAIAssistant);
@ -152,6 +207,12 @@ export default {
shouldCreateNewSession() {
return this.responses.length === 0;
},
currentSessionList() {
return this.sessionStore[this.currentSessionKey] || [];
},
hasSessionHistory() {
return this.currentSessionList.length > 0;
},
},
watch: {
inputModel(value) {
@ -173,18 +234,24 @@ export default {
this.inputMaxlength = params.maxlength || null;
this.applyHook = params.onApply || null;
this.beforeSendHook = params.onBeforeSend || null;
this.modalTitle = params.title || null;
this.applyButtonText = params.applyButtonText || null;
this.submitButtonText = params.submitButtonText || null;
this.showApplyButton = params.showApplyButton !== false;
this.loadingText = params.loadingText || null;
this.renderHook = params.onRender || null;
this.pendingAutoSubmit = !!params.autoSubmit;
//
this.responses = [];
//
this.initSession(params.sessionKey, params.resumeSession);
this.showModal = true;
this.fetchModelOptions();
this.clearActiveSSEClients();
this.clearAutoSubmitTimer();
this.$nextTick(() => {
this.scheduleAutoSubmit();
this.scrollResponsesToBottom();
});
},
@ -512,6 +579,8 @@ export default {
responseEntry.status = 'completed';
}
this.releaseSSEClient(sse);
//
this.saveCurrentSession();
break;
}
});
@ -817,6 +886,275 @@ export default {
}
return distance <= threshold;
},
/**
* 处理内容区域的点击事件
*/
onContentClick(e) {
const target = e.target;
if (target.tagName !== 'A') {
return;
}
const href = target.getAttribute('href');
if (!href || !href.startsWith('dootask://')) {
return;
}
e.preventDefault();
e.stopPropagation();
// dootask://
// : dootask://type/id dootask://task/123
const match = href.match(/^dootask:\/\/(\w+)\/(\d+)$/);
if (!match) {
return;
}
const [, type, id] = match;
const numId = parseInt(id, 10);
switch (type) {
case 'task':
this.$store.dispatch('openTask', {id: numId});
break;
case 'project':
this.goForward({name: 'manage-project', params: {projectId: numId}});
break;
case 'file':
this.goForward({name: 'manage-file', params: {folderId: 0, fileId: null, shakeId: numId}});
this.$store.state.fileShakeId = numId;
setTimeout(() => {
this.$store.state.fileShakeId = 0;
}, 600);
break;
case 'contact':
this.$store.dispatch('openDialogUserid', numId).catch(({msg}) => {
$A.modalError(msg || this.$L('打开会话失败'));
});
break;
}
},
// ==================== ====================
/**
* 加载持久化的会话数据
*/
async loadSessionStore() {
try {
const stored = await $A.IDBString(this.sessionCacheKey);
if (stored) {
this.sessionStore = JSON.parse(stored);
}
} catch (e) {
this.sessionStore = {};
}
},
/**
* 持久化会话数据
*/
saveSessionStore() {
try {
$A.IDBSave(this.sessionCacheKey, JSON.stringify(this.sessionStore));
} catch (e) {
console.warn('[AIAssistant] Failed to save session store:', e);
}
},
/**
* 生成会话 ID
*/
generateSessionId() {
return `session-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
},
/**
* 根据首次输入生成会话标题
*/
generateSessionTitle(responses) {
if (!responses || responses.length === 0) {
return this.$L('新会话');
}
const firstPrompt = responses.find(r => r.prompt)?.prompt || '';
if (!firstPrompt) {
return this.$L('新会话');
}
// 20
const title = firstPrompt.trim().substring(0, 20);
return title.length < firstPrompt.trim().length ? `${title}...` : title;
},
/**
* 获取指定场景的会话列表
*/
getSessionList(sessionKey) {
return this.sessionStore[sessionKey] || [];
},
/**
* 初始化会话
* @param {string} sessionKey - 会话场景标识不传则不启用会话管理
* @param {boolean|number} resumeSession - 恢复上次会话true 总是恢复数字表示秒数阈值上次会话在该时间内则恢复
*/
initSession(sessionKey, resumeSession = false) {
//
if (this.responses.length > 0) {
this.saveCurrentSession();
}
this.sessionEnabled = !!sessionKey;
if (this.sessionEnabled) {
this.currentSessionKey = sessionKey;
if (resumeSession) {
const sessions = this.getSessionList(sessionKey);
if (sessions.length > 0) {
const lastSession = sessions[0];
//
if (typeof resumeSession === 'number') {
const elapsed = (Date.now() - lastSession.updatedAt) / 1000;
if (elapsed > resumeSession) {
//
this.currentSessionId = this.generateSessionId();
this.responses = [];
return;
}
}
this.currentSessionId = lastSession.id;
this.responses = JSON.parse(JSON.stringify(lastSession.responses));
return;
}
}
this.currentSessionId = this.generateSessionId();
this.responses = [];
} else {
this.currentSessionKey = 'default';
this.currentSessionId = null;
this.responses = [];
}
},
/**
* 创建新会话
*/
createNewSession(autoSaveCurrent = true) {
//
if (autoSaveCurrent && this.responses.length > 0) {
this.saveCurrentSession();
}
//
this.currentSessionId = this.generateSessionId();
this.responses = [];
},
/**
* 保存当前会话到存储
*/
saveCurrentSession() {
if (!this.sessionEnabled || !this.currentSessionId || this.responses.length === 0) {
return;
}
const sessionKey = this.currentSessionKey;
if (!this.sessionStore[sessionKey]) {
this.$set(this.sessionStore, sessionKey, []);
}
const sessions = this.sessionStore[sessionKey];
const existingIndex = sessions.findIndex(s => s.id === this.currentSessionId);
const sessionData = {
id: this.currentSessionId,
title: this.generateSessionTitle(this.responses),
responses: JSON.parse(JSON.stringify(this.responses)),
createdAt: existingIndex > -1 ? sessions[existingIndex].createdAt : Date.now(),
updatedAt: Date.now(),
};
if (existingIndex > -1) {
sessions[existingIndex] = sessionData;
} else {
sessions.unshift(sessionData);
}
//
if (sessions.length > this.maxSessionsPerKey) {
sessions.splice(this.maxSessionsPerKey);
}
this.saveSessionStore();
},
/**
* 加载指定会话
*/
loadSession(sessionId) {
const sessions = this.getSessionList(this.currentSessionKey);
const session = sessions.find(s => s.id === sessionId);
if (session) {
//
if (this.currentSessionId !== sessionId && this.responses.length > 0) {
this.saveCurrentSession();
}
this.currentSessionId = session.id;
this.responses = JSON.parse(JSON.stringify(session.responses));
}
},
/**
* 删除指定会话
*/
deleteSession(sessionId) {
const sessions = this.getSessionList(this.currentSessionKey);
const index = sessions.findIndex(s => s.id === sessionId);
if (index > -1) {
sessions.splice(index, 1);
this.saveSessionStore();
//
if (this.currentSessionId === sessionId) {
this.createNewSession(false);
}
}
},
/**
* 清空当前场景的所有历史会话
*/
clearSessionHistory() {
$A.modalConfirm({
title: this.$L('清空历史会话'),
content: this.$L('确定要清空当前场景的所有历史会话吗?'),
onOk: () => {
this.$set(this.sessionStore, this.currentSessionKey, []);
this.saveSessionStore();
this.createNewSession(false);
}
});
},
/**
* 格式化会话时间显示
*/
formatSessionTime(timestamp) {
const now = $A.daytz();
const time = $A.dayjs(timestamp);
if (now.format('YYYY-MM-DD') === time.format('YYYY-MM-DD')) {
return this.$L('今天') + ' ' + time.format('HH:mm');
}
if (now.subtract(1, 'day').format('YYYY-MM-DD') === time.format('YYYY-MM-DD')) {
return this.$L('昨天') + ' ' + time.format('HH:mm');
}
if (now.year() === time.year()) {
return time.format('MM-DD HH:mm');
}
return time.format('YYYY-MM-DD HH:mm');
},
},
}
</script>
@ -825,18 +1163,64 @@ export default {
.ai-assistant-modal {
--apply-reasoning-before-bg: #e1e1e1;
.ivu-modal {
transition: max-width 0.3s ease;
transition: width 0.3s, max-width 0.3s;
.ivu-modal-header {
padding-left: 30px !important;
padding-right: 30px !important;
border-bottom: none !important;
}
.ivu-modal-body {
padding-top: 0 !important;
padding-bottom: 0 !important;
padding: 0 !important;
}
.ivu-modal-footer {
.ivu-btn {
min-width: auto !important;
}
.ai-assistant-header {
display: flex;
align-items: center;
margin: -11px 24px -10px 0;
height: 38px;
.ai-assistant-header-title {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
color: #303133;
padding-right: 12px;
gap: 8px;
> i {
font-size: 18px;
}
> span {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 18px;
font-weight: 500;
}
}
.ai-assistant-header-actions {
display: flex;
align-items: center;
gap: 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;
}
}
}
}
@ -844,19 +1228,19 @@ export default {
.ai-assistant-content {
display: flex;
flex-direction: column;
gap: 16px;
max-height: calc(var(--window-height) - var(--status-bar-height) - var(--navigation-bar-height) - 344px);
max-height: calc(var(--window-height) - var(--status-bar-height) - var(--navigation-bar-height) - 266px);
@media (height <= 900px) {
max-height: calc(var(--window-height) - var(--status-bar-height) - var(--navigation-bar-height) - 214px);
max-height: calc(var(--window-height) - var(--status-bar-height) - var(--navigation-bar-height) - 136px);
}
.ai-assistant-output {
flex: 1;
min-height: 0;
padding: 12px;
border-radius: 8px;
padding: 12px 24px;
margin-bottom: 12px;
border-radius: 0;
background: #f8f9fb;
border: 1px solid rgba(0, 0, 0, 0.04);
border: 0;
overflow-y: auto;
}
@ -874,7 +1258,7 @@ export default {
display: flex;
justify-content: flex-end;
align-items: center;
height: 24px;
height: 26px;
color: #999;
gap: 4px;
}
@ -885,10 +1269,13 @@ export default {
}
.ai-assistant-apply-btn {
font-size: 13px;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
border-radius: 4px;
height: 26px;
padding: 0 8px;
}
.ai-assistant-output-status {
@ -974,6 +1361,33 @@ export default {
}
}
.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;
}
}
.ivu-select-selection {
background-color: transparent;
border: 0;
border-radius: 0;
box-shadow: none;
padding: 0 0 0 8px;
}
}
.ai-assistant-footer {
display: flex;
justify-content: space-between;
@ -991,7 +1405,8 @@ export default {
box-shadow: none;
.ivu-select-placeholder,
.ivu-select-selected-value {
padding-left: 4px;
padding-left: 0;
opacity: 0.8;
}
}
}
@ -999,10 +1414,85 @@ export default {
flex: 1;
display: flex;
justify-content: flex-end;
gap: 12px;
}
}
}
.ai-assistant-history-menu {
min-width: 240px;
max-width: 300px;
max-height: 320px;
overflow-y: auto;
.ivu-dropdown-item {
&.active {
background-color: rgba(45, 140, 240, 0.1);
}
&:hover {
background-color: rgba(0, 0, 0, 0.04);
}
}
.history-item {
display: flex;
align-items: center;
gap: 8px;
.history-item-content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
.history-item-title {
font-size: 13px;
color: #303133;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.history-item-time {
font-size: 11px;
color: #909399;
}
}
.history-item-delete {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 4px;
opacity: 0;
transition: opacity 0.2s, background-color 0.2s;
cursor: pointer;
&:hover {
background-color: rgba(0, 0, 0, 0.08);
}
> i {
font-size: 12px;
color: #909399;
}
}
&:hover .history-item-delete {
opacity: 1;
}
}
.history-clear {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: #F56C6C;
}
}
body.dark-mode-reverse {
.ai-assistant-modal {
--apply-reasoning-before-bg: #4e4e56;

View File

@ -647,21 +647,53 @@ export default {
},
toggleAiSearch() {
this.aiSearch = !this.aiSearch;
//
if (this.searchKey.trim()) {
// AI
const aiSearchTypes = ['file', 'task', 'project', 'contact'];
this.searchResults = this.searchResults.filter(item => !aiSearchTypes.includes(item.type));
//
if (this.action) {
if (aiSearchTypes.includes(this.action)) {
this.distSearch(this.action);
}
} else {
aiSearchTypes.forEach(type => this.distSearch(type));
}
const keyword = this.searchKey.trim();
emitter.emit('openAIAssistant', {
sessionKey: 'ai-search',
resumeSession: 300,
title: this.$L('AI 搜索'),
value: keyword,
placeholder: this.$L('请描述你想搜索的内容...'),
loadingText: this.$L('搜索中...'),
showApplyButton: false,
autoSubmit: !!keyword,
onBeforeSend: this.handleAISearchBeforeSend,
});
this.onHide();
},
handleAISearchBeforeSend(context = []) {
const systemPrompt = [
'你是一个智能搜索助手,负责帮助用户在 DooTask 系统中搜索和整理信息。',
'你可以使用 intelligent_search 工具来搜索任务、项目、文件和联系人。',
'',
'请根据用户的搜索需求:',
'1. 调用搜索工具获取相关结果',
'2. 对搜索结果进行分类整理',
'3. 以清晰的格式呈现给用户',
'4. 如有需要,可以进行多次搜索以获取更全面的结果',
'',
'## 链接格式要求',
'在返回结果时,请使用以下格式创建可点击的链接:',
'- 任务: [任务名称](dootask://task/任务ID)',
'- 项目: [项目名称](dootask://project/项目ID)',
'- 文件: [文件名称](dootask://file/文件ID)',
'- 联系人: [联系人名称](dootask://contact/用户ID)',
'',
'示例:',
'- [完成项目报告](dootask://task/123)',
'- [产品开发项目](dootask://project/456)',
].join('\n');
const prepared = [
['system', systemPrompt]
];
if (context.length > 0) {
prepared.push(...context);
}
return prepared;
}
}
};

View File

@ -1063,6 +1063,7 @@ export default {
onProjectAI() {
emitter.emit('openAIAssistant', {
sessionKey: 'project-create',
placeholder: this.$L('请简要描述项目目标、范围或关键里程碑AI 将生成名称和任务列表'),
onBeforeSend: this.handleProjectAIBeforeSend,
onRender: this.handleProjectAIRender,

View File

@ -152,7 +152,7 @@
<em>{{$L('上传文件')}}</em>
</div>
</template>
<div class="chat-input-popover-item" @click="onToolbar('ai')">
<div v-if="dialogId > 0" class="chat-input-popover-item" @click="onToolbar('ai')">
<i class="taskfont">&#xe8a1;</i>
<em>{{$L('AI 生成')}}</em>
</div>
@ -1941,6 +1941,7 @@ export default {
return;
}
emitter.emit('openAIAssistant', {
sessionKey: 'chat-message',
placeholder: this.$L('请简要描述消息的主题、语气或要点AI 将生成完整消息'),
onBeforeSend: this.handleMessageAIBeforeSend,
onApply: this.handleMessageAIApply,

View File

@ -174,6 +174,7 @@ export default {
return;
}
emitter.emit('openAIAssistant', {
sessionKey: 'report-analysis',
placeholder: this.$L('补充你想聚焦的风险、成果或建议,留空直接生成分析'),
onBeforeSend: this.handleReportAnalysisBeforeSend,
onApply: this.handleReportAnalysisApply,

View File

@ -265,6 +265,7 @@ export default {
return;
}
emitter.emit('openAIAssistant', {
sessionKey: 'report-edit',
placeholder: this.$L('补充你想强调的重点或特殊说明AI 将在此基础上整理汇报'),
onBeforeSend: this.handleReportAIBeforeSend,
onApply: this.handleReportAIApply,

View File

@ -631,6 +631,7 @@ export default {
onAI() {
emitter.emit('openAIAssistant', {
sessionKey: 'task-create',
placeholder: this.$L('请简要描述任务目标、背景或预期交付AI 将生成标题、详细说明和子任务'),
onBeforeSend: this.handleTaskAIBeforeSend,
onRender: this.handleTaskAIRender,