@sequent-org/moodboard 1.4.28 → 1.4.30

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sequent-org/moodboard",
3
- "version": "1.4.28",
3
+ "version": "1.4.30",
4
4
  "type": "module",
5
5
  "description": "Interactive moodboard",
6
6
  "main": "./src/index.js",
@@ -43,7 +43,7 @@ export class ChatSessionController {
43
43
  this._abort = null;
44
44
 
45
45
  this._state = {
46
- messages: this._history.load(),
46
+ messages: this._history.load().map((m) => (m.pending ? { ...m, pending: false, error: m.error || 'Прервано' } : m)),
47
47
  providerId: 'yandex-art',
48
48
  presetId: DEFAULT_PRESET_ID,
49
49
  settings: this._loadSettings(),
@@ -112,18 +112,23 @@ export class ChatSessionController {
112
112
  /**
113
113
  * Отправляет user-сообщение и создаёт изображение через YandexART.
114
114
  * @param {string} text
115
- * @param {{widthRatio?: number, heightRatio?: number, model?: string}} [options]
115
+ * @param {{widthRatio?: number, heightRatio?: number, model?: string, imageCount?: number}} [options]
116
116
  */
117
117
  async send(text, options = {}) {
118
118
  const trimmed = (text || '').trim();
119
119
  if (!trimmed || this._state.status === 'streaming') return;
120
120
 
121
+ const imageCount = normalizeImageCount(options.imageCount);
121
122
  const userMsg = makeMessage('user', trimmed);
122
- const assistantMsg = makeMessage('assistant', '', { provider: 'yandex-art', pending: true, kind: 'image' });
123
+ const assistantMsgs = Array.from({ length: imageCount }, (_, index) => makeMessage(
124
+ 'assistant',
125
+ imageCount > 1 ? `Генерируется изображение ${index + 1} из ${imageCount}…` : '',
126
+ { provider: 'yandex-art', pending: true, kind: 'image' }
127
+ ));
123
128
 
124
129
  this._state = {
125
130
  ...this._state,
126
- messages: [...this._state.messages, userMsg, assistantMsg],
131
+ messages: [...this._state.messages, userMsg, ...assistantMsgs],
127
132
  status: 'streaming',
128
133
  error: null
129
134
  };
@@ -132,31 +137,54 @@ export class ChatSessionController {
132
137
 
133
138
  const abort = new AbortController();
134
139
  this._abort = abort;
140
+ let lastError = null;
135
141
 
136
142
  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 });
143
+ await Promise.all(
144
+ assistantMsgs.map((assistantMsg, index) => {
145
+ if (abort.signal.aborted) {
146
+ lastError = 'Отменено';
147
+ this._updateAssistant(assistantMsg.id, { error: lastError });
148
+ return Promise.resolve();
149
+ }
150
+
151
+ return this._client
152
+ .generateImage({
153
+ prompt: trimmed,
154
+ widthRatio: options.widthRatio,
155
+ heightRatio: options.heightRatio,
156
+ model: options.model,
157
+ signal: abort.signal
158
+ })
159
+ .then((result) => {
160
+ this._updateAssistant(assistantMsg.id, {
161
+ error: null,
162
+ imageBase64: result.imageBase64,
163
+ mimeType: result.mimeType,
164
+ operationId: result.operationId,
165
+ content: imageCount > 1 ? `Изображение ${index + 1} из ${imageCount} добавлено на доску.` : ''
166
+ });
167
+ })
168
+ .catch((err) => {
169
+ const msg = err?.name === 'AbortError' ? 'Отменено' : (err?.message || 'Ошибка запроса');
170
+ lastError = msg;
171
+ this._updateAssistant(assistantMsg.id, { error: msg });
172
+ });
173
+ })
174
+ );
154
175
  } finally {
155
176
  this._abort = null;
177
+ this._state = {
178
+ ...this._state,
179
+ status: lastError ? 'error' : 'idle',
180
+ error: lastError
181
+ };
182
+ this._history.save(this._state.messages);
183
+ this._emit();
156
184
  }
157
185
  }
158
186
 
159
- _finalizeAssistant(id, { error, imageBase64, mimeType, operationId }) {
187
+ _updateAssistant(id, { error, imageBase64, mimeType, operationId, content }) {
160
188
  const messages = this._state.messages.map((m) =>
161
189
  m.id === id
162
190
  ? {
@@ -165,15 +193,14 @@ export class ChatSessionController {
165
193
  error: error || undefined,
166
194
  imageBase64: imageBase64 || m.imageBase64,
167
195
  mimeType: mimeType || m.mimeType,
168
- operationId: operationId || m.operationId
196
+ operationId: operationId || m.operationId,
197
+ content: content ?? m.content
169
198
  }
170
199
  : m
171
200
  );
172
201
  this._state = {
173
202
  ...this._state,
174
- messages,
175
- status: error ? 'error' : 'idle',
176
- error: error || null
203
+ messages
177
204
  };
178
205
  this._history.save(messages);
179
206
  this._emit();
@@ -218,3 +245,12 @@ function makeMessage(role, content, extra = {}) {
218
245
  ...extra
219
246
  };
220
247
  }
248
+
249
+ function normalizeImageCount(value) {
250
+ const count = Number.parseInt(value, 10);
251
+ if (!Number.isFinite(count)) {
252
+ return 1;
253
+ }
254
+
255
+ return Math.min(Math.max(count, 1), 4);
256
+ }
@@ -77,6 +77,13 @@ export class ChatComposer {
77
77
  this._textarea.focus();
78
78
  }
79
79
 
80
+ addAttachment(file) {
81
+ if (!file) return;
82
+ this._attachments.push(file);
83
+ this._renderAttachmentsPreview();
84
+ this._refreshSendState();
85
+ }
86
+
80
87
  destroy() {
81
88
  for (const off of this._listeners) off();
82
89
  this._listeners = [];
@@ -51,6 +51,13 @@ const COUNT_OPTIONS = [
51
51
  { id: '4', label: '4 Изображения', icon: COUNT_ICONS[4] },
52
52
  ];
53
53
 
54
+ const BOARD_IMAGE_WIDTH = 300;
55
+ const BOARD_IMAGE_STEP = 320;
56
+ const BOARD_IMAGE_GAP = BOARD_IMAGE_STEP - BOARD_IMAGE_WIDTH;
57
+ // Скорость перестановки AI-изображений на доске и въезда заглушек регулируется здесь.
58
+ const BOARD_IMAGE_REARRANGE_MS = 520;
59
+ const REFERENCE_DRAG_PREVIEW_SIZE = 96;
60
+
54
61
  const MODEL_OPTIONS = [
55
62
  {
56
63
  id: 'auto',
@@ -129,14 +136,15 @@ export class ChatWindow {
129
136
  this._unsubscribe = null;
130
137
  this._attached = false;
131
138
  this._boardImageMessageIds = new Set();
132
- // Упорядоченный список ID объектов на доске, размещённых через AI-генерацию.
133
- // Используется для сдвига предыдущих изображений влево при новой генерации.
134
- this._boardAiImageIds = [];
135
- this._onBoardObjectCreated = (data) => {
136
- if (data?.objectData?.properties?.name === 'ai-generated.jpg') {
137
- this._boardAiImageIds.push(data.objectId);
138
- }
139
- };
139
+ this._shiftedForImageBatchKeys = new Set();
140
+ this._pendingOverlayEls = [];
141
+ this._pendingOverlayMessageIds = new Set();
142
+ this._boardImageShiftAnimations = new Map();
143
+ this._boardCursor = null;
144
+ this._draggedReferenceObject = null;
145
+ this._draggedReferenceStartPosition = null;
146
+ this._referenceDragPreview = null;
147
+ this._referenceDragHandlers = null;
140
148
  }
141
149
 
142
150
  attach() {
@@ -162,6 +170,7 @@ export class ChatWindow {
162
170
  }
163
171
  );
164
172
  this._composer.attach();
173
+ this._attachReferenceDragEvents();
165
174
 
166
175
  this._extendedPromptModal = new ChatExtendedPromptModal(
167
176
  this._container,
@@ -230,8 +239,6 @@ export class ChatWindow {
230
239
  );
231
240
  this._countMenu.attach();
232
241
 
233
- this._boardCore?.eventBus?.on?.(Events.Object.Created, this._onBoardObjectCreated);
234
-
235
242
  const initialState = this._session.getState();
236
243
  this._markExistingBoardImages(initialState.messages);
237
244
  this._unsubscribe = this._session.subscribe((state) => this._render(state));
@@ -244,9 +251,13 @@ export class ChatWindow {
244
251
 
245
252
  detach() {
246
253
  if (!this._attached) return;
254
+ this._clearPendingOverlays();
255
+ this._cancelBoardImageShiftAnimations();
256
+ this._clearReferenceDragState();
247
257
  if (this._unsubscribe) { this._unsubscribe(); this._unsubscribe = null; }
248
- this._boardCore?.eventBus?.off?.(Events.Object.Created, this._onBoardObjectCreated);
249
- this._boardAiImageIds = [];
258
+ this._detachReferenceDragEvents();
259
+ this._shiftedForImageBatchKeys.clear();
260
+ this._pendingOverlayMessageIds.clear();
250
261
  this._composer?.destroy();
251
262
  this._extendedPromptModal?.destroy();
252
263
  this._contentTypeMenu?.destroy();
@@ -312,6 +323,61 @@ export class ChatWindow {
312
323
  this._countMenu.refresh();
313
324
  this._updateCountPillIcon();
314
325
  this._composer.setStreaming(state.status === 'streaming');
326
+ this._updatePendingImages(state.status === 'streaming' ? state.messages : []);
327
+ }
328
+
329
+ _updatePendingImages(messages) {
330
+ this._clearPendingOverlays();
331
+
332
+ const pending = (messages || []).filter((m) => m.pending && m.kind === 'image');
333
+ if (pending.length === 0) return;
334
+
335
+ const world = this._boardCore?.pixi?.worldLayer || this._boardCore?.pixi?.app?.stage;
336
+ const s = world?.scale?.x || 1;
337
+
338
+ this._shiftExistingImagesForBatch(messages, pending[0].id, s);
339
+
340
+ const [wr, hr] = parseFormatRatio(this._formatId);
341
+ const ratio = wr / hr;
342
+ const wScreen = Math.round(BOARD_IMAGE_WIDTH * s);
343
+ const hScreen = Math.round(wScreen / ratio);
344
+
345
+ for (const message of pending) {
346
+ const slot = this._getImageBatchSlot(messages, message.id, s);
347
+ const left = Math.round(slot.x - wScreen / 2);
348
+ const top = Math.round(slot.y - hScreen / 2);
349
+
350
+ const overlay = document.createElement('div');
351
+ overlay.className = 'moodboard-chat__pending-overlay';
352
+ overlay.style.cssText = `left:${left}px;top:${top}px;width:${wScreen}px;height:${hScreen}px`;
353
+ overlay.style.setProperty('--moodboard-chat-board-animation-ms', `${BOARD_IMAGE_REARRANGE_MS}ms`);
354
+ overlay.style.setProperty('--moodboard-chat-pending-enter-x', `${Math.round(BOARD_IMAGE_STEP * s)}px`);
355
+
356
+ if (!this._pendingOverlayMessageIds.has(message.id)) {
357
+ overlay.classList.add('moodboard-chat__pending-overlay--enter');
358
+ this._pendingOverlayMessageIds.add(message.id);
359
+ this._scheduleAnimationFrame(() => {
360
+ if (overlay.isConnected) {
361
+ overlay.classList.add('moodboard-chat__pending-overlay--entered');
362
+ }
363
+ });
364
+ }
365
+
366
+ const label = document.createElement('span');
367
+ label.className = 'moodboard-chat__pending-image-label';
368
+ label.textContent = 'В процессе...';
369
+ overlay.appendChild(label);
370
+
371
+ document.body.appendChild(overlay);
372
+ this._pendingOverlayEls.push(overlay);
373
+ }
374
+ }
375
+
376
+ _clearPendingOverlays() {
377
+ for (const el of this._pendingOverlayEls) {
378
+ el.remove();
379
+ }
380
+ this._pendingOverlayEls = [];
315
381
  }
316
382
 
317
383
  _getImageRequestOptions() {
@@ -319,52 +385,340 @@ export class ChatWindow {
319
385
  return {
320
386
  widthRatio,
321
387
  heightRatio,
322
- model: this._modelId === 'yandex' ? 'yandex-art' : undefined
388
+ model: this._modelId === 'yandex' ? 'yandex-art' : undefined,
389
+ imageCount: parseImageCount(this._countId)
323
390
  };
324
391
  }
325
392
 
326
- _addImageToBoard(msg) {
327
- if (!this._boardCore?.eventBus) return;
328
- const dataUrl = `data:${msg.mimeType || 'image/jpeg'};base64,${msg.imageBase64}`;
329
- const view = this._boardCore.pixi?.app?.view;
330
- const world = this._boardCore.pixi?.worldLayer || this._boardCore.pixi?.app?.stage;
331
- const s = world?.scale?.x || 1;
393
+ _attachReferenceDragEvents() {
394
+ const eventBus = this._boardCore?.eventBus;
395
+ if (!eventBus || typeof eventBus.on !== 'function' || this._referenceDragHandlers) return;
332
396
 
333
- // Сдвигаем все ранее размещённые AI-изображения влево на 320 экранных пикселей
334
- // (в мировых единицах: 320 / масштаб), чтобы новое изображение всегда появлялось
335
- // на фиксированной базовой позиции, не перекрывая предыдущие.
336
- if (this._boardAiImageIds.length > 0) {
337
- const worldShift = Math.round(320 / s);
338
- const objects = this._boardCore.state?.state?.objects;
339
- for (const id of this._boardAiImageIds) {
340
- const obj = objects?.find((o) => o.id === id);
341
- if (obj?.position) {
342
- this._boardCore.updateObjectPositionDirect?.(
343
- id,
344
- { x: Math.round(obj.position.x - worldShift), y: obj.position.y },
345
- { snap: false }
346
- );
347
- }
397
+ const onCursorMove = ({ x, y } = {}) => {
398
+ if (Number.isFinite(x) && Number.isFinite(y)) {
399
+ this._boardCursor = { x, y };
400
+ this._updateReferenceDragPreview();
348
401
  }
402
+ };
403
+ const onDragStart = (data) => {
404
+ this._handleReferenceDragStart(data);
405
+ };
406
+ const onDragEnd = (data) => {
407
+ void this._handleReferenceDragEnd(data);
408
+ };
409
+
410
+ this._referenceDragHandlers = { onCursorMove, onDragStart, onDragEnd };
411
+ eventBus.on(Events.UI.CursorMove, onCursorMove);
412
+ eventBus.on(Events.Tool.DragStart, onDragStart);
413
+ eventBus.on(Events.Tool.DragEnd, onDragEnd);
414
+ }
415
+
416
+ _detachReferenceDragEvents() {
417
+ const eventBus = this._boardCore?.eventBus;
418
+ const handlers = this._referenceDragHandlers;
419
+ if (!eventBus || typeof eventBus.off !== 'function' || !handlers) return;
420
+
421
+ eventBus.off(Events.UI.CursorMove, handlers.onCursorMove);
422
+ eventBus.off(Events.Tool.DragStart, handlers.onDragStart);
423
+ eventBus.off(Events.Tool.DragEnd, handlers.onDragEnd);
424
+ this._referenceDragHandlers = null;
425
+ }
426
+
427
+ _handleReferenceDragStart(data = {}) {
428
+ const objectId = data?.object;
429
+ const object = this._boardCore?.state?.state?.objects?.find((item) => item?.id === objectId);
430
+ this._draggedReferenceObject = isReferenceImageObject(object) ? object : null;
431
+ this._draggedReferenceStartPosition = this._draggedReferenceObject?.position
432
+ ? { ...this._draggedReferenceObject.position }
433
+ : null;
434
+ this._updateReferenceDragPreview();
435
+ }
436
+
437
+ async _handleReferenceDragEnd(data = {}) {
438
+ const isDropTarget = this._isBoardCursorOverInput();
439
+ const objectId = data?.object;
440
+ const object = this._boardCore?.state?.state?.objects?.find((item) => item?.id === objectId);
441
+ const startPosition = this._draggedReferenceStartPosition;
442
+ this._clearReferenceDragState();
443
+ if (!isDropTarget || !isReferenceImageObject(object)) return null;
444
+
445
+ this._restoreReferenceObjectPosition(objectId, startPosition);
446
+ await this._addImageObjectAsReference(object);
447
+ }
448
+
449
+ _restoreReferenceObjectPosition(objectId, position) {
450
+ if (!objectId || !position) return;
451
+
452
+ const updatePosition = this._boardCore?.updateObjectPositionDirect;
453
+ if (typeof updatePosition === 'function') {
454
+ updatePosition.call(this._boardCore, objectId, position, { snap: false });
455
+ return;
349
456
  }
350
457
 
351
- // Новое изображение центрируется по горизонтали над панелью чата.
352
- // Якорь по вертикали — верхний край composer (всегда виден).
353
- // Объект создаётся с центром в (x,y), поэтому нижний край = y + h*s/2.
354
- // При w=300 мировых ед. и масштабе s≈1 нижний край ≈ y+150.
355
- // Чтобы нижний край изображения был на 100 px выше composer: y = composerTop - 250.
356
- const chatRect = this._refs?.root?.getBoundingClientRect?.();
458
+ const object = this._boardCore?.state?.state?.objects?.find((item) => item?.id === objectId);
459
+ if (object?.position) {
460
+ object.position = { ...position };
461
+ }
462
+ }
463
+
464
+ _updateReferenceDragPreview() {
465
+ const object = this._draggedReferenceObject;
466
+ if (!object || !this._isBoardCursorOverInput()) {
467
+ this._hideReferenceDragPreview();
468
+ return;
469
+ }
470
+
471
+ const src = getImageObjectSource(object);
472
+ if (!src) {
473
+ this._hideReferenceDragPreview();
474
+ return;
475
+ }
476
+
477
+ const preview = this._ensureReferenceDragPreview(object, src);
478
+ const { clientX, clientY } = this._getBoardCursorClientPosition();
479
+ preview.style.left = `${Math.round(clientX)}px`;
480
+ preview.style.top = `${Math.round(clientY)}px`;
481
+ this._refs?.textarea?.closest?.('.moodboard-chat__input-row')?.classList.add('is-reference-drop-target');
482
+ }
483
+
484
+ _ensureReferenceDragPreview(object, src) {
485
+ if (!this._referenceDragPreview) {
486
+ const preview = document.createElement('img');
487
+ preview.className = 'moodboard-chat__reference-drag-preview';
488
+ preview.alt = getImageObjectFileName(object, src);
489
+ preview.width = REFERENCE_DRAG_PREVIEW_SIZE;
490
+ preview.height = REFERENCE_DRAG_PREVIEW_SIZE;
491
+ document.body.appendChild(preview);
492
+ this._referenceDragPreview = preview;
493
+ }
494
+
495
+ if (this._referenceDragPreview.src !== src) {
496
+ this._referenceDragPreview.src = src;
497
+ }
498
+ this._referenceDragPreview.alt = getImageObjectFileName(object, src);
499
+
500
+ return this._referenceDragPreview;
501
+ }
502
+
503
+ _hideReferenceDragPreview() {
504
+ this._referenceDragPreview?.remove();
505
+ this._referenceDragPreview = null;
506
+ this._refs?.textarea?.closest?.('.moodboard-chat__input-row')?.classList.remove('is-reference-drop-target');
507
+ }
508
+
509
+ _clearReferenceDragState() {
510
+ this._draggedReferenceObject = null;
511
+ this._draggedReferenceStartPosition = null;
512
+ this._boardCursor = null;
513
+ this._hideReferenceDragPreview();
514
+ }
515
+
516
+ _isBoardCursorOverInput() {
517
+ const cursor = this._boardCursor;
518
+ const inputRow = this._refs?.textarea?.closest?.('.moodboard-chat__input-row');
519
+ if (!cursor || !inputRow) return false;
520
+
521
+ const containerRect = this._container.getBoundingClientRect?.();
522
+ const rect = inputRow.getBoundingClientRect();
523
+ const { clientX, clientY } = this._getBoardCursorClientPosition(containerRect);
524
+
525
+ return clientX >= rect.left
526
+ && clientX <= rect.right
527
+ && clientY >= rect.top
528
+ && clientY <= rect.bottom;
529
+ }
530
+
531
+ _getBoardCursorClientPosition(containerRect = null) {
532
+ const rect = containerRect || this._container.getBoundingClientRect?.();
533
+ const cursor = this._boardCursor || { x: 0, y: 0 };
534
+
535
+ return {
536
+ clientX: (rect?.left || 0) + cursor.x,
537
+ clientY: (rect?.top || 0) + cursor.y
538
+ };
539
+ }
540
+
541
+ async _addImageObjectAsReference(object) {
542
+ if (!object || !this._composer) return;
543
+
544
+ try {
545
+ const file = await createFileFromImageObject(object);
546
+ if (!file) return;
547
+ this._composer.addAttachment(file);
548
+ this._composer.focus();
549
+ } catch (err) {
550
+ console.warn('[ChatWindow] cannot add selected image reference:', err);
551
+ }
552
+ }
553
+
554
+ _getImageBatchSlot(messages, messageId, scale = 1) {
555
+ const batch = findImageGenerationBatch(messages, messageId);
556
+ const anchor = this._getImageGroupAnchor();
557
+ const step = Math.round(BOARD_IMAGE_STEP * scale);
558
+ const count = Math.max(batch.count, 1);
559
+ const index = Math.min(Math.max(batch.index, 0), count - 1);
560
+ const leftmostCenter = anchor.x - ((count - 1) * step) / 2;
561
+
562
+ return {
563
+ x: Math.round(leftmostCenter + index * step),
564
+ y: anchor.y
565
+ };
566
+ }
567
+
568
+ _getImageGroupAnchor() {
357
569
  const composerRect = this._refs?.composer?.getBoundingClientRect?.();
358
- const x = chatRect
359
- ? Math.round(chatRect.left + chatRect.width / 2)
360
- : (view ? Math.round(view.clientWidth / 2) : 400);
361
- const y = composerRect
362
- ? Math.round(composerRect.top - 250)
363
- : (chatRect
364
- ? Math.round(chatRect.top - 150)
365
- : (view ? Math.round(view.clientHeight * 0.3) : 200));
570
+ if (composerRect) {
571
+ return {
572
+ x: Math.round(composerRect.left + composerRect.width / 2),
573
+ y: Math.round(composerRect.top - 250)
574
+ };
575
+ }
576
+
577
+ const chatRect = this._refs?.root?.getBoundingClientRect?.();
578
+ if (chatRect) {
579
+ return {
580
+ x: Math.round(chatRect.left + chatRect.width / 2),
581
+ y: Math.round(chatRect.top - 150)
582
+ };
583
+ }
584
+
585
+ return { x: 400, y: 200 };
586
+ }
587
+
588
+ _shiftExistingImagesForBatch(messages, messageId, scale = 1) {
589
+ const batch = findImageGenerationBatch(messages, messageId);
590
+ const batchKey = getImageGenerationBatchKey(batch);
591
+ if (this._shiftedForImageBatchKeys.has(batchKey)) return;
592
+
593
+ this._shiftedForImageBatchKeys.add(batchKey);
594
+ this._shiftBoardAiImagesLeft(this._getImageBatchWorldBounds(messages, messageId, scale));
595
+ }
596
+
597
+ _getImageBatchWorldBounds(messages, messageId, scale = 1) {
598
+ const batch = findImageGenerationBatch(messages, messageId);
599
+ const anchor = this._getImageGroupAnchor();
600
+ const world = this._boardCore?.pixi?.worldLayer || this._boardCore?.pixi?.app?.stage;
601
+ const s = scale || 1;
602
+ const step = Math.round(BOARD_IMAGE_STEP * s);
603
+ const width = Math.round(BOARD_IMAGE_WIDTH * s);
604
+ const count = Math.max(batch.count, 1);
605
+ const leftScreen = anchor.x - ((count - 1) * step) / 2 - width / 2;
606
+ const rightScreen = anchor.x + ((count - 1) * step) / 2 + width / 2;
607
+
608
+ return {
609
+ left: Math.round((leftScreen - (world?.x || 0)) / s),
610
+ right: Math.round((rightScreen - (world?.x || 0)) / s)
611
+ };
612
+ }
613
+
614
+ _shiftBoardAiImagesLeft(nextBatchBounds) {
615
+ const aiObjects = this._getBoardAiImageObjects();
616
+ if (aiObjects.length === 0 || !nextBatchBounds) return;
617
+
618
+ const existingRight = Math.max(...aiObjects.map((object) => object.position.x + getBoardObjectWidth(object)));
619
+ const shift = Math.ceil(existingRight + BOARD_IMAGE_GAP - nextBatchBounds.left);
620
+ if (shift <= 0) return;
621
+
622
+ const ids = new Set(aiObjects.map((object) => object.id));
623
+ const objects = this._boardCore?.state?.state?.objects;
624
+ for (const id of ids) {
625
+ const obj = objects?.find((item) => item.id === id);
626
+ if (obj?.position) {
627
+ this._animateBoardImageToPosition(
628
+ id,
629
+ obj.position,
630
+ { x: Math.round(obj.position.x - shift), y: obj.position.y }
631
+ );
632
+ }
633
+ }
634
+ }
635
+
636
+ _animateBoardImageToPosition(id, fromPosition, toPosition) {
637
+ const updatePosition = this._boardCore?.updateObjectPositionDirect;
638
+ if (!id || typeof updatePosition !== 'function' || !fromPosition || !toPosition) return;
639
+
640
+ this._cancelBoardImageShiftAnimation(id);
641
+
642
+ if (BOARD_IMAGE_REARRANGE_MS <= 0 || prefersReducedMotion()) {
643
+ updatePosition.call(this._boardCore, id, toPosition, { snap: false });
644
+ return;
645
+ }
646
+
647
+ const from = {
648
+ x: Number(fromPosition.x) || 0,
649
+ y: Number(fromPosition.y) || 0
650
+ };
651
+ const to = {
652
+ x: Math.round(Number(toPosition.x) || 0),
653
+ y: Math.round(Number(toPosition.y) || 0)
654
+ };
655
+ const startAt = getAnimationTime();
656
+ const record = { frame: null };
657
+
658
+ const step = (now) => {
659
+ const progress = Math.min(Math.max((now - startAt) / BOARD_IMAGE_REARRANGE_MS, 0), 1);
660
+ const eased = easeOutCubic(progress);
661
+ const next = {
662
+ x: Math.round(from.x + (to.x - from.x) * eased),
663
+ y: Math.round(from.y + (to.y - from.y) * eased)
664
+ };
665
+
666
+ updatePosition.call(this._boardCore, id, next, { snap: false });
667
+
668
+ if (progress < 1) {
669
+ record.frame = this._scheduleAnimationFrame(step);
670
+ return;
671
+ }
672
+
673
+ updatePosition.call(this._boardCore, id, to, { snap: false });
674
+ this._boardImageShiftAnimations.delete(id);
675
+ };
676
+
677
+ record.frame = this._scheduleAnimationFrame(step);
678
+ this._boardImageShiftAnimations.set(id, record);
679
+ }
680
+
681
+ _cancelBoardImageShiftAnimation(id) {
682
+ const record = this._boardImageShiftAnimations.get(id);
683
+ if (!record) return;
684
+
685
+ cancelAnimationFrameSafe(record.frame);
686
+ this._boardImageShiftAnimations.delete(id);
687
+ }
688
+
689
+ _cancelBoardImageShiftAnimations() {
690
+ for (const id of this._boardImageShiftAnimations.keys()) {
691
+ this._cancelBoardImageShiftAnimation(id);
692
+ }
693
+ }
694
+
695
+ _scheduleAnimationFrame(callback) {
696
+ if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
697
+ return window.requestAnimationFrame(callback);
698
+ }
699
+
700
+ return setTimeout(() => callback(getAnimationTime()), 16);
701
+ }
702
+
703
+ _getBoardAiImageObjects() {
704
+ const objects = this._boardCore?.state?.state?.objects;
705
+ if (!Array.isArray(objects)) return [];
706
+
707
+ return objects.filter((object) => isBoardAiImageObject(object));
708
+ }
709
+
710
+ _addImageToBoard(msg) {
711
+ if (!this._boardCore?.eventBus) return;
712
+ const dataUrl = `data:${msg.mimeType || 'image/jpeg'};base64,${msg.imageBase64}`;
713
+ const world = this._boardCore.pixi?.worldLayer || this._boardCore.pixi?.app?.stage;
714
+ const s = world?.scale?.x || 1;
715
+ const messages = this._session.getState().messages;
716
+ this._shiftExistingImagesForBatch(messages, msg.id, s);
717
+ const slot = this._getImageBatchSlot(messages, msg.id, s);
718
+
366
719
  this._boardCore.eventBus.emit(Events.UI.PasteImageAt, {
367
- x, y,
720
+ x: slot.x,
721
+ y: slot.y,
368
722
  src: dataUrl,
369
723
  name: 'ai-generated.jpg',
370
724
  skipUpload: true
@@ -405,3 +759,161 @@ function parseFormatRatio(formatId) {
405
759
 
406
760
  return [width, height];
407
761
  }
762
+
763
+ function parseImageCount(countId) {
764
+ if (!countId || countId === 'auto') {
765
+ return 1;
766
+ }
767
+
768
+ const count = Number.parseInt(countId, 10);
769
+ if (!Number.isFinite(count)) {
770
+ return 1;
771
+ }
772
+
773
+ return Math.min(Math.max(count, 1), 4);
774
+ }
775
+
776
+ function findImageGenerationBatch(messages, messageId) {
777
+ const list = Array.isArray(messages) ? messages : [];
778
+ const targetIndex = list.findIndex((message) => message?.id === messageId);
779
+ if (targetIndex === -1) {
780
+ return { index: 0, count: 1 };
781
+ }
782
+
783
+ let start = targetIndex;
784
+ while (start > 0 && isImageGenerationMessage(list[start - 1])) {
785
+ start--;
786
+ }
787
+
788
+ let end = targetIndex;
789
+ while (end + 1 < list.length && isImageGenerationMessage(list[end + 1])) {
790
+ end++;
791
+ }
792
+
793
+ return {
794
+ index: targetIndex - start,
795
+ count: end - start + 1,
796
+ ids: list.slice(start, end + 1).map((message) => message.id)
797
+ };
798
+ }
799
+
800
+ function getImageGenerationBatchKey(batch) {
801
+ return batch.ids?.join('|') || 'unknown';
802
+ }
803
+
804
+ function isImageGenerationMessage(message) {
805
+ return message?.role === 'assistant'
806
+ && (message.kind === 'image' || message.pending || Boolean(message.imageBase64));
807
+ }
808
+
809
+ function isBoardAiImageObject(object) {
810
+ return Boolean(object?.id)
811
+ && object.type === 'image'
812
+ && object.properties?.name === 'ai-generated.jpg'
813
+ && object.position
814
+ && Number.isFinite(object.position.x);
815
+ }
816
+
817
+ function isReferenceImageObject(object) {
818
+ return Boolean(object?.id)
819
+ && (object.type === 'image' || object.type === 'revit-screenshot-img')
820
+ && typeof getImageObjectSource(object) === 'string';
821
+ }
822
+
823
+ async function createFileFromImageObject(object) {
824
+ const src = getImageObjectSource(object);
825
+ if (!src) return null;
826
+
827
+ const name = getImageObjectFileName(object, src);
828
+ const blob = src.startsWith('data:')
829
+ ? dataUrlToBlob(src)
830
+ : await fetchImageBlob(src);
831
+
832
+ return createNamedBlob(blob, name);
833
+ }
834
+
835
+ function getImageObjectSource(object) {
836
+ const src = object?.src || object?.properties?.src || object?.properties?.url || object?.url;
837
+ return typeof src === 'string' && src.trim() ? src.trim() : null;
838
+ }
839
+
840
+ function getImageObjectFileName(object, src) {
841
+ const explicitName = object?.properties?.name || object?.name;
842
+ if (typeof explicitName === 'string' && explicitName.trim()) {
843
+ return explicitName.trim();
844
+ }
845
+
846
+ if (!src.startsWith('data:')) {
847
+ const lastPathPart = src.split(/[?#]/)[0].split('/').pop();
848
+ if (lastPathPart) return lastPathPart;
849
+ }
850
+
851
+ return 'board-reference.png';
852
+ }
853
+
854
+ function dataUrlToBlob(dataUrl) {
855
+ const [meta = '', data = ''] = dataUrl.split(',');
856
+ const mimeMatch = meta.match(/^data:([^;]+)/);
857
+ const mimeType = mimeMatch?.[1] || 'image/png';
858
+ const isBase64 = /;base64/i.test(meta);
859
+ const binary = isBase64 ? atob(data) : decodeURIComponent(data);
860
+ const bytes = new Uint8Array(binary.length);
861
+
862
+ for (let i = 0; i < binary.length; i++) {
863
+ bytes[i] = binary.charCodeAt(i);
864
+ }
865
+
866
+ return new Blob([bytes], { type: mimeType });
867
+ }
868
+
869
+ async function fetchImageBlob(src) {
870
+ const response = await fetch(src);
871
+ if (!response.ok) {
872
+ throw new Error(`Cannot load image reference (${response.status})`);
873
+ }
874
+
875
+ return response.blob();
876
+ }
877
+
878
+ function createNamedBlob(blob, name) {
879
+ if (typeof File === 'function') {
880
+ return new File([blob], name, { type: blob.type || 'image/png' });
881
+ }
882
+
883
+ blob.name = name;
884
+ return blob;
885
+ }
886
+
887
+ function getBoardObjectWidth(object) {
888
+ const width = object?.width ?? object?.properties?.width ?? BOARD_IMAGE_WIDTH;
889
+ return Number.isFinite(width) ? Math.max(1, Math.round(width)) : BOARD_IMAGE_WIDTH;
890
+ }
891
+
892
+ function easeOutCubic(progress) {
893
+ return 1 - Math.pow(1 - progress, 3);
894
+ }
895
+
896
+ function getAnimationTime() {
897
+ if (typeof performance !== 'undefined' && typeof performance.now === 'function') {
898
+ return performance.now();
899
+ }
900
+
901
+ return Date.now();
902
+ }
903
+
904
+ function cancelAnimationFrameSafe(frame) {
905
+ if (!frame) return;
906
+
907
+ if (typeof window !== 'undefined' && typeof window.cancelAnimationFrame === 'function') {
908
+ window.cancelAnimationFrame(frame);
909
+ return;
910
+ }
911
+
912
+ clearTimeout(frame);
913
+ }
914
+
915
+ function prefersReducedMotion() {
916
+ return typeof window !== 'undefined'
917
+ && typeof window.matchMedia === 'function'
918
+ && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
919
+ }
@@ -28,17 +28,21 @@ export function buildChatDom() {
28
28
  statusBar.setAttribute('aria-atomic', 'true');
29
29
  statusBar.innerHTML = '<span class="moodboard-chat__status-bar-text">Идёт процесс генерации изображения…</span>';
30
30
 
31
+ const pendingImages = createDiv('moodboard-chat__pending-images');
32
+
31
33
  const rendererRefs = {
32
34
  root,
33
35
  history,
34
36
  composer,
35
- statusBar
37
+ statusBar,
38
+ pendingImages
36
39
  };
37
40
 
38
41
  composer.appendChild(buildInputRow(refs => Object.assign(rendererRefs, refs)));
39
42
  composer.appendChild(buildActionsRow(refs => Object.assign(rendererRefs, refs)));
40
43
 
41
44
  root.appendChild(history);
45
+ root.appendChild(pendingImages);
42
46
  root.appendChild(statusBar);
43
47
  root.appendChild(composer);
44
48
 
@@ -198,6 +198,13 @@
198
198
  flex-direction: column;
199
199
  gap: 0;
200
200
  padding-bottom: 10px;
201
+ border-radius: 10px;
202
+ transition: background-color 120ms ease, box-shadow 120ms ease;
203
+ }
204
+
205
+ .moodboard-chat__input-row.is-reference-drop-target {
206
+ background: rgba(99, 102, 241, 0.08);
207
+ box-shadow: inset 0 0 0 1px rgba(99, 102, 241, 0.22);
201
208
  }
202
209
 
203
210
  .moodboard-chat__textarea-row {
@@ -206,6 +213,19 @@
206
213
  gap: 8px;
207
214
  }
208
215
 
216
+ .moodboard-chat__reference-drag-preview {
217
+ position: fixed;
218
+ z-index: 2700;
219
+ width: 96px;
220
+ height: 96px;
221
+ object-fit: cover;
222
+ border-radius: 10px;
223
+ box-shadow: 0 14px 32px rgba(15, 23, 42, 0.24);
224
+ transform: translate(-50%, -50%) scale(0.92);
225
+ pointer-events: none;
226
+ opacity: 0.96;
227
+ }
228
+
209
229
  .moodboard-chat__input-row.has-attachments .moodboard-chat__textarea-row .moodboard-chat__pill-wrapper {
210
230
  position: absolute;
211
231
  top: 0;
@@ -661,6 +681,79 @@
661
681
  display: block;
662
682
  }
663
683
 
684
+ /* Контейнер блоков-заглушек при параллельной генерации изображений */
685
+ .moodboard-chat__pending-images {
686
+ display: none;
687
+ gap: 8px;
688
+ background: #ffffff;
689
+ border-radius: 12px;
690
+ padding: 8px;
691
+ margin-bottom: 8px;
692
+ box-shadow: 0 4px 24px rgba(0, 0, 0, 0.06);
693
+ }
694
+
695
+ .moodboard-chat__pending-images.is-visible {
696
+ display: flex;
697
+ }
698
+
699
+ .moodboard-chat__pending-image-block {
700
+ flex: 1;
701
+ min-height: 180px;
702
+ background: #6B7E87;
703
+ border-radius: 8px;
704
+ position: relative;
705
+ overflow: hidden;
706
+ }
707
+
708
+ /* Overlay-заглушка на полотне доски во время генерации изображения */
709
+ @keyframes moodboard-pending-shimmer {
710
+ 0% { background-position: 200% 0; }
711
+ 100% { background-position: -100% 0; }
712
+ }
713
+
714
+ .moodboard-chat__pending-overlay {
715
+ position: fixed;
716
+ --moodboard-chat-board-animation-ms: 520ms;
717
+ --moodboard-chat-pending-enter-x: 320px;
718
+ background: linear-gradient(
719
+ 90deg,
720
+ #5F7179 0%,
721
+ #7A8D96 50%,
722
+ #5F7179 100%
723
+ );
724
+ background-size: 200% 100%;
725
+ animation: moodboard-pending-shimmer 5.76s linear infinite;
726
+ border-radius: 12px;
727
+ overflow: hidden;
728
+ pointer-events: none;
729
+ z-index: 10;
730
+ transition:
731
+ transform var(--moodboard-chat-board-animation-ms) cubic-bezier(0.22, 1, 0.36, 1),
732
+ opacity var(--moodboard-chat-board-animation-ms) ease;
733
+ will-change: transform, opacity;
734
+ }
735
+
736
+ .moodboard-chat__pending-overlay--enter {
737
+ opacity: 0;
738
+ transform: translateX(var(--moodboard-chat-pending-enter-x));
739
+ }
740
+
741
+ .moodboard-chat__pending-overlay--entered {
742
+ opacity: 1;
743
+ transform: translateX(0);
744
+ }
745
+
746
+ .moodboard-chat__pending-image-label {
747
+ position: absolute;
748
+ bottom: 10px;
749
+ left: 12px;
750
+ font-family: 'GeistSans', 'GeistSans Fallback', 'Roboto', Arial, sans-serif;
751
+ font-size: 20px;
752
+ font-weight: 400;
753
+ color: #ffffff;
754
+ pointer-events: none;
755
+ }
756
+
664
757
  /* Попап настроек */
665
758
  .moodboard-chat__settings-popup {
666
759
  position: absolute;