diff --git a/resources/assets/js/components/AIAssistant.vue b/resources/assets/js/components/AIAssistant.vue
index 251f5c6b2..6c023c640 100644
--- a/resources/assets/js/components/AIAssistant.vue
+++ b/resources/assets/js/components/AIAssistant.vue
@@ -1,11 +1,52 @@
+
-
-
@@ -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');
+ },
},
}
@@ -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;
diff --git a/resources/assets/js/components/SearchBox.vue b/resources/assets/js/components/SearchBox.vue
index e7e188e3c..8dfac028f 100755
--- a/resources/assets/js/components/SearchBox.vue
+++ b/resources/assets/js/components/SearchBox.vue
@@ -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;
}
}
};
diff --git a/resources/assets/js/pages/manage.vue b/resources/assets/js/pages/manage.vue
index 67dd343b5..88810a680 100644
--- a/resources/assets/js/pages/manage.vue
+++ b/resources/assets/js/pages/manage.vue
@@ -1063,6 +1063,7 @@ export default {
onProjectAI() {
emitter.emit('openAIAssistant', {
+ sessionKey: 'project-create',
placeholder: this.$L('请简要描述项目目标、范围或关键里程碑,AI 将生成名称和任务列表'),
onBeforeSend: this.handleProjectAIBeforeSend,
onRender: this.handleProjectAIRender,
diff --git a/resources/assets/js/pages/manage/components/ChatInput/index.vue b/resources/assets/js/pages/manage/components/ChatInput/index.vue
index 9255335b5..9810d1c8c 100755
--- a/resources/assets/js/pages/manage/components/ChatInput/index.vue
+++ b/resources/assets/js/pages/manage/components/ChatInput/index.vue
@@ -152,7 +152,7 @@
{{$L('上传文件')}}
-