Add stop-generation button

This commit is contained in:
dimitrievgs 2025-10-19 21:33:01 +03:00
parent e39be5259c
commit 220e6cd2ad
7 changed files with 369 additions and 138 deletions

147
main.js

File diff suppressed because one or more lines are too long

19
main.ts
View File

@ -34,6 +34,7 @@ export default class LLMAgentPlugin extends Plugin {
settings: LLMAgentSettings;
eventBus: EventBus;
webSocketService: WebSocketService;
activeStreamControllers: Map<string, AbortController>;
async onload() {
await this.loadSettings();
@ -74,6 +75,11 @@ export default class LLMAgentPlugin extends Plugin {
console.log('LLM Agent plugin загружен');
this.activeStreamControllers = new Map<string, AbortController>();
this.eventBus.on('stream-started', this.handleStreamStarted.bind(this));
this.eventBus.on('stream-ended', this.handleStreamEnded.bind(this));
// This adds a settings tab so the user can configure various aspects of the plugin
this.addSettingTab(new SampleSettingTab(this.app, this));
}
@ -83,6 +89,11 @@ export default class LLMAgentPlugin extends Plugin {
this.webSocketService.disconnect();
}
this.activeStreamControllers.clear();
this.eventBus.off('stream-started', this.handleStreamStarted);
this.eventBus.off('stream-ended', this.handleStreamEnded);
console.log('LLM Agent plugin выгружен');
}
@ -130,6 +141,14 @@ export default class LLMAgentPlugin extends Plugin {
handleNewChat() {
this.eventBus.emit('new-chat');
}
handleStreamStarted(data: { nodeId: string; controller: AbortController }): void {
this.activeStreamControllers.set(data.nodeId, data.controller);
}
handleStreamEnded(data: { nodeId: string }): void {
this.activeStreamControllers.delete(data.nodeId);
}
}
// ----------------------------------------------- Sample Setting Tab ---------------------------------------------------------------

View File

@ -15,16 +15,18 @@ import LLMAgentPlugin from 'main';
import { MarkdownRenderer, setIcon, Notice } from 'obsidian'; // Добавляем импорт MarkdownRenderer
import { Dialog } from 'src/utils/Dialog';
interface ChatPanelProps {
chatHistory: ChatHistoryItem[];
onSendMessage: (message: string, selectedModel?: string) => void;
availableModels?: string[];
selectedModel?: string;
plugin: LLMAgentPlugin;
currentGraphCustomSystemPrompt: string | null;
}
export class ChatPanel {
container: HTMLElement;
props: {
chatHistory: ChatHistoryItem[];
onSendMessage: (message: string, selectedModel?: string) => void;
availableModels?: string[];
selectedModel?: string;
plugin: LLMAgentPlugin;
currentGraphCustomSystemPrompt: string | null;
};
props: ChatPanelProps;
chatHistory: ChatHistoryItem[];
messageInput: HTMLDivElement;
sendButton: HTMLButtonElement;
@ -33,6 +35,8 @@ export class ChatPanel {
modelDropdown: HTMLDivElement;
selectedModel: string;
customPromptIndicator: HTMLSpanElement | null = null;
currentStreamingNodeId: string | null;
stopButton: HTMLButtonElement;
// ----------------------------------------------- Reference System Fields ---------------------------------------------------------------
private autocompleteManager: AutocompleteManager;
@ -42,17 +46,7 @@ export class ChatPanel {
private currentReferences: FileReference[] = [];
private isProcessingReference = false;
constructor(
container: HTMLElement,
props: {
chatHistory: ChatHistoryItem[];
onSendMessage: (message: string, selectedModel?: string) => void;
availableModels?: string[];
selectedModel?: string;
plugin: LLMAgentPlugin;
currentGraphCustomSystemPrompt: string | null;
}
) {
constructor(container: HTMLElement, props: ChatPanelProps) {
this.container = container;
this.props = props;
this.chatHistory = props.chatHistory || [];
@ -331,7 +325,10 @@ export class ChatPanel {
</div>
</div>
<div class="control-buttons-right">
<button class="submit-button" id="send-button" data-tooltip="Enter">
<button class="stop-button" id="stop-button" style="display: none;">
<span class="stop-icon"></span>
</button>
<button class="submit-button" id="send-button">
<span class="submit-text"> Enter</span>
</button>
</div>
@ -347,6 +344,7 @@ export class ChatPanel {
this.modelSelectButton = this.container.querySelector('#model-select-button') as HTMLButtonElement;
this.modelDropdown = this.container.querySelector('#model-dropdown') as HTMLDivElement;
this.customPromptIndicator = this.container.querySelector('#custom-prompt-indicator') as HTMLSpanElement;
this.stopButton = this.container.querySelector('#stop-button') as HTMLButtonElement;
this.setupEventListeners();
this.setupReferenceSystem();
@ -418,6 +416,8 @@ export class ChatPanel {
this.toggleModelDropdown();
});
this.stopButton.addEventListener('click', () => this.handleStopGeneration());
// Close dropdown when clicking outside
document.addEventListener('click', (e) => {
if (!this.modelSelectButton.contains(e.target as Node) && !this.modelDropdown.contains(e.target as Node)) {
@ -896,6 +896,24 @@ export class ChatPanel {
});
}
private handleStopGeneration(): void {
if (this.currentStreamingNodeId) {
this.props.plugin.eventBus.emit('stop-stream-request', {
nodeId: this.currentStreamingNodeId
});
}
}
updateStopButtonVisibility(isStreaming: boolean, nodeId: string | null): void {
this.currentStreamingNodeId = nodeId;
if (isStreaming && nodeId) {
this.stopButton.style.display = 'inline-flex';
} else {
this.stopButton.style.display = 'none';
}
}
private copyMessageContent(content: string): Promise<void> {
return navigator.clipboard.writeText(content);
}

View File

@ -227,34 +227,6 @@
scroll-margin-bottom: 30px; /* Добавляет отступ в 20px снизу при прокрутке к этому элементу */
}
.submit-button {
padding: 4px 8px; /* Восстанавливаем паддинг */
border: none; /* Убираем рамку */
border-radius: 8px;
cursor: pointer;
font-size: 11px;
transition: all 0.2s;
/* Явно переопределяем стили, которые могли быть затронуты */
width: auto; /* Убедимся, что ширина не ограничена */
height: auto; /* Убедимся, что высота не ограничена */
background-color: var(
--button-primary-background
) !important; /* Используем переменную для основного цвета кнопки */
color: var(
--button-primary-text-color
) !important; /* Используем переменную для цвета текста кнопки */
opacity: 1; /* Если .clickable-icon устанавливает opacity */
display: inline-flex; /* Или flex, если нужно выравнивание элементов внутри */
align-items: center; /* Для вертикального выравнивания контента, если display: flex */
justify-content: center; /* Для горизонтального выравнивания контента, если display: flex */
}
#send-button:hover {
background-color: var(
--button-primary-hover-background
) !important; /* Переменная для наведения */
}
.chat-input-container {
border-top: 1px solid var(--background-modifier-border);
padding: 8px 10px 4px 10px;
@ -426,20 +398,39 @@
border-radius: 0 0 8px 8px;
}
.submit-button {
padding: 4px 8px;
border: none;
.stop-button, .submit-button {
padding: 4px 8px; /* Восстанавливаем паддинг */
border: none; /* Убираем рамку */
border-radius: 8px;
cursor: pointer;
font-size: 11px;
font-size: 14px;
transition: all 0.2s;
/* Явно переопределяем стили, которые могли быть затронуты */
width: auto; /* Убедимся, что ширина не ограничена */
height: auto; /* Убедимся, что высота не ограничена */
background-color: var(
--button-primary-background
) !important; /* Используем переменную для основного цвета кнопки */
color: var(
--button-primary-text-color
) !important; /* Используем переменную для цвета текста кнопки */
opacity: 1; /* Если .clickable-icon устанавливает opacity */
display: inline-flex; /* Или flex, если нужно выравнивание элементов внутри */
align-items: center; /* Для вертикального выравнивания контента, если display: flex */
justify-content: center; /* Для горизонтального выравнивания контента, если display: flex */
}
.submit-button:hover:enabled {
.stop-button:hover, .submit-button:hover {
background-color: var(
--button-primary-hover-background
) !important; /* Переменная для наведения */
}
.stop-button:hover:enabled, .submit-button:hover:enabled {
filter: brightness(1.25);
}
.submit-button:disabled {
.stop-button:disabled, .submit-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}

View File

@ -20,6 +20,7 @@ export class ChatView extends ItemView {
chatPanel: ChatPanel | null;
availableModels: string[];
currentGraphCustomSystemPrompt: string | null;
currentStreamingNodeId: string | null = null;
constructor(leaf: WorkspaceLeaf, plugin: LLMAgentPlugin) {
super(leaf);
@ -72,6 +73,9 @@ export class ChatView extends ItemView {
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> {
@ -86,6 +90,9 @@ export class ChatView extends ItemView {
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 ---------------------------------------------------------------
@ -223,12 +230,25 @@ export class ChatView extends ItemView {
* и обновляет панель чата.
* @param {{ nodeId: string }} data - Объект, содержащий ID выбранного узла.
*/
handleNodeSelected(data: { nodeId: string }): void {
this.currentNode = data.nodeId;
if (this.selectedGraphId) {
this.fetchMessagesFromRootToNode(data.nodeId);
}
}
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
);
}
}
});
}
}
/**
* Обрабатывает событие начала нового чата. Сбрасывает текущее состояние чата
@ -280,27 +300,36 @@ export class ChatView extends ItemView {
Object.assign(body, { existing_assistant_node_id: existingAssistantNodeId });
}
const response = await fetch(`${this.plugin.settings.apiBaseUrl}/chat/stream`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!response.ok) {
throw new Error(`Ошибка стриминга: ${response.status}`);
}
const reader = response.body?.getReader();
if (!reader) {
throw new Error('Response body is null');
}
const decoder = new TextDecoder();
const abortController = new AbortController();
let assistantNodeId: string | null = null;
let accumulatedContent = '';
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;
@ -313,6 +342,12 @@ export class ChatView extends ItemView {
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);
}
@ -331,6 +366,8 @@ export class ChatView extends ItemView {
graphId: graphId,
currentNode: assistantNodeId
});
this.plugin.eventBus.emit('stream-ended', { nodeId: assistantNodeId });
}
break;
} else if (data.type === 'done') {
@ -339,13 +376,29 @@ export class ChatView extends ItemView {
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();
reader?.releaseLock();
if (assistantNodeId) {
this.plugin.eventBus.emit('stream-ended', { nodeId: assistantNodeId });
}
}
}
@ -454,17 +507,45 @@ export class ChatView extends ItemView {
* Обрабатывает событие начала нового независимого чата.
* Сбрасывает текущее состояние ChatView, но не влияет на GraphView.
*/
async handleStartNewIndependentChat(): Promise<void> {
this.chatHistory = [];
this.currentNode = null;
// Оставляем this.selectedGraphId без изменений
if (this.chatPanel) {
await this.chatPanel.updateHistory(this.chatHistory);
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);
}
}
// Опционально: можно уведомить пользователя о начале нового чата
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 ---------------------------------------------------------------
@ -479,18 +560,18 @@ export class ChatView extends ItemView {
}
}
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) {
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);
}
this.chatPanel.updateCustomSystemPromptStatus(null);
}
}
}
}
}

