616 lines
19 KiB
TypeScript
616 lines
19 KiB
TypeScript
// @ts-nocheck
|
||
import { debugLog } from './common';
|
||
import { useModelStore } from '../../stores/model';
|
||
|
||
export const messageMethods = {
|
||
async executeSystemCommand(rawCommand, options = {}) {
|
||
const command = (rawCommand || '').toString().trim();
|
||
if (!command) {
|
||
return { success: false, message: '命令不能为空' };
|
||
}
|
||
|
||
if (!this.isConnected) {
|
||
if (options.showToast !== false) {
|
||
this.uiPushToast({
|
||
title: '连接不可用',
|
||
message: '当前无法执行命令,请稍后重试。',
|
||
type: 'error'
|
||
});
|
||
}
|
||
return { success: false, message: '连接不可用' };
|
||
}
|
||
|
||
try {
|
||
const response = await fetch('/api/commands', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({ command })
|
||
});
|
||
|
||
const payload = await response.json().catch(() => ({}));
|
||
const result = {
|
||
command: payload.command || command.replace(/^\//, ''),
|
||
success: !!payload.success,
|
||
message: payload.message,
|
||
data: payload.data
|
||
};
|
||
this.handleSystemCommandResult(result, options);
|
||
return result;
|
||
} catch (error) {
|
||
const message = error instanceof Error ? error.message : '命令执行失败';
|
||
const result = {
|
||
command: command.replace(/^\//, ''),
|
||
success: false,
|
||
message
|
||
};
|
||
this.handleSystemCommandResult(result, options);
|
||
return result;
|
||
}
|
||
},
|
||
|
||
handleSystemCommandResult(data, options = {}) {
|
||
const showToast = options.showToast !== false;
|
||
if (data.command === 'clear' && data.success) {
|
||
this.logMessageState?.('command_result-clear', { data });
|
||
this.messages = [];
|
||
this.logMessageState?.('command_result-cleared', { data });
|
||
this.currentMessageIndex = -1;
|
||
this.chatClearExpandedBlocks();
|
||
this.resetTokenStatistics();
|
||
if (showToast) {
|
||
this.uiPushToast({
|
||
title: '已清除',
|
||
message: data.message || '对话已清除',
|
||
type: 'success'
|
||
});
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (data.command === 'status' && data.success) {
|
||
this.addSystemMessage(`系统状态:\n${JSON.stringify(data.data || {}, null, 2)}`);
|
||
if (showToast) {
|
||
this.uiPushToast({
|
||
title: '状态已更新',
|
||
message: '已获取系统状态',
|
||
type: 'success'
|
||
});
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (!data.success) {
|
||
this.addSystemMessage(`命令失败: ${data.message || '未知错误'}`);
|
||
if (showToast) {
|
||
this.uiPushToast({
|
||
title: '命令执行失败',
|
||
message: data.message || '请稍后重试',
|
||
type: 'error'
|
||
});
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (showToast) {
|
||
this.uiPushToast({
|
||
title: '命令已执行',
|
||
message: data.command || '完成',
|
||
type: 'success'
|
||
});
|
||
}
|
||
},
|
||
|
||
handleSendOrStop() {
|
||
if (this.compressionInProgress) {
|
||
this.uiPushToast({
|
||
title: '对话自动压缩中',
|
||
message: '当前不可发送/停止,请等待压缩完成',
|
||
type: 'warning'
|
||
});
|
||
return;
|
||
}
|
||
if (this.composerBusy) {
|
||
this.stopTask();
|
||
} else {
|
||
this.sendMessage();
|
||
}
|
||
},
|
||
|
||
async sendMessage() {
|
||
console.log('[DEBUG_AWAITING] ===== sendMessage =====');
|
||
|
||
if (this.compressionInProgress) {
|
||
this.uiPushToast({
|
||
title: '对话自动压缩中',
|
||
message: '压缩完成后才能继续发送消息',
|
||
type: 'warning'
|
||
});
|
||
return;
|
||
}
|
||
if (this.streamingUi) {
|
||
return;
|
||
}
|
||
if (this.mediaUploading) {
|
||
this.uiPushToast({
|
||
title: '上传中',
|
||
message: '请等待图片/视频上传完成后再发送',
|
||
type: 'info'
|
||
});
|
||
return;
|
||
}
|
||
|
||
const text = (this.inputMessage || '').trim();
|
||
const images = Array.isArray(this.selectedImages) ? this.selectedImages.slice(0, 9) : [];
|
||
const videos = Array.isArray(this.selectedVideos) ? this.selectedVideos.slice(0, 1) : [];
|
||
const hasText = text.length > 0;
|
||
const hasImages = images.length > 0;
|
||
const hasVideos = videos.length > 0;
|
||
|
||
if (!hasText && !hasImages && !hasVideos) {
|
||
return;
|
||
}
|
||
|
||
const quotaType = this.thinkingMode ? 'thinking' : 'fast';
|
||
if (this.isQuotaExceeded(quotaType)) {
|
||
this.showQuotaToast({ type: quotaType });
|
||
return;
|
||
}
|
||
|
||
const modelStore = useModelStore();
|
||
const currentModel = modelStore.models.find((m) => m.key === this.currentModelKey);
|
||
if (hasImages && !currentModel?.supportsImage) {
|
||
this.uiPushToast({
|
||
title: '当前模型不支持图片',
|
||
message: '请切换到支持图片输入的模型再发送图片',
|
||
type: 'error'
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (hasVideos && !currentModel?.supportsVideo) {
|
||
this.uiPushToast({
|
||
title: '当前模型不支持视频',
|
||
message: '请切换到支持视频输入的模型后再发送视频',
|
||
type: 'error'
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (hasVideos && hasImages) {
|
||
this.uiPushToast({
|
||
title: '请勿同时发送',
|
||
message: '视频与图片需分开发送,每条仅包含一种媒体',
|
||
type: 'warning'
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (hasVideos) {
|
||
this.uiPushToast({
|
||
title: '视频处理中',
|
||
message: '读取视频需要较长时间,请耐心等待',
|
||
type: 'info',
|
||
duration: 5000
|
||
});
|
||
}
|
||
|
||
const message = text;
|
||
|
||
const wasBlank = this.isConversationBlank();
|
||
if (wasBlank) {
|
||
this.blankHeroExiting = true;
|
||
this.blankHeroActive = true;
|
||
setTimeout(() => {
|
||
this.blankHeroExiting = false;
|
||
this.blankHeroActive = false;
|
||
}, 320);
|
||
}
|
||
|
||
let targetConversationId = this.currentConversationId;
|
||
if (!targetConversationId) {
|
||
try {
|
||
const createResp = await fetch('/api/conversations', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({
|
||
thinking_mode: this.thinkingMode,
|
||
mode: this.runMode
|
||
})
|
||
});
|
||
const createResult = await createResp.json().catch(() => ({}));
|
||
if (!createResp.ok || !createResult?.success || !createResult?.conversation_id) {
|
||
throw new Error(createResult?.message || createResult?.error || '创建对话失败');
|
||
}
|
||
targetConversationId = createResult.conversation_id;
|
||
this.skipConversationHistoryReload = true;
|
||
this.currentConversationId = targetConversationId;
|
||
this.currentConversationTitle = '新对话';
|
||
this.conversations = [
|
||
{
|
||
id: targetConversationId,
|
||
title: '新对话',
|
||
updated_at: new Date().toISOString(),
|
||
total_messages: 0,
|
||
total_tools: 0
|
||
},
|
||
...this.conversations.filter((conv) => conv && conv.id !== targetConversationId)
|
||
];
|
||
const pathFragment = this.stripConversationPrefix(targetConversationId);
|
||
history.replaceState({ conversationId: targetConversationId }, '', `/${pathFragment}`);
|
||
await this.applyPendingVersioningToConversation(targetConversationId);
|
||
} catch (error) {
|
||
this.uiPushToast({
|
||
title: '发送失败',
|
||
message: error?.message || '创建新对话失败,请重试',
|
||
type: 'error'
|
||
});
|
||
return;
|
||
}
|
||
}
|
||
|
||
// 标记任务进行中,直到任务完成或用户手动停止
|
||
this.taskInProgress = true;
|
||
this.chatAddUserMessage(message, images, videos);
|
||
|
||
// 使用 REST API 创建任务(轮询模式)
|
||
try {
|
||
const { useTaskStore } = await import('../../stores/task');
|
||
const taskStore = useTaskStore();
|
||
if (typeof this.clearProcessedEvents === 'function') {
|
||
this.clearProcessedEvents();
|
||
}
|
||
|
||
await taskStore.createTask(message, images, videos, targetConversationId, {
|
||
model_key: this.currentModelKey,
|
||
run_mode: this.runMode,
|
||
thinking_mode: this.thinkingMode
|
||
});
|
||
|
||
debugLog('[Message] 任务已创建,开始轮询');
|
||
} catch (error) {
|
||
console.error('[Message] 创建任务失败:', error);
|
||
this.uiPushToast({
|
||
title: '发送失败',
|
||
message: error.message || '创建任务失败,请重试',
|
||
type: 'error'
|
||
});
|
||
this.taskInProgress = false;
|
||
return;
|
||
}
|
||
|
||
if (typeof this.monitorShowPendingReply === 'function') {
|
||
this.monitorShowPendingReply();
|
||
}
|
||
this.inputClearMessage();
|
||
this.inputClearSelectedImages();
|
||
this.inputClearSelectedVideos();
|
||
this.inputSetImagePickerOpen(false);
|
||
this.inputSetVideoPickerOpen(false);
|
||
this.inputSetLineCount(1);
|
||
this.inputSetMultiline(false);
|
||
if (hasImages) {
|
||
this.conversationHasImages = true;
|
||
this.conversationHasVideos = false;
|
||
}
|
||
if (hasVideos) {
|
||
this.conversationHasVideos = true;
|
||
this.conversationHasImages = false;
|
||
}
|
||
if (this.autoScrollEnabled) {
|
||
this.scrollToBottom();
|
||
}
|
||
this.autoResizeInput();
|
||
|
||
// 发送消息后延迟更新当前上下文Token(关键修复:恢复原逻辑)
|
||
setTimeout(() => {
|
||
if (this.currentConversationId) {
|
||
this.updateCurrentContextTokens();
|
||
}
|
||
}, 1000);
|
||
},
|
||
|
||
// 新增:停止任务方法
|
||
async stopTask() {
|
||
console.log('[DEBUG_AWAITING] ===== stopTask =====');
|
||
if (this.compressionInProgress) {
|
||
this.uiPushToast({
|
||
title: '对话自动压缩中',
|
||
message: '压缩进行中,当前不可停止任务',
|
||
type: 'warning'
|
||
});
|
||
return;
|
||
}
|
||
|
||
const canStop = this.composerBusy && !this.stopRequested;
|
||
if (!canStop) {
|
||
return;
|
||
}
|
||
|
||
const shouldDropToolEvents = this.streamingUi;
|
||
this.stopRequested = true;
|
||
this.dropToolEvents = shouldDropToolEvents;
|
||
|
||
try {
|
||
const { useTaskStore } = await import('../../stores/task');
|
||
const taskStore = useTaskStore();
|
||
|
||
if (taskStore.currentTaskId) {
|
||
await taskStore.cancelTask();
|
||
}
|
||
|
||
// 等待后端确认
|
||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||
const shouldKeepBusy =
|
||
Boolean(taskStore.currentTaskId) &&
|
||
['running', 'pending', 'cancel_requested'].includes(String(taskStore.taskStatus));
|
||
|
||
// 清理前端状态
|
||
this.clearPendingTools('user_stop');
|
||
this.streamingMessage = false;
|
||
// 若后台已回传停止事件,不要再次把输入区锁回“停止中”
|
||
this.taskInProgress = shouldKeepBusy;
|
||
this.forceUnlockMonitor('user_stop');
|
||
if (typeof this.clearProcessedEvents === 'function') {
|
||
this.clearProcessedEvents();
|
||
}
|
||
|
||
// 清理assistant消息的等待动画状态
|
||
const lastMessage = this.messages[this.messages.length - 1];
|
||
const before = {
|
||
hasLastMessage: !!lastMessage,
|
||
role: lastMessage?.role,
|
||
awaitingFirstContent: lastMessage?.awaitingFirstContent,
|
||
generatingLabel: lastMessage?.generatingLabel
|
||
};
|
||
|
||
if (lastMessage && lastMessage.role === 'assistant') {
|
||
lastMessage.awaitingFirstContent = false;
|
||
lastMessage.generatingLabel = '';
|
||
}
|
||
|
||
console.log('[DEBUG_AWAITING] stopTask 清理完成', {
|
||
before,
|
||
after: {
|
||
awaitingFirstContent: lastMessage?.awaitingFirstContent,
|
||
generatingLabel: lastMessage?.generatingLabel
|
||
}
|
||
});
|
||
} catch (error) {
|
||
console.error('[Message] 取消任务失败:', error);
|
||
const { useTaskStore } = await import('../../stores/task');
|
||
const taskStore = useTaskStore();
|
||
const shouldKeepBusy =
|
||
Boolean(taskStore.currentTaskId) &&
|
||
['running', 'pending', 'cancel_requested'].includes(String(taskStore.taskStatus));
|
||
|
||
// 即使失败也清理状态
|
||
this.clearPendingTools('user_stop');
|
||
this.streamingMessage = false;
|
||
// 如果任务其实已结束,允许按钮恢复发送态
|
||
this.taskInProgress = shouldKeepBusy;
|
||
this.forceUnlockMonitor('user_stop');
|
||
if (typeof this.clearProcessedEvents === 'function') {
|
||
this.clearProcessedEvents();
|
||
}
|
||
|
||
// 清理assistant消息的等待动画状态
|
||
const lastMessage = this.messages[this.messages.length - 1];
|
||
if (lastMessage && lastMessage.role === 'assistant') {
|
||
lastMessage.awaitingFirstContent = false;
|
||
lastMessage.generatingLabel = '';
|
||
}
|
||
|
||
this.uiPushToast({
|
||
title: '停止失败',
|
||
message: '任务可能仍在运行,请刷新页面',
|
||
type: 'warning'
|
||
});
|
||
} finally {
|
||
// 确保清除 dropToolEvents 和 stopRequested 标志
|
||
this.dropToolEvents = false;
|
||
this.stopRequested = false;
|
||
}
|
||
},
|
||
|
||
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: '确定要压缩当前对话记录吗?压缩后会生成新的对话副本。',
|
||
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;
|
||
if (newId) {
|
||
await this.loadConversation(newId, { force: true });
|
||
}
|
||
const guideMessage = (result.guide_message || '').trim();
|
||
const autoTaskStarted = !!result.auto_task_started;
|
||
const autoTaskId = result.auto_task_id;
|
||
if (autoTaskStarted && autoTaskId) {
|
||
const { useTaskStore } = await import('../../stores/task');
|
||
const taskStore = useTaskStore();
|
||
if (typeof this.clearProcessedEvents === 'function') {
|
||
this.clearProcessedEvents();
|
||
}
|
||
taskStore.resumeTask(autoTaskId, {
|
||
status: result.auto_task_status || 'running',
|
||
resetOffset: true,
|
||
eventHandler: (event) => this.handleTaskEvent(event)
|
||
});
|
||
this.taskInProgress = true;
|
||
if (typeof this.monitorShowPendingReply === 'function') {
|
||
this.monitorShowPendingReply();
|
||
}
|
||
} else if (guideMessage) {
|
||
await this.sendAutoUserMessage(guideMessage);
|
||
}
|
||
|
||
this.conversationsOffset = 0;
|
||
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, []);
|
||
try {
|
||
const { useTaskStore } = await import('../../stores/task');
|
||
const taskStore = useTaskStore();
|
||
if (typeof this.clearProcessedEvents === 'function') {
|
||
this.clearProcessedEvents();
|
||
}
|
||
await taskStore.createTask(message, [], [], this.currentConversationId);
|
||
} catch (error) {
|
||
console.error('[Message] 自动消息创建任务失败:', error);
|
||
this.uiPushToast({
|
||
title: '发送失败',
|
||
message: error?.message || '创建任务失败,请重试',
|
||
type: 'error'
|
||
});
|
||
this.taskInProgress = false;
|
||
return false;
|
||
}
|
||
if (typeof this.monitorShowPendingReply === 'function') {
|
||
this.monitorShowPendingReply();
|
||
}
|
||
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`;
|
||
});
|
||
});
|
||
}
|
||
};
|