@sequent-org/moodboard 1.4.25 → 1.4.27

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 (33) 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 +220 -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/tools/object-tools/selection/TextEditorInteractionController.js +10 -0
  20. package/src/ui/TextPropertiesPanel.js +7 -1
  21. package/src/ui/chat/ChatComposer.js +198 -0
  22. package/src/ui/chat/ChatExtendedPromptModal.js +131 -0
  23. package/src/ui/chat/ChatMessageList.js +92 -0
  24. package/src/ui/chat/ChatPillMenu.js +141 -0
  25. package/src/ui/chat/ChatSettingsPopup.js +171 -0
  26. package/src/ui/chat/ChatWindow.js +407 -0
  27. package/src/ui/chat/ChatWindowRenderer.js +214 -0
  28. package/src/ui/chat/icons.js +113 -0
  29. package/src/ui/styles/chat.css +920 -0
  30. package/src/ui/styles/index.css +1 -0
  31. package/src/ui/styles/workspace.css +73 -1
  32. package/src/ui/text-properties/TextPropertiesPanelState.js +2 -0
  33. 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
+ }
@@ -267,6 +267,16 @@ export function closeTextEditorFromState(controller, commit) {
267
267
  textarea.remove();
268
268
  }
269
269
  controller.textEditor = { active: false, objectId: null, textarea: null, world: null, objectType: 'text' };
270
+
271
+ // Синхронно с createTextEditorFinalize: UI (в т.ч. панель свойств текста) ждёт окончание редактирования.
272
+ if (objectType === 'note') {
273
+ controller.eventBus.emit(Events.UI.NoteEditEnd, { objectId: objectId || null });
274
+ showNotePixiText(controller, objectId);
275
+ } else {
276
+ controller.eventBus.emit(Events.UI.TextEditEnd, { objectId: objectId || null });
277
+ }
278
+ updateGlobalTextEditorHandlesLayer();
279
+
270
280
  if (!commitValue) {
271
281
  if (shouldDeleteEmptyNewCreation) {
272
282
  controller.eventBus.emit(Events.Tool.ObjectsDelete, { objects: [objectId] });
@@ -99,7 +99,10 @@ export class TextPropertiesPanel {
99
99
  this.panel = createTextPropertiesPanelRenderer(this);
100
100
  this.layer.appendChild(this.panel);
101
101
  bindTextPropertiesPanelControls(this);
102
+ }
103
+ if (!this._docMouseDownAttached) {
102
104
  document.addEventListener('mousedown', this._onDocMouseDown, true);
105
+ this._docMouseDownAttached = true;
103
106
  }
104
107
 
105
108
  this.panel.style.display = 'flex';
@@ -116,7 +119,10 @@ export class TextPropertiesPanel {
116
119
 
117
120
  this._hideColorDropdown();
118
121
  this._hideBgColorDropdown();
119
- document.removeEventListener('mousedown', this._onDocMouseDown, true);
122
+ if (this._docMouseDownAttached) {
123
+ document.removeEventListener('mousedown', this._onDocMouseDown, true);
124
+ this._docMouseDownAttached = false;
125
+ }
120
126
  }
121
127
 
122
128
  _toggleColorDropdown() {
@@ -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
+ }