First solution for voice panel

This commit is contained in:
dimitrievgs 2026-04-30 18:59:57 +03:00
parent cb8ed95bcd
commit aee60c1ac2
4 changed files with 324 additions and 37 deletions

142
main.ts
View File

@ -17,6 +17,15 @@ interface LLMAgentSettings {
enablePromptReferences: boolean; enablePromptReferences: boolean;
excludedFolders: string[]; excludedFolders: string[];
refCacheFolder: string; refCacheFolder: string;
voiceModel: string;
voiceInterval: number;
voiceDisplayLimit: number; // 3000
voiceContextLimit: number; // N2 символов для LLM
promptRegularPath: string;
promptCommandsPath: string;
markerStart: string; // "старт"
markerStop: string; // "стоп"
} }
const DEFAULT_SETTINGS: LLMAgentSettings = { const DEFAULT_SETTINGS: LLMAgentSettings = {
@ -27,7 +36,16 @@ const DEFAULT_SETTINGS: LLMAgentSettings = {
enableFileReferences: true, enableFileReferences: true,
enablePromptReferences: true, enablePromptReferences: true,
excludedFolders: ['.obsidian', 'llm-agent-backend', 'templates', 'ref-cache'], // Папка "templates" по умолчанию исключена excludedFolders: ['.obsidian', 'llm-agent-backend', 'templates', 'ref-cache'], // Папка "templates" по умолчанию исключена
refCacheFolder: 'ref-cache' // По умолчанию относительно vault refCacheFolder: 'ref-cache', // По умолчанию относительно vault
voiceModel: '',
voiceInterval: 1,
voiceDisplayLimit: 3000,
voiceContextLimit: 3000,
promptRegularPath: '',
promptCommandsPath: '',
markerStart: 'старт',
markerStop: 'стоп'
}; };
// ----------------------------------------------- Main Plugin Class --------------------------------------------------------------- // ----------------------------------------------- Main Plugin Class ---------------------------------------------------------------
@ -38,6 +56,30 @@ export default class LLMAgentPlugin extends Plugin {
webSocketService: WebSocketService; webSocketService: WebSocketService;
activeStreamControllers: Map<string, AbortController>; activeStreamControllers: Map<string, AbortController>;
async syncVoiceSettings() {
try {
const response = await fetch(`${this.settings.apiBaseUrl}/settings/sync`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
systemPrompt: this.settings.systemPrompt,
interval: this.settings.voiceInterval,
context_limit: this.settings.voiceContextLimit,
marker_start: this.settings.markerStart,
marker_stop: this.settings.markerStop,
// Передаем также пути к файлам промптов, если нужно
prompt_regular: this.settings.promptRegularPath,
prompt_commands: this.settings.promptCommandsPath
})
});
if (response.ok) {
console.log("LLM Agent: Voice settings synced with backend.");
}
} catch (e) {
console.error("LLM Agent: Failed to sync settings", e);
}
}
async onload() { async onload() {
await this.loadSettings(); await this.loadSettings();
@ -84,6 +126,10 @@ export default class LLMAgentPlugin extends Plugin {
// This adds a settings tab so the user can configure various aspects of the plugin // This adds a settings tab so the user can configure various aspects of the plugin
this.addSettingTab(new SampleSettingTab(this.app, this)); this.addSettingTab(new SampleSettingTab(this.app, this));
// Вызываем один раз при запуске
await this.syncVoiceSettings();
} }
onunload() { onunload() {
@ -165,15 +211,16 @@ class SampleSettingTab extends PluginSettingTab {
display(): void { display(): void {
const { containerEl } = this; const { containerEl } = this;
containerEl.empty(); containerEl.empty();
containerEl.createEl('h2', { text: 'Основные настройки' });
new Setting(containerEl) new Setting(containerEl)
.setName('API Base URL') .setName('API Base URL')
.setDesc('Базовый URL для API LLM Agent') .setDesc('Базовый URL для API LLM Agent')
.addText((text) => .addText((text) =>
text text
.setPlaceholder('Введите URL') .setPlaceholder('http://localhost:5000/api')
.setValue(this.plugin.settings.apiBaseUrl) .setValue(this.plugin.settings.apiBaseUrl)
.onChange(async (value) => { .onChange(async (value) => {
this.plugin.settings.apiBaseUrl = value; this.plugin.settings.apiBaseUrl = value;
@ -181,7 +228,6 @@ class SampleSettingTab extends PluginSettingTab {
}) })
); );
// Новое поле для системного промпта
new Setting(containerEl) new Setting(containerEl)
.setName('Системный промпт') .setName('Системный промпт')
.setDesc('Этот промпт будет отправляться LLM перед каждым диалогом.') .setDesc('Этот промпт будет отправляться LLM перед каждым диалогом.')
@ -195,7 +241,92 @@ class SampleSettingTab extends PluginSettingTab {
}) })
); );
// Новое поле для папки промптов containerEl.createEl('h2', { text: 'Голосовое управление (Voice Service)' });
new Setting(containerEl)
.setName('Интервал регулярного анализа (мин)')
.setDesc('Как часто LLM анализирует последние фразы (Prompt 1).')
.addText((text) =>
text
.setValue(String(this.plugin.settings.voiceInterval))
.onChange(async (value) => {
this.plugin.settings.voiceInterval = Number(value);
await this.plugin.saveSettings();
await this.plugin.syncVoiceSettings();
})
);
new Setting(containerEl)
.setName('Объем контекста (символы)')
.setDesc('Сколько последних символов транскрибации отправлять в модель.')
.addText((text) =>
text
.setValue(String(this.plugin.settings.voiceContextLimit))
.onChange(async (value) => {
this.plugin.settings.voiceContextLimit = Number(value);
await this.plugin.saveSettings();
await this.plugin.syncVoiceSettings();
})
);
new Setting(containerEl)
.setName('Маркер начала команды')
.setDesc('Слово, после которого текст считается командой (Response 2).')
.addText((text) =>
text
.setPlaceholder('старт')
.setValue(this.plugin.settings.markerStart)
.onChange(async (value) => {
this.plugin.settings.markerStart = value;
await this.plugin.saveSettings();
await this.plugin.syncVoiceSettings();
})
);
new Setting(containerEl)
.setName('Маркер конца команды')
.setDesc('Слово, завершающее захват голосовой команды.')
.addText((text) =>
text
.setPlaceholder('стоп')
.setValue(this.plugin.settings.markerStop)
.onChange(async (value) => {
this.plugin.settings.markerStop = value;
await this.plugin.saveSettings();
await this.plugin.syncVoiceSettings();
})
);
new Setting(containerEl)
.setName('Регулярный промпт')
.setDesc('Инструкции для Промпта №1.')
.addTextArea((text) =>
text
// .setPlaceholder('prompts/regular_analysis.md')
.setValue(this.plugin.settings.promptRegularPath)
.onChange(async (value) => {
this.plugin.settings.promptRegularPath = value;
await this.plugin.saveSettings();
await this.plugin.syncVoiceSettings();
})
);
new Setting(containerEl)
.setName('Промпт команд')
.setDesc('Инструкции для Промпта №2.')
.addTextArea((text) =>
text
// .setPlaceholder('prompts/voice_commands.md')
.setValue(this.plugin.settings.promptCommandsPath)
.onChange(async (value) => {
this.plugin.settings.promptCommandsPath = value;
await this.plugin.saveSettings();
await this.plugin.syncVoiceSettings();
})
);
containerEl.createEl('h2', { text: 'Управление ссылками и файлами' });
new Setting(containerEl) new Setting(containerEl)
.setName('Папка с промптами') .setName('Папка с промптами')
.setDesc('Путь к папке, содержащей промпты для команд #/##') .setDesc('Путь к папке, содержащей промпты для команд #/##')
@ -256,6 +387,7 @@ class SampleSettingTab extends PluginSettingTab {
); );
// Динамический список исключенных папок // Динамический список исключенных папок
containerEl.createEl('h3', { text: 'Исключенные папки' });
this.plugin.settings.excludedFolders.forEach((folder, index) => { this.plugin.settings.excludedFolders.forEach((folder, index) => {
new Setting(containerEl) new Setting(containerEl)
.setClass('llm-agent-excluded-folder-item') // Добавляем класс для стилизации .setClass('llm-agent-excluded-folder-item') // Добавляем класс для стилизации

View File

@ -0,0 +1,71 @@
import LLMAgentPlugin from 'main';
export class VoicePanel {
private activeTab: 'res1' | 'res2' | 'trans' = 'res1';
constructor(
private container: HTMLElement,
private plugin: LLMAgentPlugin
) {
this.render();
this.startPolling();
}
render() {
this.container.innerHTML = `
<div class="voice-panel-container">
<div class="voice-header">
<div class="v-controls">
<button id="v-new" class="mod-cta">Start New</button>
<button id="v-cont">Continue</button>
<button id="v-stop" class="mod-warning">Stop</button>
</div>
<div class="v-tabs">
<button class="v-tab active" data-tab="res1">Response 1</button>
<button class="v-tab" data-tab="res2">Response 2</button>
<button class="v-tab" data-tab="trans">Transcription</button>
</div>
</div>
<div id="v-body" class="voice-body"></div>
</div>
`;
// Навешиваем логику вкладок
this.container.querySelectorAll('.v-tab').forEach((tab) => {
tab.addEventListener('click', (e) => {
this.container.querySelectorAll('.v-tab').forEach((t) => t.classList.remove('active'));
(e.target as HTMLElement).classList.add('active');
this.activeTab = (e.target as HTMLElement).dataset.tab as any;
});
});
// Навешиваем управление через API
const control = async (action: string) => {
await fetch(`${this.plugin.settings.apiBaseUrl}/voice/control`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action })
});
};
this.container.querySelector('#v-new')?.addEventListener('click', () => control('start'));
this.container.querySelector('#v-cont')?.addEventListener('click', () => control('continue'));
this.container.querySelector('#v-stop')?.addEventListener('click', () => control('stop'));
}
async startPolling() {
setInterval(async () => {
const res = await fetch(`${this.plugin.settings.apiBaseUrl}/voice/status`);
const data = await res.json();
this.updateContent(data);
}, 3000);
}
updateContent(data: any) {
const body = this.container.querySelector('#v-body');
if (!body) return;
if (this.activeTab === 'trans') body.textContent = data.transcription;
if (this.activeTab === 'res1') body.textContent = data.response1;
if (this.activeTab === 'res2') body.innerHTML = data.response2.join('<br>---<br>');
}
}

View File

@ -740,9 +740,71 @@
.llm-agent-graph-main { .llm-agent-graph-main {
display: flex; display: flex;
flex-direction: column;
height: 100%; height: 100%;
width: 100%; }
/* background-color: var(--background-primary); */
.upper-content-wrapper {
flex: 1;
display: flex;
overflow: hidden;
}
/* Голосовая панель */
.voice-panel-fixed-bottom {
display: flex;
flex-direction: column;
border-top: 1px solid var(--background-modifier-border);
}
.voice-panel-container {
display: flex;
flex-direction: column;
height: 100%;
padding: 5px;
}
.voice-header {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: 5px;
border-bottom: 1px solid var(--background-modifier-border);
}
.v-tabs {
display: flex;
gap: 2px;
}
.v-tab {
padding: 4px 8px;
font-size: 11px;
cursor: pointer;
background: transparent;
border: none;
color: var(--text-muted);
}
.v-tab.active {
color: var(--text-accent);
border-bottom: 2px solid var(--text-accent);
}
.voice-body {
flex: 1;
overflow-y: auto;
font-size: 12px;
font-family: var(--font-monospace);
padding: 10px;
background-color: var(--background-primary);
white-space: pre-wrap; /* Чтобы транскрибация не уходила в одну строку */
}
/* Кнопки управления голосом */
.v-controls button {
margin-right: 5px;
padding: 4px 10px;
} }
.llm-agent-history-sidebar { .llm-agent-history-sidebar {

View File

@ -14,6 +14,7 @@ import { HistoryPanel } from '../components/HistoryPanel'; // Предполаг
import { GraphPanelComponent } from '../components/GraphPanel'; // Импортируем React-компонент и ReactDOM import { GraphPanelComponent } from '../components/GraphPanel'; // Импортируем React-компонент и ReactDOM
import { Dialog } from 'src/utils/Dialog'; import { Dialog } from 'src/utils/Dialog';
import { GraphSettingsModal } from '../modals/GraphSettingsModal'; import { GraphSettingsModal } from '../modals/GraphSettingsModal';
import { VoicePanel } from '../components/VoicePanel';
export const GRAPH_VIEW_TYPE = 'llm-agent-graph-view'; export const GRAPH_VIEW_TYPE = 'llm-agent-graph-view';
@ -90,59 +91,80 @@ export class GraphView extends ItemView {
const container = this.containerEl.children[1] as HTMLElement; const container = this.containerEl.children[1] as HTMLElement;
container.empty(); container.empty();
// Создаем основной контейнер с flexbox layout // 1. Основной контейнер - теперь вертикальный стек
const mainContainer = container.createDiv('llm-agent-graph-main'); const mainContainer = container.createDiv('llm-agent-graph-main');
mainContainer.style.cssText = ` mainContainer.style.cssText = `
display: flex; display: flex;
flex-direction: column; /* Элементы кладутся друг под друга */
height: 100%; height: 100%;
width: 100%; width: 100%;
background-color: var(--background-primary); overflow: hidden;
`; `;
// Левая панель для истории // 2. Верхняя часть (История + Граф)
this.historyContainer = mainContainer.createDiv('llm-agent-history-sidebar') as HTMLDivElement; const upperContent = mainContainer.createDiv('upper-content-wrapper');
upperContent.style.cssText = `
display: flex;
flex-direction: row; /* Элементы бок о бок */
flex: 1; /* Занимает всё свободное место (около 75-80%) */
width: 100%;
min-height: 0; /* Важно для корректного скролла внутри */
`;
// 3. Левая панель для истории (внутри верхней части)
this.historyContainer = upperContent.createDiv('llm-agent-history-sidebar');
this.historyContainer.classList.add(this.isHistoryCollapsed ? 'collapsed' : 'expanded'); this.historyContainer.classList.add(this.isHistoryCollapsed ? 'collapsed' : 'expanded');
// Правая панель для графа // 4. Правая панель для графа (внутри верхней части)
this.graphContainer = mainContainer.createDiv('llm-agent-graph-container') as HTMLDivElement; this.graphContainer = upperContent.createDiv('llm-agent-graph-container');
this.graphContainer.style.cssText = ` this.graphContainer.style.cssText = `
flex: 1; flex: 1;
position: relative; position: relative;
background-color: var(--background-primary); background-color: var(--background-primary);
`; `;
// Инициализируем панель истории // 5. Контейнер для голосовой панели (строго ВНИЗУ)
const voiceContainer = mainContainer.createDiv('voice-panel-fixed-bottom');
voiceContainer.style.cssText = `
height: 200px; /* Фиксированная высота или % */
width: 100%;
border-top: 1px solid var(--divider-color);
background-color: var(--background-secondary);
overflow: hidden;
flex-shrink: 0; /* Не дает панели сжиматься */
`;
// Инициализация компонентов
this.historyPanel = new HistoryPanel( this.historyPanel = new HistoryPanel(
this.historyContainer, this.historyContainer,
{ { graphs: this.graphsList, eventBus: this.plugin.eventBus },
graphs: this.graphsList,
eventBus: this.plugin.eventBus
},
this.plugin this.plugin
); );
// Создаем кнопки переключения и глобальные кнопки действий
this.addToggleButton(); this.addToggleButton();
this.addGlobalActionButtons(); // Добавляем вызов новой функции this.addGlobalActionButtons();
// Инициализируем React Flow
this.initializeGraphPanel(); this.initializeGraphPanel();
// Подписываемся на события // Инициализируем VoicePanel
new VoicePanel(voiceContainer, this.plugin);
// Подписки на события
this.setupEventListeners();
// Загружаем данные
await this.fetchGraphsList();
}
// Вынес для чистоты
private setupEventListeners() {
this.plugin.eventBus.on('new-chat', this.handleNewChat.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('graph-selected', this.handleGraphSelected.bind(this));
this.plugin.eventBus.on('delete-node', this.handleDeleteNodeFromChat.bind(this)); // Слушаем событие из ChatPanel this.plugin.eventBus.on('delete-node', this.handleDeleteNodeFromChat.bind(this));
// WebSocket Event Handlers
this.plugin.eventBus.on('ws-graph-title-updated', this.handleGraphTitleUpdated.bind(this)); this.plugin.eventBus.on('ws-graph-title-updated', this.handleGraphTitleUpdated.bind(this));
this.plugin.eventBus.on('ws-node-title-updated', this.handleNodeTitleUpdated.bind(this)); 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('ws-node-type-changed', this.handleNodeTypeChanged.bind(this));
this.plugin.eventBus.on('stream-started', this.handleStreamStarted.bind(this)); this.plugin.eventBus.on('stream-started', this.handleStreamStarted.bind(this));
this.plugin.eventBus.on('stream-ended', this.handleStreamEnded.bind(this)); this.plugin.eventBus.on('stream-ended', this.handleStreamEnded.bind(this));
// Загружаем список графов
await this.fetchGraphsList();
} }
/** /**