llm-agent-plugin/src/views/ChatView.ts
2026-05-30 23:47:20 +03:00

639 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* ChatView управляет пользовательским интерфейсом чата, отображает историю сообщений,
* отправляет запросы LLM-агенту и обрабатывает ответы, обновляя состояние чата и графа.
*/
import { ItemView, Notice, WorkspaceLeaf } from 'obsidian';
import { ChatPanel } from '../components/ChatPanel';
import LLMAgentPlugin from 'main';
import { Attachment } from 'src/components/models/ChatHistoryItem';
import { TemplateEngine } from '../references/TemplateEngine';
export const CHAT_VIEW_TYPE = 'llm-agent-chat-view';
import { LLM_DEFAULTS } from 'src/constants';
// ----------------------------------------------- Chat View ---------------------------------------------------------------
export class ChatView extends ItemView {
plugin: LLMAgentPlugin;
chatHistory: any[];
selectedGraphId: string | null;
currentNode: string | null;
chatContainer: HTMLDivElement;
chatPanel: ChatPanel | null;
currentGraphCustomSystemPrompt: string | null;
currentStreamingNodeId: string | null = null;
constructor(leaf: WorkspaceLeaf, plugin: LLMAgentPlugin) {
super(leaf);
this.plugin = plugin;
this.chatHistory = [];
this.selectedGraphId = null;
this.currentNode = null;
this.chatPanel = null;
this.currentGraphCustomSystemPrompt = null;
}
getViewType(): string {
return CHAT_VIEW_TYPE;
}
getDisplayText(): string {
return 'LLM Agent Chat';
}
getIcon(): string {
return 'message-circle';
}
async onOpen(): Promise<void> {
const container = this.containerEl.children[1] as HTMLElement;
container.empty();
// Создаем только контейнер для чата (без истории)
this.chatContainer = container.createDiv() as HTMLDivElement;
this.chatContainer.addClass('llm-agent-chat-container');
// Загружаем доступные модели (TODO: проверить, что здесь это нужно, что не сделали этого ранее)
// await this.plugin.fetchAvailableModels();
// Инициализируем только чат панель с передачей plugin
this.chatPanel = new ChatPanel(this.chatContainer, {
chatHistory: this.chatHistory,
onSendMessage: this.handleSendMessage.bind(this),
selectedModel: this.plugin.settings.defaultModel || LLM_DEFAULTS.MODEL,
plugin: this.plugin,
currentGraphCustomSystemPrompt: this.currentGraphCustomSystemPrompt
});
this.plugin.eventBus.on('settings-changed', this.handleSettingsChanged.bind(this));
this.plugin.eventBus.on('node-selected', this.handleNodeSelected.bind(this));
this.plugin.eventBus.on('new-chat', this.handleNewChat.bind(this));
this.plugin.eventBus.on('graph-selected', this.handleGraphSelected.bind(this));
this.plugin.eventBus.on('regenerate-message', this.handleRegenerateMessage.bind(this));
this.plugin.eventBus.on('ws-node-type-changed', this.handleNodeTypeChanged.bind(this));
this.plugin.eventBus.on('start-new-independent-chat', this.handleStartNewIndependentChat.bind(this));
this.plugin.eventBus.on('graph-custom-prompt-updated', this.handleGraphCustomPromptUpdated.bind(this));
this.plugin.eventBus.on('stop-stream-request', this.handleStopStreamRequest.bind(this)); // НОВОЕ
this.plugin.eventBus.on('stream-started', this.handleStreamStarted.bind(this));
this.plugin.eventBus.on('stream-ended', this.handleStreamEnded.bind(this));
}
async onClose(): Promise<void> {
if (this.chatPanel) {
this.chatPanel.destroy();
this.chatPanel = null;
}
this.plugin.eventBus.off('settings-changed', this.handleSettingsChanged);
this.plugin.eventBus.off('node-selected', this.handleNodeSelected);
this.plugin.eventBus.off('new-chat', this.handleNewChat);
this.plugin.eventBus.off('graph-selected', this.handleGraphSelected);
this.plugin.eventBus.off('regenerate-message', this.handleRegenerateMessage);
this.plugin.eventBus.off('ws-node-type-changed', this.handleNodeTypeChanged);
this.plugin.eventBus.off('start-new-independent-chat', this.handleStartNewIndependentChat);
this.plugin.eventBus.off('graph-custom-prompt-updated', this.handleGraphCustomPromptUpdated);
this.plugin.eventBus.off('stop-stream-request', this.handleStopStreamRequest); // НОВОЕ
this.plugin.eventBus.off('stream-started', this.handleStreamStarted);
this.plugin.eventBus.off('stream-ended', this.handleStreamEnded);
}
// ----------------------------------------------- API Methods ---------------------------------------------------------------
// TODO: проблемное место
/**
* Загружает историю сообщений от корня до указанного узла графа.
* Обновляет `chatHistory` и отображение в `chatPanel`.
* @param {string} nodeId - ID конечного узла для загрузки истории.
* @returns {Promise<void>}
*/
async fetchMessagesFromRootToNode(nodeId: string | null): Promise<void> {
try {
// Убедимся, что selectedGraphId установлен
if (!this.selectedGraphId) {
console.warn('selectedGraphId не установлен. Невозможно загрузить сообщения.');
return;
}
const url = nodeId
? `${this.plugin.settings.apiBaseUrl}/messages/${this.selectedGraphId}/${nodeId}`
: `${this.plugin.settings.apiBaseUrl}/messages/${this.selectedGraphId}/last`;
const response = await fetch(url);
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Ошибка загрузки сообщений: ${response.status} - ${errorText}`);
}
const data = await response.json();
this.chatHistory =
data.map((msg: any) => ({
...msg,
type: msg.type || msg.role // Используем type, если есть, иначе role как fallback
})) || [];
// Уточнение, чтобы после выбора графа мы создавали узлы с не-пустым родителем
if (this.chatHistory.length > 0) {
// Устанавливаем currentNode на ID последнего сообщения в загруженной истории
this.currentNode = this.chatHistory[this.chatHistory.length - 1].node_id;
} else {
this.currentNode = null; // Если история пуста, current_node_id также null
}
if (this.chatPanel) {
await this.chatPanel.updateHistory(this.chatHistory);
}
} catch (error) {
console.error('Ошибка загрузки сообщений:', error);
// Optionally: show a notice to the user
// new Notice(`Ошибка загрузки сообщений: ${error.message}`);
/* Спорно, пока закомментил: this.chatHistory = []; // Очищаем историю при ошибке
this.currentNode = null; // Сбрасываем currentNode при ошибке
if (this.chatPanel) {
await this.chatPanel.updateHistory(this.chatHistory);
}*/
}
}
// ----------------------------------------------- Event Handlers ---------------------------------------------------------------
async handleSendMessage(message: string, selectedModel?: string, attachments?: Attachment[], customMaxTokens?: number, customTemperature?: number, agencyMode: boolean = false): Promise<void> {
try {
// ЗАЩИТА: Если граф не выбран, создаем его на лету, чтобы не потерять текст
if (!this.selectedGraphId) {
const createResponse = await fetch(`${this.plugin.settings.apiBaseUrl}/graphs/new`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
if (!createResponse.ok) {
throw new Error(`Ошибка автоматического создания графа: ${createResponse.status}`);
}
const createResult = await createResponse.json();
this.selectedGraphId = createResult.graph_id;
// Уведомляем другие компоненты о том, что выбран новый граф
this.plugin.eventBus.emit('graph-selected', {
graphId: this.selectedGraphId,
currentNode: null
});
}
const cacheFolder = this.getCacheFolderPath();
// Шаг 1: Создаём узел пользователя с вложениями
const sendResponse = await fetch(`${this.plugin.settings.apiBaseUrl}/chat/send`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message: message,
graph_id: this.selectedGraphId,
parent_node_id: this.currentNode,
attachments: attachments || [],
cache_folder: cacheFolder
})
});
if (!sendResponse.ok) {
throw new Error(`Ошибка при отправке сообщения: ${sendResponse.status}`);
}
const sendResult = await sendResponse.json();
const userNodeId = sendResult.node_id;
this.plugin.eventBus.emit('graph-selected', {
graphId: this.selectedGraphId,
currentNode: userNodeId
});
// Шаг 2: Запускаем стриминг ответа
if (this.selectedGraphId) {
const systemPromptToUse =
this.currentGraphCustomSystemPrompt !== null ? this.currentGraphCustomSystemPrompt : this.plugin.settings.systemPrompt;
await this.streamLLMResponse(this.selectedGraphId!, userNodeId, selectedModel, null, systemPromptToUse, cacheFolder, customMaxTokens, customTemperature, agencyMode);
} else {
throw new Error('Graph ID не определён после отправки сообщения');
}
} catch (error: any) {
console.error('Ошибка отправки сообщения:', error);
new Notice(`Ошибка: ${error?.message || error}`, 5000);
}
}
handleSettingsChanged(): void {
if (this.chatPanel)
{
// 1. Обновляем модель
this.chatPanel.setSelectedModel(this.plugin.settings.defaultModel);
// 2. Обновляем состояние тумблера из настроек плагина
if (this.chatPanel.agencyToggle) {
this.chatPanel.agencyToggle.checked = !!this.plugin.settings.agencyModeEnabled;
}
// 3. Уведомляем панель об обновлении всего объекта плагина (и настроек внутри)
this.chatPanel.updateSettings(this.plugin);
}
}
/**
* Обрабатывает выбор узла на графе. Загружает историю сообщений до выбранного узла
* и обновляет панель чата.
* @param {{ nodeId: string }} data - Объект, содержащий ID выбранного узла.
*/
handleNodeSelected(data: { nodeId: string }): void {
this.currentNode = data.nodeId;
if (this.selectedGraphId) {
this.fetchMessagesFromRootToNode(data.nodeId).then(() => {
// После загрузки новой ветки проверяем, есть ли стриминг в ней
if (this.currentStreamingNodeId) {
const isInNewBranch = this.chatHistory.some((msg) => msg.node_id === this.currentStreamingNodeId);
if (this.chatPanel) {
this.chatPanel.updateStopButtonVisibility(isInNewBranch, isInNewBranch ? this.currentStreamingNodeId : null);
}
}
});
}
}
/**
* Обрабатывает событие начала нового чата. Сбрасывает текущее состояние чата
* и очищает отображение истории.
*/
async handleNewChat(): Promise<void> {
this.chatHistory = [];
this.currentNode = null;
//this.selectedGraphId = null;
//this.currentGraphCustomSystemPrompt = null;
this.currentGraphCustomSystemPrompt = null;
if (this.chatPanel) {
await this.chatPanel.updateHistory(this.chatHistory);
this.chatPanel.updateCustomSystemPromptStatus(null);
}
}
/**
* Обрабатывает выбор графа из `GraphView`. Устанавливает выбранный граф и обновляет
*/
async handleGraphSelected(data: { graphId: string; currentNode: string | null }): Promise<void> {
this.selectedGraphId = data.graphId;
this.currentNode = data.currentNode;
// Важно: custom_system_prompt будет обновлен через 'graph-custom-prompt-updated' событие,
// которое генерируется в GraphView после fetchGraphData.
// Здесь мы пока не трогаем this.currentGraphCustomSystemPrompt напрямую,
// но ожидаем, что GraphView его обновит.
await this.fetchMessagesFromRootToNode(data.currentNode);
}
/**
* Стримит ответ LLM.
*/
async streamLLMResponse(
graphId: string,
userNodeId: string,
selectedModel?: string,
existingAssistantNodeId: string | null = null,
systemPrompt: string | null = null,
cacheFolder?: string,
customMaxTokens?: number,
customTemperature?: number,
agencyMode: boolean = false
): Promise<void> {
// Разворачиваем системный промпт
let finalSystemPrompt = systemPrompt;
if (systemPrompt) {
const templateEngine = new TemplateEngine(this.plugin);
finalSystemPrompt = await templateEngine.processRawSettingValue(systemPrompt);
}
const temperature = customTemperature !== undefined
? customTemperature
: ((this.plugin.settings as any).temperatureChat !== undefined ? (this.plugin.settings as any).temperatureChat : LLM_DEFAULTS.TEMPERATURE);
const body = {
graph_id: graphId,
user_node_id: userNodeId,
system_prompt: finalSystemPrompt,
model: selectedModel || this.plugin.settings.defaultModel,
cache_folder: cacheFolder,
temperature: temperature,
max_tokens: customMaxTokens,
agency_mode: agencyMode
};
if (existingAssistantNodeId) {
Object.assign(body, { existing_assistant_node_id: existingAssistantNodeId });
}
const abortController = new AbortController();
let assistantNodeId: string | null = null;
let reader: ReadableStreamDefaultReader<Uint8Array> | null = null;
try {
const response = await fetch(`${this.plugin.settings.apiBaseUrl}/chat/stream`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: abortController.signal
});
if (!response.ok) {
throw new Error(`Ошибка стриминга: ${response.status}`);
}
if (!response.body) {
throw new Error('Response body is null');
}
reader = response.body.getReader();
const decoder = new TextDecoder();
let accumulatedContent = '';
while (true) {
if (abortController.signal.aborted) {
break;
}
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
const lines = chunk.split('\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = JSON.parse(line.slice(6));
if (data.type === 'node_created') {
assistantNodeId = data.node_id;
this.plugin.eventBus.emit('stream-started', {
nodeId: assistantNodeId,
controller: abortController
});
if (data.reused) {
this.clearStreamingMessage(assistantNodeId!);
}
this.plugin.eventBus.emit('graph-selected', {
graphId: graphId,
currentNode: assistantNodeId
});
}
else if (data.type === 'tool_start' && assistantNodeId) {
if (this.chatPanel) this.chatPanel.toolExecutionUpdate(assistantNodeId, data.name, 'start', data.step);
} else if (data.type === 'tool_end' && assistantNodeId) {
if (this.chatPanel) this.chatPanel.toolExecutionUpdate(assistantNodeId, data.name, 'end', data.step);
} else if (data.type === 'chunk' && assistantNodeId) {
accumulatedContent += data.content;
this.updateStreamingMessage(assistantNodeId, accumulatedContent);
} else if (data.type === 'error') {
console.error('Ошибка:', data.error);
if (assistantNodeId) {
this.markNodeAsError(assistantNodeId, data.error);
this.plugin.eventBus.emit('graph-selected', {
graphId: graphId,
currentNode: assistantNodeId
});
this.plugin.eventBus.emit('stream-ended', { nodeId: assistantNodeId });
}
break;
} else if (data.type === 'done') {
this.currentNode = assistantNodeId;
this.plugin.eventBus.emit('graph-selected', {
graphId: graphId,
currentNode: assistantNodeId
});
if (assistantNodeId) {
this.plugin.eventBus.emit('stream-ended', { nodeId: assistantNodeId });
}
break;
}
}
}
}
} catch (error: any) {
if (error.name === 'AbortError') {
console.log('Стрим был отменен пользователем');
if (assistantNodeId) {
this.plugin.eventBus.emit('stream-ended', { nodeId: assistantNodeId });
}
} else {
throw error; // Пробрасываем другие ошибки
}
} finally {
reader?.releaseLock();
if (assistantNodeId) {
this.plugin.eventBus.emit('stream-ended', { nodeId: assistantNodeId });
}
}
}
/**
* Очищает содержимое сообщения в истории чата, помечая его как заглушку.
* Используется для подготовки к регенерации.
* @param {string} nodeId - ID узла, содержимое которого нужно очистить.
*/
clearStreamingMessage(nodeId: string): void {
const message = this.chatHistory.find((msg) => msg.node_id === nodeId);
if (message) {
message.content = 'Получение ответа...';
message.type = 'llm';
// Точечное обновление вместо полного рендера всей панели
if (this.chatPanel) {
this.chatPanel.streamUpdate(nodeId, message.content);
}
}
}
updateStreamingMessage(nodeId: string, content: string): void {
// Надежный поиск по ID, а не по длине массива (предотвращает создание дублей)
let message = this.chatHistory.find((msg) => msg.node_id === nodeId);
if (message) {
message.content = content;
// Отправляем точечное обновление в панель чата
if (this.chatPanel) {
this.chatPanel.streamUpdate(nodeId, content);
}
} else {
// Если узла еще нет (первый чанк стрима), добавляем его ОДИН РАЗ
this.chatHistory.push({
role: 'assistant',
content: content,
node_id: nodeId,
graph_id: this.selectedGraphId || ''
});
// При самом первом появлении отрисовываем штатным способом (создается HTML-контейнер)
if (this.chatPanel) {
this.chatPanel.updateHistory(this.chatHistory);
}
}
}
markNodeAsError(nodeId: string, errorMessage: string): void {
const lastMessage = this.chatHistory[this.chatHistory.length - 1];
if (lastMessage && lastMessage.node_id === nodeId) {
lastMessage.type = 'error';
lastMessage.content = errorMessage;
} else {
// Если узел не последний, попробуйте найти его и обновить
const messageIndex = this.chatHistory.findIndex((msg) => msg.node_id === nodeId);
if (messageIndex !== -1) {
this.chatHistory[messageIndex].type = 'error';
this.chatHistory[messageIndex].content = errorMessage;
} else {
// Если узел не найден, добавьте новое сообщение об ошибке
this.chatHistory.push({
role: 'assistant',
content: errorMessage,
node_id: nodeId,
graph_id: this.selectedGraphId || '',
type: 'error'
});
}
}
if (this.chatPanel) {
this.chatPanel.updateHistory(this.chatHistory);
}
}
async handleRegenerateMessage(data: { graphId: string; nodeId: string; model: string, maxTokens?: number, temperature?: number, agencyMode?: boolean }): Promise<void> {
try {
const systemPromptToUse =
this.currentGraphCustomSystemPrompt !== null ? this.currentGraphCustomSystemPrompt : this.plugin.settings.systemPrompt;
const cacheFolder = this.getCacheFolderPath();
const temperature = data.temperature !== undefined
? data.temperature
: ((this.plugin.settings as any).temperatureChat !== undefined ? (this.plugin.settings as any).temperatureChat : LLM_DEFAULTS.TEMPERATURE);
const response = await fetch(`${this.plugin.settings.apiBaseUrl}/chat/regenerate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
graph_id: data.graphId,
node_id: data.nodeId,
model: data.model,
system_prompt: systemPromptToUse,
cache_folder: cacheFolder,
temperature: temperature,
max_tokens: data.maxTokens
})
});
if (!response.ok) {
throw new Error(`Ошибка регенерации: ${response.status}`);
}
const result = await response.json();
const existingAssistantNodeId = result.existing_assistant_node_id;
const userNodeId = result.user_node_id;
const modelToUse = result.model;
const systemPromptFromBackend = result.system_prompt;
const maxTokensFromBackend = result.max_tokens;
const temperatureFromBackend = result.temperature;
if (data.graphId) {
await this.streamLLMResponse(data.graphId, userNodeId, modelToUse, existingAssistantNodeId, systemPromptFromBackend,
cacheFolder, maxTokensFromBackend, temperatureFromBackend, data.agencyMode || false);
}
} catch (error: any) {
console.error('Ошибка регенерации:', error);
new Notice(`Ошибка регенерации: ${error?.message || error}`, 5000);
}
}
/**
* Обрабатывает событие начала нового независимого чата.
* Сбрасывает текущее состояние ChatView, но не влияет на GraphView.
*/
async handleStartNewIndependentChat(): Promise<void> {
this.chatHistory = [];
this.currentNode = null;
// Оставляем this.selectedGraphId без изменений
if (this.chatPanel) {
await this.chatPanel.updateHistory(this.chatHistory);
this.chatPanel.updateCustomSystemPromptStatus(null);
}
// Опционально: можно уведомить пользователя о начале нового чата
new Notice('Начат новый чат. Текущий граф не изменен.', 2000);
}
handleStopStreamRequest(data: { nodeId: string }): void {
const controller = (this.plugin as any).activeStreamControllers?.get(data.nodeId);
if (controller) {
controller.abort();
new Notice('Генерация остановлена пользователем.', 2000);
}
}
handleStreamStarted(data: { nodeId: string; controller: AbortController }): void {
this.currentStreamingNodeId = data.nodeId;
// TODO: Временно не проверяем, что блок сообщения уже отрисован в панели,
// т.к. у нас пока к моменту вызова 'stream-started' сообщение ещё не отрисовано
// const isInCurrentBranch = this.chatHistory.some((msg) => msg.node_id === data.nodeId);
// if (this.chatPanel && isInCurrentBranch) {
if (this.chatPanel) {
this.chatPanel.updateStopButtonVisibility(true, data.nodeId);
}
}
handleStreamEnded(data: { nodeId: string }): void {
if (this.currentStreamingNodeId === data.nodeId) {
this.currentStreamingNodeId = null;
if (this.chatPanel) {
this.chatPanel.updateStopButtonVisibility(false, null);
}
}
}
// ----------------------------------------------- WebSocket Event Handlers ---------------------------------------------------------------
handleNodeTypeChanged(data: { graphId: string; nodeId: string; nodeType: string }): void {
// Обновляем тип узла в истории чата
const messageIndex = this.chatHistory.findIndex((msg) => msg.node_id === data.nodeId);
if (messageIndex !== -1) {
this.chatHistory[messageIndex].type = data.nodeType;
if (this.chatPanel) {
this.chatPanel.updateHistory(this.chatHistory);
}
}
}
handleGraphCustomPromptUpdated(data: { graph_id: string; custom_system_prompt: string | null }): void {
if (this.selectedGraphId === data.graph_id) {
this.currentGraphCustomSystemPrompt = data.custom_system_prompt;
if (this.chatPanel) {
this.chatPanel.updateCustomSystemPromptStatus(this.currentGraphCustomSystemPrompt);
}
} else if (this.selectedGraphId === null && data.graph_id === null) {
// Сброс промпта, если граф не выбран
this.currentGraphCustomSystemPrompt = null;
if (this.chatPanel) {
this.chatPanel.updateCustomSystemPromptStatus(null);
}
}
}
// ----------------------------------------------- Helpers ---------------------------------------------------------------
/**
* Возвращает абсолютный путь к папке кеша
*/
private getCacheFolderPath(): string {
// TODO: Нужно обработать случай, когда cacheFolder пустой
return this.chatPanel?.cacheManager?.getCacheFolderPath() ?? '';
}
}