- 新增 server/work_timer.py,在对话真正空闲(无前台任务、无后台子智能体/后台命令/压缩)时持久化 work_timer,并同步内存副本,解决刷新后计时器回退问题。 - server/chat_flow_task_main.py / chat_flow.py / tasks/models.py 在任务结束/取消时按空闲判定决定是否持久化。 - 前端 lifecycle.ts 在仍有后台任务时不提前停止计时器。 - 子智能体工具(create/terminate/get_status)渲染美化,task 参数放在顶部元信息区。 - 子智能体状态查询支持返回「已完成」「已终止」「不存在」;修复 wait_for_completion 在 final_result 就绪前返回导致的「ID 被占用」误报。 - 统一后台完成通知池 poll_completion_notifications,合并子智能体与后台 run_command 两路轮询,避免逐条触发工作循环与单工作区互斥冲突。 - 删除本次新增的各类 debug_log / notify_pool_log 调用及辅助脚本。
207 lines
6.8 KiB
TypeScript
207 lines
6.8 KiB
TypeScript
// @ts-nocheck
|
|
import { debugLog } from '../common';
|
|
import { useTaskStore } from '../../../stores/task';
|
|
import {
|
|
extractSkillRefsFromMessage,
|
|
SKILL_MARKDOWN_LINK_RE,
|
|
} from './shared';
|
|
|
|
export const chatMethods = {
|
|
async clearChat() {
|
|
const confirmed = await this.confirmAction({
|
|
title: '清除对话',
|
|
message: '确定要清除所有对话记录吗?该操作不可撤销。',
|
|
confirmText: '清除',
|
|
cancelText: '取消'
|
|
});
|
|
if (confirmed) {
|
|
await this.executeSystemCommand('/clear', { showToast: false });
|
|
}
|
|
},
|
|
async compressConversation() {
|
|
if (!this.currentConversationId) {
|
|
this.uiPushToast({
|
|
title: '无法压缩',
|
|
message: '当前没有可压缩的对话。',
|
|
type: 'info'
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (this.compressing) {
|
|
return;
|
|
}
|
|
|
|
const confirmed = await this.confirmAction({
|
|
title: '压缩对话',
|
|
message: '确定要压缩当前对话记录吗?较早的消息会被折叠并生成压缩摘要,当前对话 ID 保持不变。',
|
|
confirmText: '压缩',
|
|
cancelText: '取消'
|
|
});
|
|
if (!confirmed) {
|
|
return;
|
|
}
|
|
|
|
this.compressing = true;
|
|
this.compressionInProgress = true;
|
|
this.compressionMode = 'manual';
|
|
this.compressionStage = 'requesting';
|
|
this.compressionError = '';
|
|
if (this.compressionToastId) {
|
|
this.uiDismissToast(this.compressionToastId);
|
|
this.compressionToastId = null;
|
|
}
|
|
this.compressionToastId = this.uiPushToast({
|
|
title: '压缩中',
|
|
message: '对话正在压缩,请稍候…',
|
|
type: 'info',
|
|
duration: null,
|
|
closable: false
|
|
});
|
|
|
|
try {
|
|
const response = await fetch(`/api/conversations/${this.currentConversationId}/compress`, {
|
|
method: 'POST'
|
|
});
|
|
|
|
const result = await response.json();
|
|
|
|
if (response.ok && result.success) {
|
|
this.compressionStage = 'switching';
|
|
const newId = result.compressed_conversation_id;
|
|
const isInPlace = newId && newId === this.currentConversationId;
|
|
// in-place 压缩:对话 id 不变,不重新加载(避免 resetAllStates
|
|
// 重置滚动状态 + fetchAndDisplayHistory 清空重渲染导致闪烁和锁死)。
|
|
if (newId && !isInPlace) {
|
|
await this.loadConversation(newId, { force: true });
|
|
}
|
|
const guideMessage = (result.guide_message || '').trim();
|
|
// 手动压缩只有一种行为:后端已把引导语作为 user 消息追加进历史,
|
|
// 这里刷新展示即可,不触发新一轮请求(等待用户继续发送消息才工作)。
|
|
if (newId && !isInPlace) {
|
|
await this.loadConversation(newId, { force: true });
|
|
} else if (isInPlace) {
|
|
await this.loadConversation(this.currentConversationId, { force: true });
|
|
}
|
|
void guideMessage;
|
|
|
|
if (!isInPlace) {
|
|
await this.loadConversationsList();
|
|
}
|
|
|
|
debugLog('对话压缩完成:', result);
|
|
this.uiPushToast({
|
|
title: '压缩完成',
|
|
message: '已压缩较早的对话内容',
|
|
type: 'success',
|
|
duration: 2200
|
|
});
|
|
} else {
|
|
const message = result.message || result.error || '压缩失败';
|
|
this.compressionError = message;
|
|
this.uiPushToast({
|
|
title: '压缩失败',
|
|
message,
|
|
type: 'error'
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error('压缩对话异常:', error);
|
|
this.compressionError = error.message || '请稍后重试';
|
|
this.uiPushToast({
|
|
title: '压缩对话异常',
|
|
message: error.message || '请稍后重试',
|
|
type: 'error'
|
|
});
|
|
} finally {
|
|
if (this.compressionToastId) {
|
|
this.uiDismissToast(this.compressionToastId);
|
|
this.compressionToastId = null;
|
|
}
|
|
this.compressing = false;
|
|
this.compressionInProgress = false;
|
|
this.compressionMode = '';
|
|
this.compressionStage = '';
|
|
}
|
|
},
|
|
async sendAutoUserMessage(text) {
|
|
const message = (text || '').trim();
|
|
if (!message || !this.isConnected) {
|
|
return false;
|
|
}
|
|
const quotaType = this.thinkingMode ? 'thinking' : 'fast';
|
|
if (this.isQuotaExceeded(quotaType)) {
|
|
this.showQuotaToast({ type: quotaType });
|
|
return false;
|
|
}
|
|
this.taskInProgress = true;
|
|
this.chatAddUserMessage(message, [], [], [], 'user');
|
|
this.chatStartAssistantMessage();
|
|
this.stopRequested = false;
|
|
if (typeof this.monitorShowPendingReply === 'function') {
|
|
this.monitorShowPendingReply();
|
|
}
|
|
try {
|
|
const { useTaskStore } = await import('../../../stores/task');
|
|
const taskStore = useTaskStore();
|
|
if (typeof this.clearProcessedEvents === 'function') {
|
|
this.clearProcessedEvents();
|
|
}
|
|
await taskStore.createTask(message, [], [], this.currentConversationId, {
|
|
eventHandler: (event: any) => this.handleTaskEvent(event)
|
|
});
|
|
} catch (error) {
|
|
console.error('[Message] 自动消息创建任务失败:', error);
|
|
this.uiPushToast({
|
|
title: '发送失败',
|
|
message: error?.message || '创建任务失败,请重试',
|
|
type: 'error'
|
|
});
|
|
this.streamingMessage = false;
|
|
this.taskInProgress = false;
|
|
if (typeof this.cleanupTrailingEmptyAssistantPlaceholder === 'function') {
|
|
this.cleanupTrailingEmptyAssistantPlaceholder('auto_create_task_failed');
|
|
}
|
|
if (typeof this.forceUnlockMonitor === 'function') {
|
|
this.forceUnlockMonitor('auto_create_task_failed');
|
|
}
|
|
return false;
|
|
}
|
|
if (this.autoScrollEnabled) {
|
|
this.scrollToBottom();
|
|
}
|
|
this.autoResizeInput();
|
|
setTimeout(() => {
|
|
if (this.currentConversationId) {
|
|
this.updateCurrentContextTokens();
|
|
}
|
|
}, 1000);
|
|
return true;
|
|
},
|
|
autoResizeInput() {
|
|
this.$nextTick(() => {
|
|
const textarea = this.getComposerElement('stadiumInput');
|
|
if (!textarea || !(textarea instanceof HTMLTextAreaElement)) {
|
|
return;
|
|
}
|
|
const previousHeight = textarea.offsetHeight;
|
|
textarea.style.height = 'auto';
|
|
const computedStyle = window.getComputedStyle(textarea);
|
|
const lineHeight = parseFloat(computedStyle.lineHeight || '20') || 20;
|
|
const maxHeight = lineHeight * 6;
|
|
const targetHeight = Math.min(textarea.scrollHeight, maxHeight);
|
|
this.inputSetLineCount(Math.max(1, Math.round(targetHeight / lineHeight)));
|
|
this.inputSetMultiline(targetHeight > lineHeight * 1.4);
|
|
if (Math.abs(targetHeight - previousHeight) <= 0.5) {
|
|
textarea.style.height = `${targetHeight}px`;
|
|
return;
|
|
}
|
|
textarea.style.height = `${previousHeight}px`;
|
|
void textarea.offsetHeight;
|
|
requestAnimationFrame(() => {
|
|
textarea.style.height = `${targetHeight}px`;
|
|
});
|
|
});
|
|
}
|
|
};
|