@sequent-org/moodboard 1.4.26 → 1.4.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/package.json +2 -1
  2. package/src/assets/fonts/geist/geist-sans-latin-100-normal.ttf +0 -0
  3. package/src/assets/fonts/geist/geist-sans-latin-200-normal.ttf +0 -0
  4. package/src/assets/fonts/geist/geist-sans-latin-300-normal.ttf +0 -0
  5. package/src/assets/fonts/geist/geist-sans-latin-400-normal.ttf +0 -0
  6. package/src/assets/fonts/geist/geist-sans-latin-500-normal.ttf +0 -0
  7. package/src/assets/fonts/geist/geist-sans-latin-600-normal.ttf +0 -0
  8. package/src/assets/fonts/geist/geist-sans-latin-700-normal.ttf +0 -0
  9. package/src/assets/fonts/geist/geist-sans-latin-800-normal.ttf +0 -0
  10. package/src/assets/fonts/geist/geist-sans-latin-900-normal.ttf +0 -0
  11. package/src/core/SaveManager.js +6 -0
  12. package/src/core/flows/ClipboardFlow.js +16 -6
  13. package/src/moodboard/bootstrap/MoodBoardUiFactory.js +10 -0
  14. package/src/moodboard/lifecycle/MoodBoardDestroyer.js +3 -0
  15. package/src/services/ai/AiClient.js +224 -0
  16. package/src/services/ai/ChatHistoryStore.js +55 -0
  17. package/src/services/ai/ChatPresets.js +40 -0
  18. package/src/services/ai/ChatSessionController.js +220 -0
  19. package/src/ui/chat/ChatComposer.js +198 -0
  20. package/src/ui/chat/ChatExtendedPromptModal.js +131 -0
  21. package/src/ui/chat/ChatMessageList.js +92 -0
  22. package/src/ui/chat/ChatPillMenu.js +141 -0
  23. package/src/ui/chat/ChatSettingsPopup.js +171 -0
  24. package/src/ui/chat/ChatWindow.js +407 -0
  25. package/src/ui/chat/ChatWindowRenderer.js +214 -0
  26. package/src/ui/chat/icons.js +113 -0
  27. package/src/ui/styles/chat.css +920 -0
  28. package/src/ui/styles/index.css +1 -0
  29. package/src/ui/styles/workspace.css +73 -1
  30. package/src/utils/styleLoader.js +2 -1