View File

@ -43,6 +43,8 @@ export class GraphView extends ItemView {
newIndependentChatActionButton: HTMLButtonElement | null = null;
graphSettingsButton: HTMLButtonElement | null = null;
activeStreamControllers: Map<string, AbortController>;
constructor(leaf: WorkspaceLeaf, plugin: LLMAgentPlugin) {
super(leaf);
this.plugin = plugin;
@ -56,6 +58,7 @@ export class GraphView extends ItemView {
this.selectedNodeIds = new Set<string>();
this.selectedGraphTitle = null;
this.currentGraphCustomSystemPrompt = null;
this.activeStreamControllers = new Map<string, AbortController>();
}
/**
@ -135,6 +138,9 @@ export class GraphView extends ItemView {
this.plugin.eventBus.on('ws-node-title-updated', this.handleNodeTitleUpdated.bind(this));
this.plugin.eventBus.on('ws-node-type-changed', this.handleNodeTypeChanged.bind(this));
this.plugin.eventBus.on('stream-started', this.handleStreamStarted.bind(this));
this.plugin.eventBus.on('stream-ended', this.handleStreamEnded.bind(this));
// Загружаем список графов
await this.fetchGraphsList();
}
@ -163,6 +169,9 @@ export class GraphView extends ItemView {
this.plugin.eventBus.off('ws-graph-title-updated', this.handleGraphTitleUpdated);
this.plugin.eventBus.off('ws-node-title-updated', this.handleNodeTitleUpdated);
this.plugin.eventBus.off('ws-node-type-changed', this.handleNodeTypeChanged);
this.plugin.eventBus.off('stream-started', this.handleStreamStarted);
this.plugin.eventBus.off('stream-ended', this.handleStreamEnded);
}
// ----------------------------------------------- Custom Title Update ---------------------------------------------------
@ -666,6 +675,14 @@ export class GraphView extends ItemView {
new Notice('Операция удаления завершена.', 2000);
}
handleStreamStarted(data: { nodeId: string; controller: AbortController }): void {
this.activeStreamControllers.set(data.nodeId, data.controller);
}
handleStreamEnded(data: { nodeId: string }): void {
this.activeStreamControllers.delete(data.nodeId);
}
// ----------------------------------------------- WebSocket Event Handlers ---------------------------------------------------------------
/**

File diff suppressed because one or more lines are too long