@@ -0,0 +1,220 @@
1
+ import { CHAT_PRESETS, DEFAULT_PRESET_ID, getPresetById } from './ChatPresets.js';
2
+
3
+ /**
4
+ * Контроллер сессии чата.
5
+ *
6
+ * Одна ответственность: держит состояние диалога и оркестрирует
7
+ * вызовы AiClient + сохранение в ChatHistoryStore. Не знает про DOM.
8
+ *
9
+ * Связь с UI — через слушателей (subscribe), а не через прямые ссылки.
10
+ *
11
+ * Состояние:
12
+ * - messages: список сообщений (с временным assistant-сообщением во время стриминга)
13
+ * - providerId: текущий провайдер (yandex-art)
14
+ * - presetId: текущий пресет промпта
15
+ * - settings: { systemPrompt, temperature, maxTokens }
16
+ * - status: 'idle' | 'streaming' | 'error'
17
+ * - error: string|null
18
+ *
19
+ * События для подписчиков (один колбэк на всё, для простоты):
20
+ * - 'state' — любое изменение состояния (UI делает rerender)
21
+ */
22
+
23
+ export const DEFAULT_SETTINGS = {
24
+ systemPrompt: '',
25
+ temperature: 0.7,
26
+ maxTokens: 2000
27
+ };
28
+
29
+ const SETTINGS_STORAGE_KEY = 'moodboard.ai.chat.settings.v1';
30
+
31
+ export class ChatSessionController {
32
+ /**
33
+ * @param {object} deps
34
+ * @param {import('./AiClient.js').AiClient} deps.aiClient
35
+ * @param {import('./ChatHistoryStore.js').ChatHistoryStore} deps.historyStore
36
+ * @param {Storage} [deps.settingsStorage]
37
+ */
38
+ constructor({ aiClient, historyStore, settingsStorage }) {
39
+ this._client = aiClient;
40
+ this._history = historyStore;
41
+ this._settingsStorage = settingsStorage || (typeof localStorage !== 'undefined' ? localStorage : null);
42
+ this._listeners = new Set();
43
+ this._abort = null;
44
+
45
+ this._state = {
46
+ messages: this._history.load(),
47
+ providerId: 'yandex-art',
48
+ presetId: DEFAULT_PRESET_ID,
49
+ settings: this._loadSettings(),
50
+ status: 'idle',
51
+ error: null,
52
+ availableProviders: []
53
+ };
54
+ }
55
+
56
+ getState() {
57
+ return this._state;
58
+ }
59
+
60
+ subscribe(listener) {
61
+ this._listeners.add(listener);
62
+ return () => this._listeners.delete(listener);
63
+ }
64
+
65
+ setProvider(providerId) {
66
+ if (!providerId || providerId === this._state.providerId) return;
67
+ this._state = { ...this._state, providerId };
68
+ this._emit();
69
+ }
70
+
71
+ setPreset(presetId) {
72
+ const preset = getPresetById(presetId);
73
+ const next = { ...this._state, presetId: preset.id };
74
+ if (!this._state.settings.systemPrompt || this._isPresetSystemPrompt(this._state.settings.systemPrompt)) {
75
+ next.settings = { ...this._state.settings, systemPrompt: preset.systemPrompt };
76
+ this._saveSettings(next.settings);
77
+ }
78
+ this._state = next;
79
+ this._emit();
80
+ }
81
+
82
+ updateSettings(patch) {
83
+ const settings = { ...this._state.settings, ...patch };
84
+ this._state = { ...this._state, settings };
85
+ this._saveSettings(settings);
86
+ this._emit();
87
+ }
88
+
89
+ setAvailableProviders(list) {
90
+ this._state = { ...this._state, availableProviders: Array.isArray(list) ? list : [] };
91
+ const enabled = this._state.availableProviders.filter((p) => p.enabled);
92
+ if (enabled.length > 0 && !enabled.some((p) => p.id === this._state.providerId)) {
93
+ this._state = { ...this._state, providerId: enabled[0].id };
94
+ }
95
+ this._emit();
96
+ }
97
+
98
+ clearHistory() {
99
+ if (this._abort) this.abort();
100
+ this._state = { ...this._state, messages: [], status: 'idle', error: null };
101
+ this._history.save([]);
102
+ this._emit();
103
+ }
104
+
105
+ abort() {
106
+ if (this._abort) {
107
+ try { this._abort.abort(); } catch { /* noop */ }
108
+ this._abort = null;
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Отправляет user-сообщение и создаёт изображение через YandexART.
114
+ * @param {string} text
115
+ * @param {{widthRatio?: number, heightRatio?: number, model?: string}} [options]
116
+ */
117
+ async send(text, options = {}) {
118
+ const trimmed = (text || '').trim();
119
+ if (!trimmed || this._state.status === 'streaming') return;
120
+
121
+ const userMsg = makeMessage('user', trimmed);
122
+ const assistantMsg = makeMessage('assistant', '', { provider: 'yandex-art', pending: true, kind: 'image' });
123
+
124
+ this._state = {
125
+ ...this._state,
126
+ messages: [...this._state.messages, userMsg, assistantMsg],
127
+ status: 'streaming',
128
+ error: null
129
+ };
130
+ this._history.save(this._state.messages);
131
+ this._emit();
132
+
133
+ const abort = new AbortController();
134
+ this._abort = abort;
135
+
136
+ try {
137
+ const result = await this._client.generateImage({
138
+ prompt: trimmed,
139
+ widthRatio: options.widthRatio,
140
+ heightRatio: options.heightRatio,
141
+ model: options.model,
142
+ signal: abort.signal
143
+ });
144
+
145
+ this._finalizeAssistant(assistantMsg.id, {
146
+ error: null,
147
+ imageBase64: result.imageBase64,
148
+ mimeType: result.mimeType,
149
+ operationId: result.operationId
150
+ });
151
+ } catch (err) {
152
+ const message = err?.name === 'AbortError' ? 'Отменено' : (err?.message || 'Ошибка запроса');
153
+ this._finalizeAssistant(assistantMsg.id, { error: message });
154
+ } finally {
155
+ this._abort = null;
156
+ }
157
+ }
158
+
159
+ _finalizeAssistant(id, { error, imageBase64, mimeType, operationId }) {
160
+ const messages = this._state.messages.map((m) =>
161
+ m.id === id
162
+ ? {
163
+ ...m,
164
+ pending: false,
165
+ error: error || undefined,
166
+ imageBase64: imageBase64 || m.imageBase64,
167
+ mimeType: mimeType || m.mimeType,
168
+ operationId: operationId || m.operationId
169
+ }
170
+ : m
171
+ );
172
+ this._state = {
173
+ ...this._state,
174
+ messages,
175
+ status: error ? 'error' : 'idle',
176
+ error: error || null
177
+ };
178
+ this._history.save(messages);
179
+ this._emit();
180
+ }
181
+
182
+ _emit() {
183
+ for (const listener of this._listeners) {
184
+ try { listener(this._state); } catch (err) { console.error('[ChatSession] listener error:', err); }
185
+ }
186
+ }
187
+
188
+ _loadSettings() {
189
+ if (!this._settingsStorage) return { ...DEFAULT_SETTINGS };
190
+ try {
191
+ const raw = this._settingsStorage.getItem(SETTINGS_STORAGE_KEY);
192
+ if (!raw) return { ...DEFAULT_SETTINGS };
193
+ const parsed = JSON.parse(raw);
194
+ return { ...DEFAULT_SETTINGS, ...(parsed || {}) };
195
+ } catch {
196
+ return { ...DEFAULT_SETTINGS };
197
+ }
198
+ }
199
+
200
+ _saveSettings(settings) {
201
+ if (!this._settingsStorage) return;
202
+ try {
203
+ this._settingsStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(settings));
204
+ } catch { /* noop */ }
205
+ }
206
+
207
+ _isPresetSystemPrompt(text) {
208
+ return CHAT_PRESETS.some((p) => p.systemPrompt === text);
209
+ }
210
+ }
211
+
212
+ function makeMessage(role, content, extra = {}) {
213
+ return {
214
+ id: `msg_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
215
+ role,
216
+ content,
217
+ ts: Date.now(),
218
+ ...extra
219
+ };
220
+ }
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Композер: textarea + кнопка отправки + вложения.
3
+ *
4
+ * Одна ответственность: события ввода и отправки. Не знает про пиллы,
5
+ * меню провайдеров и настройки — этим занимаются отдельные модули.
6
+ *
7
+ * Контракт колбэков:
8
+ * onSubmit(text, attachments) — нажат Enter без Shift или клик по send в состоянии 'ready'
9
+ * onAbort() — клик по send в состоянии 'streaming'
10
+ */
11
+
12
+ const CLOSE_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="none" viewBox="0 0 12 12"><path stroke="currentColor" stroke-linecap="round" stroke-width="1.2" d="m2.5 2.5 7 7m0-7-7 7"></path></svg>`;
13
+
14
+ export class ChatComposer {
15
+ /**
16
+ * @param {{ textarea: HTMLTextAreaElement, send: HTMLButtonElement, attach: HTMLButtonElement, fileInput: HTMLInputElement, attachmentsPreview: HTMLElement, enhancePrompt?: HTMLButtonElement, statusBar?: HTMLElement }} refs
17
+ * @param {{ onSubmit: (text: string, attachments: File[]) => void, onAbort: () => void }} handlers
18
+ */
19
+ constructor(refs, handlers) {
20
+ this._textarea = refs.textarea;
21
+ this._send = refs.send;
22
+ this._attach = refs.attach ?? null;
23
+ this._fileInput = refs.fileInput ?? null;
24
+ this._attachmentsPreview = refs.attachmentsPreview ?? null;
25
+ this._enhancePrompt = refs.enhancePrompt ?? null;
26
+ this._statusBar = refs.statusBar ?? null;
27
+ this._handlers = handlers;
28
+ this._listeners = [];
29
+ /** @type {File[]} */
30
+ this._attachments = [];
31
+ }
32
+
33
+ attach() {
34
+ this._on(this._textarea, 'input', () => {
35
+ this._resizeTextarea();
36
+ this._refreshSendState();
37
+ });
38
+ this._on(this._textarea, 'keydown', (e) => {
39
+ if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
40
+ e.preventDefault();
41
+ this._submit();
42
+ }
43
+ });
44
+ this._on(this._send, 'click', () => {
45
+ if (this._send.dataset.state === 'streaming') {
46
+ this._handlers.onAbort?.();
47
+ } else {
48
+ this._submit();
49
+ }
50
+ });
51
+ if (this._attach && this._fileInput) {
52
+ this._on(this._attach, 'click', () => this._fileInput.click());
53
+ this._on(this._fileInput, 'change', () => this._handleFileChange());
54
+ }
55
+ this._resizeTextarea();
56
+ this._refreshSendState();
57
+ }
58
+
59
+ /**
60
+ * Внешнее состояние стриминга — управляет иконкой кнопки send.
61
+ * @param {'idle'|'streaming'} status
62
+ */
63
+ setStreaming(isStreaming) {
64
+ if (isStreaming) {
65
+ this._send.dataset.state = 'streaming';
66
+ this._send.disabled = false;
67
+ } else {
68
+ this._refreshSendState();
69
+ }
70
+ if (this._statusBar) {
71
+ this._statusBar.classList.toggle('is-visible', isStreaming);
72
+ this._statusBar.classList.toggle('is-generating', isStreaming);
73
+ }
74
+ }
75
+
76
+ focus() {
77
+ this._textarea.focus();
78
+ }
79
+
80
+ destroy() {
81
+ for (const off of this._listeners) off();
82
+ this._listeners = [];
83
+ this._attachments = [];
84
+ }
85
+
86
+ _submit() {
87
+ const text = this._textarea.value;
88
+ const trimmed = text.trim();
89
+ const hasAttachments = this._attachments.length > 0;
90
+ if (!trimmed && !hasAttachments) return;
91
+ if (this._send.dataset.state === 'streaming') return;
92
+ const attachments = [...this._attachments];
93
+ this._attachments = [];
94
+ this._textarea.value = '';
95
+ this._resizeTextarea();
96
+ this._renderAttachmentsPreview();
97
+ this._refreshSendState();
98
+ this._handlers.onSubmit?.(trimmed, attachments);
99
+ }
100
+
101
+ _refreshSendState() {
102
+ const hasText = this._textarea.value.trim().length > 0;
103
+ const hasAttachments = this._attachments.length > 0;
104
+ this._send.dataset.state = (hasText || hasAttachments) ? 'ready' : 'idle';
105
+ this._send.disabled = false;
106
+ if (this._enhancePrompt) {
107
+ this._enhancePrompt.dataset.empty = hasText ? 'false' : 'true';
108
+ }
109
+ }
110
+
111
+ _handleFileChange() {
112
+ const files = Array.from(this._fileInput.files || []);
113
+ if (!files.length) return;
114
+ for (const file of files) {
115
+ this._attachments.push(file);
116
+ }
117
+ this._fileInput.value = '';
118
+ this._renderAttachmentsPreview();
119
+ this._refreshSendState();
120
+ }
121
+
122
+ _renderAttachmentsPreview() {
123
+ const container = this._attachmentsPreview;
124
+ if (!container) return;
125
+
126
+ container.innerHTML = '';
127
+ const inputRow = container.closest('.moodboard-chat__input-row');
128
+ if (this._attachments.length === 0) {
129
+ container.classList.remove('is-visible');
130
+ inputRow?.classList.remove('has-attachments');
131
+ this._textarea.placeholder = 'Опишите то, что хотите сгенерировать';
132
+ return;
133
+ }
134
+
135
+ container.classList.add('is-visible');
136
+ inputRow?.classList.add('has-attachments');
137
+ this._textarea.placeholder = 'Опишите правку, изменение или стилевое направление эталонного изображения';
138
+ for (let i = 0; i < this._attachments.length; i++) {
139
+ const file = this._attachments[i];
140
+ const item = this._buildAttachmentItem(file, i);
141
+ container.appendChild(item);
142
+ }
143
+ }
144
+
145
+ _buildAttachmentItem(file, index) {
146
+ const item = document.createElement('div');
147
+ item.className = 'moodboard-chat__attachment-item';
148
+ item.title = file.name;
149
+
150
+ const isImage = file.type.startsWith('image/');
151
+
152
+ if (isImage) {
153
+ const img = document.createElement('img');
154
+ img.className = 'moodboard-chat__attachment-thumb';
155
+ img.alt = file.name;
156
+ const url = URL.createObjectURL(file);
157
+ img.src = url;
158
+ img.addEventListener('load', () => URL.revokeObjectURL(url), { once: true });
159
+ item.appendChild(img);
160
+ } else {
161
+ const icon = document.createElement('div');
162
+ icon.className = 'moodboard-chat__attachment-icon';
163
+ const ext = file.name.split('.').pop()?.toUpperCase() ?? '?';
164
+ icon.textContent = ext;
165
+ item.appendChild(icon);
166
+ }
167
+
168
+ const badge = document.createElement('div');
169
+ badge.className = 'moodboard-chat__attachment-badge';
170
+ badge.textContent = String(index + 1);
171
+ item.appendChild(badge);
172
+
173
+ const remove = document.createElement('button');
174
+ remove.type = 'button';
175
+ remove.className = 'moodboard-chat__attachment-remove';
176
+ remove.setAttribute('aria-label', `Удалить ${file.name}`);
177
+ remove.innerHTML = CLOSE_SVG;
178
+ remove.addEventListener('click', () => {
179
+ this._attachments.splice(index, 1);
180
+ this._renderAttachmentsPreview();
181
+ this._refreshSendState();
182
+ });
183
+ item.appendChild(remove);
184
+
185
+ return item;
186
+ }
187
+
188
+ _resizeTextarea() {
189
+ this._textarea.style.height = 'auto';
190
+ const nextHeight = Math.max(47, Math.ceil(this._textarea.scrollHeight));
191
+ this._textarea.style.height = `${nextHeight}px`;
192
+ }
193
+
194
+ _on(el, type, handler) {
195
+ el.addEventListener(type, handler);
196
+ this._listeners.push(() => el.removeEventListener(type, handler));
197
+ }
198
+ }
@@ -0,0 +1,131 @@
1
+ import { ICONS } from './icons.js';
2
+
3
+ export class ChatExtendedPromptModal {
4
+ /**
5
+ * @param {HTMLElement} container - workspace-контейнер
6
+ * @param {HTMLTextAreaElement} sourceTextarea
7
+ * @param {HTMLElement} triggerBtn
8
+ */
9
+ constructor(container, sourceTextarea, triggerBtn) {
10
+ this._container = container;
11
+ this._sourceTextarea = sourceTextarea;
12
+ this._triggerBtn = triggerBtn;
13
+ this._refs = null;
14
+ this._listeners = [];
15
+ this._isVisible = false;
16
+ }
17
+
18
+ attach() {
19
+ this._refs = this._buildDom();
20
+ this._container.appendChild(this._refs.overlay);
21
+
22
+ this._on(this._triggerBtn, 'click', () => this.show());
23
+ this._on(this._refs.closeBtn, 'click', () => this.hide());
24
+ this._on(this._refs.overlay, 'click', (e) => {
25
+ if (e.target === this._refs.overlay) this.hide();
26
+ });
27
+
28
+ this._on(this._refs.textarea, 'input', () => {
29
+ this._sourceTextarea.value = this._refs.textarea.value;
30
+ this._sourceTextarea.dispatchEvent(new Event('input', { bubbles: true }));
31
+ });
32
+
33
+ this._on(this._refs.clearBtn, 'click', () => {
34
+ this._refs.textarea.value = '';
35
+ this._refs.textarea.focus();
36
+ this._sourceTextarea.value = '';
37
+ this._sourceTextarea.dispatchEvent(new Event('input', { bubbles: true }));
38
+ });
39
+
40
+ this._on(this._refs.enhanceBtn, 'click', () => {
41
+ const originalEnhance = this._sourceTextarea.parentElement?.querySelector('.moodboard-chat__input-icon-btn--enhance-prompt');
42
+ if (originalEnhance) originalEnhance.click();
43
+ });
44
+ }
45
+
46
+ show() {
47
+ this._refs.textarea.value = this._sourceTextarea.value;
48
+ this._refs.overlay.classList.add('is-visible');
49
+ this._isVisible = true;
50
+
51
+ setTimeout(() => {
52
+ this._refs.textarea.focus();
53
+ this._refs.textarea.selectionStart = this._refs.textarea.value.length;
54
+ }, 10);
55
+ }
56
+
57
+ hide() {
58
+ this._refs.overlay.classList.remove('is-visible');
59
+ this._isVisible = false;
60
+ this._sourceTextarea.focus();
61
+ }
62
+
63
+ destroy() {
64
+ for (const off of this._listeners) off();
65
+ this._listeners = [];
66
+ if (this._refs?.overlay && this._refs.overlay.parentNode === this._container) {
67
+ this._container.removeChild(this._refs.overlay);
68
+ }
69
+ this._refs = null;
70
+ }
71
+
72
+ _on(el, type, handler) {
73
+ el.addEventListener(type, handler);
74
+ this._listeners.push(() => el.removeEventListener(type, handler));
75
+ }
76
+
77
+ _buildDom() {
78
+ const overlay = document.createElement('div');
79
+ overlay.className = 'moodboard-chat__extended-overlay';
80
+
81
+ const modal = document.createElement('div');
82
+ modal.className = 'moodboard-chat__extended-modal';
83
+
84
+ const header = document.createElement('div');
85
+ header.className = 'moodboard-chat__extended-header';
86
+
87
+ const title = document.createElement('div');
88
+ title.className = 'moodboard-chat__extended-title';
89
+ title.textContent = 'Запрос';
90
+
91
+ const closeBtn = document.createElement('button');
92
+ closeBtn.type = 'button';
93
+ closeBtn.className = 'moodboard-chat__extended-close';
94
+ closeBtn.innerHTML = ICONS.close || 'Закрыть';
95
+
96
+ header.appendChild(title);
97
+ header.appendChild(closeBtn);
98
+
99
+ const body = document.createElement('div');
100
+ body.className = 'moodboard-chat__extended-body';
101
+
102
+ const textarea = document.createElement('textarea');
103
+ textarea.className = 'moodboard-chat__extended-textarea';
104
+ textarea.placeholder = 'Опишите то, что хотите сгенерировать';
105
+
106
+ const actions = document.createElement('div');
107
+ actions.className = 'moodboard-chat__extended-actions';
108
+
109
+ const clearBtn = document.createElement('button');
110
+ clearBtn.type = 'button';
111
+ clearBtn.className = 'moodboard-chat__extended-clear';
112
+ clearBtn.textContent = 'Очистить';
113
+
114
+ const enhanceBtn = document.createElement('button');
115
+ enhanceBtn.type = 'button';
116
+ enhanceBtn.className = 'moodboard-chat__extended-enhance';
117
+ enhanceBtn.innerHTML = `<span class="moodboard-chat__extended-enhance-icon">${ICONS.enhancePrompt}</span> Улучшить`;
118
+
119
+ actions.appendChild(clearBtn);
120
+ actions.appendChild(enhanceBtn);
121
+
122
+ body.appendChild(textarea);
123
+ body.appendChild(actions);
124
+
125
+ modal.appendChild(header);
126
+ modal.appendChild(body);
127
+ overlay.appendChild(modal);
128
+
129
+ return { overlay, modal, header, title, closeBtn, body, textarea, clearBtn, enhanceBtn };
130
+ }
131
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Рендер ленты сообщений чата.
3
+ *
4
+ * Одна ответственность: превратить массив messages в DOM с автоскроллом.
5
+ * Не управляет состоянием, не слушает события — только render(messages).
6
+ */
7
+
8
+ const ROLE_LABELS = {
9
+ user: 'Вы',
10
+ assistant: 'Ассистент',
11
+ system: 'Системный'
12
+ };
13
+
14
+ export class ChatMessageList {
15
+ /**
16
+ * @param {HTMLElement} root - контейнер с классом .moodboard-chat__history
17
+ */
18
+ constructor(root) {
19
+ this._root = root;
20
+ this._lastCount = 0;
21
+ }
22
+
23
+ /**
24
+ * @param {Array<{id: string, role: string, content: string, pending?: boolean, error?: string, imageBase64?: string, mimeType?: string}>} messages
25
+ */
26
+ render(messages) {
27
+ const visible = messages.filter((m) => m.role !== 'system');
28
+
29
+ if (visible.length === 0) {
30
+ this._root.classList.remove('is-visible');
31
+ this._root.replaceChildren();
32
+ this._lastCount = 0;
33
+ return;
34
+ }
35
+
36
+ this._root.classList.add('is-visible');
37
+
38
+ const fragment = document.createDocumentFragment();
39
+ for (const msg of visible) {
40
+ fragment.appendChild(this._renderMessage(msg));
41
+ }
42
+ this._root.replaceChildren(fragment);
43
+
44
+ if (visible.length !== this._lastCount) {
45
+ this._scrollToBottom();
46
+ } else {
47
+ this._scrollToBottomIfNearBottom();
48
+ }
49
+ this._lastCount = visible.length;
50
+ }
51
+
52
+ _renderMessage(msg) {
53
+ const wrap = document.createElement('div');
54
+ wrap.className = `moodboard-chat__msg moodboard-chat__msg--${msg.role}`;
55
+ if (msg.error) wrap.classList.add('moodboard-chat__msg--error');
56
+
57
+ const role = document.createElement('div');
58
+ role.className = 'moodboard-chat__msg-role';
59
+ role.textContent = ROLE_LABELS[msg.role] || msg.role;
60
+ wrap.appendChild(role);
61
+
62
+ const body = document.createElement('div');
63
+ body.className = 'moodboard-chat__msg-body';
64
+
65
+ if (msg.error) {
66
+ body.textContent = msg.error;
67
+ } else if (msg.imageBase64) {
68
+ body.textContent = msg.content || 'Изображение добавлено на доску.';
69
+ } else if (msg.pending && !msg.content) {
70
+ body.textContent = '…';
71
+ } else {
72
+ body.textContent = msg.content;
73
+ if (msg.pending) {
74
+ const cursor = document.createElement('span');
75
+ cursor.className = 'moodboard-chat__msg-cursor';
76
+ body.appendChild(cursor);
77
+ }
78
+ }
79
+
80
+ wrap.appendChild(body);
81
+ return wrap;
82
+ }
83
+
84
+ _scrollToBottom() {
85
+ this._root.scrollTop = this._root.scrollHeight;
86
+ }
87
+
88
+ _scrollToBottomIfNearBottom() {
89
+ const distanceFromBottom = this._root.scrollHeight - this._root.scrollTop - this._root.clientHeight;
90
+ if (distanceFromBottom < 80) this._scrollToBottom();
91
+ }
92
+ }