@sequent-org/moodboard 1.4.33 → 1.4.36

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.33",
3
+ "version": "1.4.36",
4
4
  "type": "module",
5
5
  "description": "Interactive moodboard",
6
6
  "main": "./src/index.js",
@@ -286,7 +286,7 @@ export function setupClipboardFlow(core) {
286
286
  }
287
287
  });
288
288
 
289
- core.eventBus.on(Events.UI.PasteImageAt, async ({ x, y, src, name, skipUpload }) => {
289
+ core.eventBus.on(Events.UI.PasteImageAt, async ({ x, y, src, name, skipUpload, aiMessageId }) => {
290
290
  if (!src) return;
291
291
  const uploaded = await ensureServerImage({ src, name, skipUpload });
292
292
  if (!uploaded?.src) return;
@@ -312,6 +312,7 @@ export function setupClipboardFlow(core) {
312
312
  name: uploaded.name,
313
313
  width: w,
314
314
  height: h,
315
+ ...(aiMessageId ? { aiMessageId } : {}),
315
316
  ...revitPayload.properties
316
317
  };
317
318
  const createdData = core.createObject(
@@ -41,6 +41,7 @@ export class ChatSessionController {
41
41
  this._settingsStorage = settingsStorage || (typeof localStorage !== 'undefined' ? localStorage : null);
42
42
  this._listeners = new Set();
43
43
  this._abort = null;
44
+ this._aborts = new Map();
44
45
 
45
46
  this._state = {
46
47
  messages: this._history.load().map((m) => (m.pending ? { ...m, pending: false, error: m.error || 'Прервано' } : m)),
@@ -103,10 +104,11 @@ export class ChatSessionController {
103
104
  }
104
105
 
105
106
  abort() {
106
- if (this._abort) {
107
- try { this._abort.abort(); } catch { /* noop */ }
108
- this._abort = null;
107
+ for (const controller of this._aborts.values()) {
108
+ try { controller.abort(); } catch { /* noop */ }
109
109
  }
110
+ this._aborts.clear();
111
+ this._abort = null;
110
112
  }
111
113
 
112
114
  /**
@@ -116,14 +118,15 @@ export class ChatSessionController {
116
118
  */
117
119
  async send(text, options = {}) {
118
120
  const trimmed = (text || '').trim();
119
- if (!trimmed || this._state.status === 'streaming') return;
121
+ if (!trimmed) return;
120
122
 
121
123
  const imageCount = normalizeImageCount(options.imageCount);
124
+ const batchId = `batch_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
122
125
  const userMsg = makeMessage('user', trimmed);
123
126
  const assistantMsgs = Array.from({ length: imageCount }, (_, index) => makeMessage(
124
127
  'assistant',
125
128
  imageCount > 1 ? `Генерируется изображение ${index + 1} из ${imageCount}…` : '',
126
- { provider: 'yandex-art', pending: true, kind: 'image' }
129
+ { provider: 'yandex-art', pending: true, kind: 'image', batchId }
127
130
  ));
128
131
 
129
132
  this._state = {
@@ -136,6 +139,7 @@ export class ChatSessionController {
136
139
  this._emit();
137
140
 
138
141
  const abort = new AbortController();
142
+ this._aborts.set(batchId, abort);
139
143
  this._abort = abort;
140
144
  let lastError = null;
141
145
 
@@ -174,11 +178,13 @@ export class ChatSessionController {
174
178
  })
175
179
  );
176
180
  } finally {
177
- this._abort = null;
181
+ this._aborts.delete(batchId);
182
+ this._abort = this._aborts.size > 0 ? [...this._aborts.values()][this._aborts.size - 1] : null;
183
+ const stillStreaming = this._state.messages.some((m) => m.pending);
178
184
  this._state = {
179
185
  ...this._state,
180
- status: lastError ? 'error' : 'idle',
181
- error: lastError
186
+ status: stillStreaming ? 'streaming' : (lastError ? 'error' : 'idle'),
187
+ error: stillStreaming ? this._state.error : lastError
182
188
  };
183
189
  this._history.save(this._state.messages);
184
190
  this._emit();
@@ -47,7 +47,7 @@ export class ChatComposer {
47
47
  });
48
48
  this._on(this._send, 'click', () => {
49
49
  if (this._send.dataset.state === 'streaming') {
50
- this._handlers.onAbort?.();
50
+ if (this._hasContent()) this._submit();
51
51
  } else {
52
52
  this._submit();
53
53
  }
@@ -140,7 +140,6 @@ export class ChatComposer {
140
140
  const trimmed = text.trim();
141
141
  const hasAttachments = this._attachments.length > 0;
142
142
  if (!trimmed && !hasAttachments) return;
143
- if (this._send.dataset.state === 'streaming') return;
144
143
  const attachments = this._attachments.map((entry) => entry.file);
145
144
  this._textarea.value = '';
146
145
  this._attachments = [];
@@ -150,6 +149,10 @@ export class ChatComposer {
150
149
  this._handlers.onSubmit?.(trimmed, attachments);
151
150
  }
152
151
 
152
+ _hasContent() {
153
+ return this._textarea.value.trim().length > 0 || this._attachments.length > 0;
154
+ }
155
+
153
156
  _refreshSendState() {
154
157
  const hasText = this._textarea.value.trim().length > 0;
155
158
  const hasAttachments = this._attachments.length > 0;
@@ -4,6 +4,7 @@ import { ChatSessionController } from '../../services/ai/ChatSessionController.j
4
4
  import { Events } from '../../core/events/Events.js';
5
5
 
6
6
  import { buildChatDom } from './ChatWindowRenderer.js';
7
+ import { formatChatErrorForDisplay } from './formatChatError.js';
7
8
  import { ChatMessageList } from './ChatMessageList.js';
8
9
  import { ChatComposer } from './ChatComposer.js';
9
10
  import { ChatPillMenu } from './ChatPillMenu.js';
@@ -144,8 +145,12 @@ export class ChatWindow {
144
145
  this._pendingOverlays = new Map();
145
146
  this._pendingOverlayTimers = new Map();
146
147
  this._boardImageShiftAnimations = new Map();
147
- this._clearSelectionOnSendClick = null;
148
+ this._pendingBatchOffsets = new Map();
149
+ this._aiImageLaneSlots = new Map();
150
+ this._draggingAiImageIds = new Set();
151
+ this._aiImageLaneHandlers = null;
148
152
  this._selectionHandlers = null;
153
+ this._viewportHandlers = null;
149
154
  // Окно от BoxSelectStart до BoxSelectCommit: в это время SelectionAdd
150
155
  // приходит на каждый mousemove и не должен пушить превью в чат —
151
156
  // финальный набор картинок мы получим из BoxSelectCommit по strict-contains.
@@ -176,10 +181,10 @@ export class ChatWindow {
176
181
  onAbort: () => this._session.abort()
177
182
  }
178
183
  );
179
- this._clearSelectionOnSendClick = () => this._clearBoardSelection();
180
- this._refs.send.addEventListener('click', this._clearSelectionOnSendClick);
181
184
  this._composer.attach();
182
185
  this._attachSelectionEvents();
186
+ this._attachViewportSync();
187
+ this._attachAiImageLaneEvents();
183
188
 
184
189
  this._extendedPromptModal = new ChatExtendedPromptModal(
185
190
  this._container,
@@ -263,13 +268,14 @@ export class ChatWindow {
263
268
  this._clearPendingOverlays();
264
269
  this._cancelBoardImageShiftAnimations();
265
270
  if (this._unsubscribe) { this._unsubscribe(); this._unsubscribe = null; }
266
- if (this._clearSelectionOnSendClick && this._refs?.send) {
267
- this._refs.send.removeEventListener('click', this._clearSelectionOnSendClick);
268
- this._clearSelectionOnSendClick = null;
269
- }
270
271
  this._detachSelectionEvents();
272
+ this._detachViewportSync();
273
+ this._detachAiImageLaneEvents();
271
274
  this._shiftedForImageBatchKeys.clear();
272
275
  this._boardImageShiftHistory.clear();
276
+ this._pendingBatchOffsets.clear();
277
+ this._aiImageLaneSlots.clear();
278
+ this._draggingAiImageIds.clear();
273
279
  this._composer?.destroy();
274
280
  this._extendedPromptModal?.destroy();
275
281
  this._contentTypeMenu?.destroy();
@@ -338,6 +344,7 @@ export class ChatWindow {
338
344
  if (state.status !== 'streaming') {
339
345
  this._revertFailedBatchShifts(state.messages);
340
346
  }
347
+ this._cleanupPlacedBatchOffsets(state.messages);
341
348
  this._messageList.render(state.messages);
342
349
  this._contentTypeMenu.refresh();
343
350
  this._modelMenu.refresh();
@@ -348,6 +355,26 @@ export class ChatWindow {
348
355
  this._updateCountPillIcon();
349
356
  this._composer.setStreaming(state.status === 'streaming');
350
357
  this._updatePendingImages(state.status === 'streaming' ? state.messages : []);
358
+
359
+ const errorBlock = this._refs.errorBlock;
360
+ const wasVisible = errorBlock.classList.contains('is-visible');
361
+ const isError = state.error && state.error !== 'Отменено';
362
+
363
+ if (isError) {
364
+ errorBlock.textContent = formatChatErrorForDisplay(state.error);
365
+ errorBlock.classList.add('is-visible');
366
+ } else {
367
+ errorBlock.classList.remove('is-visible');
368
+ }
369
+
370
+ const isVisible = errorBlock.classList.contains('is-visible');
371
+ if (!wasVisible && isVisible) {
372
+ requestAnimationFrame(() => {
373
+ this._pushBoardImagesUpIfNeeded();
374
+ });
375
+ } else if (wasVisible && !isVisible) {
376
+ this._pullBoardImagesDownIfNeeded();
377
+ }
351
378
  }
352
379
 
353
380
  _updatePendingImages(messages) {
@@ -367,35 +394,64 @@ export class ChatWindow {
367
394
  const world = this._boardCore?.pixi?.worldLayer || this._boardCore?.pixi?.app?.stage;
368
395
  const s = world?.scale?.x || 1;
369
396
 
370
- this._shiftExistingImagesForBatch(messages, pending[0].id, s);
397
+ // Смещаем уже размещённые board-объекты для каждого батча (дедупликация внутри метода)
398
+ const shiftedBids = new Set();
399
+ for (const m of pending) {
400
+ const bid = m.batchId || m.id;
401
+ if (!shiftedBids.has(bid)) {
402
+ shiftedBids.add(bid);
403
+ this._shiftExistingImagesForBatch(messages, m.id, s);
404
+ }
405
+ }
371
406
 
372
- const [wr, hr] = parseFormatRatio(this._formatId);
373
- const ratio = wr / hr;
374
- const wScreen = Math.round(BOARD_IMAGE_WIDTH * s);
375
- const hScreen = Math.round(wScreen / ratio);
376
- const enterDistance = Math.round(BOARD_IMAGE_STEP * s * BOARD_IMAGE_PENDING_ENTER_FACTOR);
407
+ // Смещаем pending-оверлеи для новых батчей (от старших к новейшему)
408
+ const pendingBatchMeta = [];
409
+ const seenBids = new Set();
410
+ for (const m of pending) {
411
+ if (m.batchId && !seenBids.has(m.batchId)) {
412
+ seenBids.add(m.batchId);
413
+ pendingBatchMeta.push({
414
+ batchId: m.batchId,
415
+ count: pending.filter((pm) => pm.batchId === m.batchId).length
416
+ });
417
+ }
418
+ }
419
+ for (const { batchId, count } of pendingBatchMeta) {
420
+ this._shiftPendingOverlaysForNewBatch(batchId, count, s);
421
+ }
422
+
423
+ const enterDistance = this._computeEnterDistance(messages, pending, s, Math.round(BOARD_IMAGE_WIDTH * s));
377
424
 
378
425
  let newIndex = 0;
379
426
  pending.forEach((message) => {
380
- const slot = this._getImageBatchSlot(messages, message.id, s);
381
- const left = Math.round(slot.x - wScreen / 2);
382
- const top = Math.round(slot.y - hScreen / 2);
383
-
384
427
  const existing = this._pendingOverlays.get(message.id);
385
428
  if (existing) {
386
- const el = existing.el;
387
- el.style.left = `${left}px`;
388
- el.style.top = `${top}px`;
389
- el.style.width = `${wScreen}px`;
390
- el.style.height = `${hScreen}px`;
391
- el.style.setProperty('--moodboard-chat-board-animation-ms', `${BOARD_IMAGE_REARRANGE_MS}ms`);
392
- el.style.setProperty('--moodboard-chat-pending-enter-x', `${enterDistance}px`);
429
+ // Мировые координаты оверлея зафиксированы в момент создания батча.
430
+ // Пересчитываем только экранную позицию из сохранённых world-координат,
431
+ // чтобы пан холста между рендерами не сдвигал worldX/worldY —
432
+ // иначе размещение реального изображения попадёт в неправильную точку.
433
+ const [wr2, hr2] = parseFormatRatio(this._formatId);
434
+ const wScreen2 = Math.round(BOARD_IMAGE_WIDTH * s);
435
+ const hScreen2 = Math.round(wScreen2 / (wr2 / hr2));
436
+ const screenLayout = {
437
+ left: Math.round(existing.worldX * s + (world?.x || 0) - wScreen2 / 2),
438
+ top: Math.round(existing.worldY * s + (world?.y || 0) - hScreen2 / 2),
439
+ width: wScreen2,
440
+ height: hScreen2,
441
+ worldX: existing.worldX,
442
+ worldY: existing.worldY
443
+ };
444
+ this._reserveAiImageLaneSlotForMessage(message.id, existing.worldX, existing.worldY);
445
+ this._applyPendingOverlayScreenLayout(existing.el, screenLayout, { animate: true, enterDistance });
393
446
  return;
394
447
  }
395
448
 
449
+ const layout = this._computePendingOverlayScreenLayout(messages, message.id, s);
450
+ this._reserveAiImageLaneSlotForMessage(message.id, layout.worldX, layout.worldY);
451
+
396
452
  const overlay = document.createElement('div');
397
453
  overlay.className = 'moodboard-chat__pending-overlay moodboard-chat__pending-overlay--enter';
398
- overlay.style.cssText = `left:${left}px;top:${top}px;width:${wScreen}px;height:${hScreen}px`;
454
+ overlay.style.cssText = `left:${layout.left}px;top:${layout.top}px;width:${layout.width}px;height:${layout.height}px`;
399
455
  overlay.style.setProperty('--moodboard-chat-board-animation-ms', `${BOARD_IMAGE_REARRANGE_MS}ms`);
400
456
  overlay.style.setProperty('--moodboard-chat-pending-enter-x', `${enterDistance}px`);
401
457
 
@@ -404,14 +460,19 @@ export class ChatWindow {
404
460
  label.textContent = 'В процессе...';
405
461
  overlay.appendChild(label);
406
462
 
407
- document.body.appendChild(overlay);
463
+ (this._container ?? document.body).appendChild(overlay);
408
464
 
409
465
  // Принудительный reflow: фиксируем стартовое состояние (translateX справа + opacity 0)
410
466
  // в layout до переключения класса. Без этого браузер может смерджить два состояния
411
467
  // в один кадр и transition не запустится — заглушка появится мгновенно.
412
468
  void overlay.offsetWidth;
413
469
 
414
- this._pendingOverlays.set(message.id, { el: overlay });
470
+ this._pendingOverlays.set(message.id, {
471
+ el: overlay,
472
+ batchId: message.batchId,
473
+ worldX: layout.worldX,
474
+ worldY: layout.worldY
475
+ });
415
476
 
416
477
  const stagger = newIndex * BOARD_IMAGE_PENDING_STAGGER_MS;
417
478
  newIndex += 1;
@@ -442,6 +503,40 @@ export class ChatWindow {
442
503
  }
443
504
  }
444
505
 
506
+ /**
507
+ * Вычисляет расстояние входа заглушки так, чтобы новая заглушка въезжала справа
508
+ * от уже размещённых AI-изображений на доске, а не накрывала их во время анимации
509
+ * сдвига. Без этого при N>1 изображений в батче enter-расстояние фиксированное
510
+ * (512px) не покрывало ширину существующего ряда (940px для трёх изображений),
511
+ * и заглушка пересекалась с ещё не ушедшими влево картинками.
512
+ */
513
+ _computeEnterDistance(messages, pending, scale, wScreen) {
514
+ const baseEnter = Math.round(BOARD_IMAGE_STEP * scale * BOARD_IMAGE_PENDING_ENTER_FACTOR);
515
+
516
+ const aiObjects = this._getBoardAiImageObjects();
517
+ if (aiObjects.length === 0) return baseEnter;
518
+
519
+ const firstNewPending = pending.find((m) => !this._pendingOverlays.has(m.id));
520
+ if (!firstNewPending) return baseEnter;
521
+
522
+ const world = this._boardCore?.pixi?.worldLayer || this._boardCore?.pixi?.app?.stage;
523
+ const worldX = world?.x || 0;
524
+ const existingRight_world = this._getAiImageLaneRightBoundary(aiObjects);
525
+ if (!Number.isFinite(existingRight_world)) return baseEnter;
526
+ const existingRight_screen = Math.round(existingRight_world * scale + worldX);
527
+
528
+ const step = Math.round(BOARD_IMAGE_STEP * scale);
529
+ const gap = Math.round(BOARD_IMAGE_GAP * scale);
530
+
531
+ const slot = this._getImageBatchSlot(messages, firstNewPending.id, scale);
532
+ const batch = findImageGenerationBatch(messages, firstNewPending.id);
533
+ const leftmostSlotX = Math.round(slot.x - batch.index * step);
534
+ const leftmostFinalLeft = leftmostSlotX - Math.round(wScreen / 2);
535
+
536
+ const neededEnter = existingRight_screen - leftmostFinalLeft + gap;
537
+ return Math.max(baseEnter, Math.ceil(neededEnter));
538
+ }
539
+
445
540
  _clearPendingOverlays() {
446
541
  for (const record of this._pendingOverlays.values()) {
447
542
  record.el.remove();
@@ -451,6 +546,72 @@ export class ChatWindow {
451
546
  clearTimeout(timer);
452
547
  }
453
548
  this._pendingOverlayTimers.clear();
549
+ this._pendingBatchOffsets.clear();
550
+ }
551
+
552
+ _shiftPendingOverlaysForNewBatch(batchId, count, scale) {
553
+ if (!batchId || this._pendingBatchOffsets.has(batchId)) return;
554
+
555
+ const step = Math.round(BOARD_IMAGE_STEP * scale);
556
+ const shiftAmount = count * step;
557
+
558
+ for (const [existingId, offset] of this._pendingBatchOffsets) {
559
+ this._pendingBatchOffsets.set(existingId, offset - shiftAmount);
560
+ }
561
+ this._pendingBatchOffsets.set(batchId, 0);
562
+
563
+ let existingOverlaysShifted = false;
564
+ const messages = this._session?.getState?.()?.messages || [];
565
+ for (const [messageId, record] of this._pendingOverlays) {
566
+ if (record.batchId === batchId) continue;
567
+ const layout = this._computePendingOverlayScreenLayout(messages, messageId, scale);
568
+ record.worldX = layout.worldX;
569
+ record.worldY = layout.worldY;
570
+ this._reserveAiImageLaneSlotForMessage(messageId, layout.worldX, layout.worldY);
571
+ this._applyPendingOverlayScreenLayout(record.el, layout, { animate: true });
572
+ existingOverlaysShifted = true;
573
+ }
574
+
575
+ // Когда существующие заглушки сдвигаются влево, уже размещённые AI-изображения
576
+ // на доске должны сдвинуться на то же расстояние — иначе они окажутся
577
+ // правее заглушек и перекроются ими.
578
+ if (!existingOverlaysShifted) return;
579
+
580
+ const worldShift = shiftAmount / scale;
581
+ for (const obj of this._getBoardAiImageObjects()) {
582
+ if (obj?.position) {
583
+ const key = getAiImageLaneKeyForObject(obj);
584
+ const currentSlot = this._getAiImageLaneSlotForObject(obj);
585
+ const from = { x: obj.position.x, y: obj.position.y };
586
+ if (this._draggingAiImageIds.has(obj.id)) {
587
+ this._reserveAiImageLaneSlotForObject(obj);
588
+ continue;
589
+ }
590
+ const to = {
591
+ x: Math.round((currentSlot?.x ?? obj.position.x) - worldShift),
592
+ y: currentSlot?.y ?? obj.position.y
593
+ };
594
+ if (key) {
595
+ this._aiImageLaneSlots.set(key, {
596
+ x: to.x,
597
+ y: to.y,
598
+ width: currentSlot?.width ?? getBoardObjectWidth(obj),
599
+ height: currentSlot?.height ?? getBoardObjectHeight(obj)
600
+ });
601
+ }
602
+ this._animateBoardImageToPosition(obj.id, from, to);
603
+ }
604
+ }
605
+ }
606
+
607
+ _cleanupPlacedBatchOffsets(messages) {
608
+ if (this._pendingBatchOffsets.size === 0) return;
609
+ for (const batchId of [...this._pendingBatchOffsets.keys()]) {
610
+ const batchMessages = (messages || []).filter((m) => m.batchId === batchId);
611
+ if (batchMessages.length === 0 || batchMessages.every((m) => !m.pending)) {
612
+ this._pendingBatchOffsets.delete(batchId);
613
+ }
614
+ }
454
615
  }
455
616
 
456
617
  _getImageRequestOptions() {
@@ -463,6 +624,164 @@ export class ChatWindow {
463
624
  };
464
625
  }
465
626
 
627
+ _computePendingOverlayScreenLayout(messages, messageId, scale = 1) {
628
+ const world = this._boardCore?.pixi?.worldLayer || this._boardCore?.pixi?.app?.stage;
629
+ const s = scale || world?.scale?.x || 1;
630
+ const [wr, hr] = parseFormatRatio(this._formatId);
631
+ const ratio = wr / hr;
632
+ const wScreen = Math.round(BOARD_IMAGE_WIDTH * s);
633
+ const hScreen = Math.round(wScreen / ratio);
634
+ const slot = this._getImageBatchSlot(messages, messageId, s);
635
+ const worldX = (slot.x - (world?.x || 0)) / s;
636
+ const worldY = (slot.y - (world?.y || 0)) / s;
637
+ const screenX = Math.round(worldX * s + (world?.x || 0));
638
+ const screenY = Math.round(worldY * s + (world?.y || 0));
639
+
640
+ return {
641
+ left: Math.round(screenX - wScreen / 2),
642
+ top: Math.round(screenY - hScreen / 2),
643
+ width: wScreen,
644
+ height: hScreen,
645
+ worldX,
646
+ worldY
647
+ };
648
+ }
649
+
650
+ _applyPendingOverlayScreenLayout(el, layout, { animate = false, enterDistance } = {}) {
651
+ el.style.left = `${layout.left}px`;
652
+ el.style.top = `${layout.top}px`;
653
+ el.style.width = `${layout.width}px`;
654
+ el.style.height = `${layout.height}px`;
655
+ if (!animate) return;
656
+
657
+ el.style.setProperty('--moodboard-chat-board-animation-ms', `${BOARD_IMAGE_REARRANGE_MS}ms`);
658
+ if (enterDistance != null) {
659
+ el.style.setProperty('--moodboard-chat-pending-enter-x', `${enterDistance}px`);
660
+ }
661
+ }
662
+
663
+ /**
664
+ * Пересчитывает screen-позиции заглушек из world-координат — вместе с AI-изображениями на доске при pan/zoom.
665
+ */
666
+ _syncPendingOverlaysToViewport({ disableTransition = false, recomputeWorld = false } = {}) {
667
+ if (this._pendingOverlays.size === 0) return;
668
+
669
+ const messages = this._session?.getState?.()?.messages;
670
+ const world = this._boardCore?.pixi?.worldLayer || this._boardCore?.pixi?.app?.stage;
671
+ const s = world?.scale?.x || 1;
672
+ const [wr, hr] = parseFormatRatio(this._formatId);
673
+ const wScreen = Math.round(BOARD_IMAGE_WIDTH * s);
674
+ const hScreen = Math.round(wScreen / (wr / hr));
675
+
676
+ for (const [messageId, record] of this._pendingOverlays) {
677
+ if (recomputeWorld || typeof record.worldX !== 'number' || typeof record.worldY !== 'number') {
678
+ const layout = this._computePendingOverlayScreenLayout(messages, messageId, s);
679
+ record.worldX = layout.worldX;
680
+ record.worldY = layout.worldY;
681
+ }
682
+
683
+ const screenX = Math.round(record.worldX * s + (world?.x || 0));
684
+ const screenY = Math.round(record.worldY * s + (world?.y || 0));
685
+ const el = record.el;
686
+ this._reserveAiImageLaneSlotForMessage(messageId, record.worldX, record.worldY);
687
+ if (disableTransition) {
688
+ el.style.transition = 'none';
689
+ }
690
+ el.style.left = `${Math.round(screenX - wScreen / 2)}px`;
691
+ el.style.top = `${Math.round(screenY - hScreen / 2)}px`;
692
+ el.style.width = `${wScreen}px`;
693
+ el.style.height = `${hScreen}px`;
694
+ if (disableTransition) {
695
+ void el.offsetWidth;
696
+ el.style.removeProperty('transition');
697
+ }
698
+ }
699
+ }
700
+
701
+ _attachViewportSync() {
702
+ const eventBus = this._boardCore?.eventBus;
703
+ if (!eventBus || typeof eventBus.on !== 'function' || this._viewportHandlers) return;
704
+
705
+ const onPanUpdate = () => {
706
+ this._syncPendingOverlaysToViewport({ disableTransition: true });
707
+ };
708
+ const onViewportChange = () => {
709
+ this._syncPendingOverlaysToViewport({ disableTransition: true, recomputeWorld: false });
710
+ };
711
+
712
+ this._viewportHandlers = { onPanUpdate, onViewportChange };
713
+ eventBus.on(Events.Tool.PanUpdate, onPanUpdate);
714
+ eventBus.on(Events.UI.ZoomPercent, onViewportChange);
715
+ eventBus.on(Events.Viewport.Changed, onViewportChange);
716
+ }
717
+
718
+ _detachViewportSync() {
719
+ const eventBus = this._boardCore?.eventBus;
720
+ const handlers = this._viewportHandlers;
721
+ if (!eventBus || typeof eventBus.off !== 'function' || !handlers) return;
722
+
723
+ eventBus.off(Events.Tool.PanUpdate, handlers.onPanUpdate);
724
+ eventBus.off(Events.UI.ZoomPercent, handlers.onViewportChange);
725
+ eventBus.off(Events.Viewport.Changed, handlers.onViewportChange);
726
+ this._viewportHandlers = null;
727
+ }
728
+
729
+ _attachAiImageLaneEvents() {
730
+ const eventBus = this._boardCore?.eventBus;
731
+ if (!eventBus || typeof eventBus.on !== 'function' || this._aiImageLaneHandlers) return;
732
+
733
+ const onDragStart = (data) => {
734
+ this._startAiImageLaneDrag([data?.object]);
735
+ };
736
+ const onDragUpdate = (data) => {
737
+ this._updateAiImageLaneDrag([data?.object]);
738
+ };
739
+ const onDragEnd = (data) => {
740
+ this._updateAiImageLaneDrag([data?.object]);
741
+ this._endAiImageLaneDrag([data?.object]);
742
+ };
743
+ const onGroupDragStart = (data) => {
744
+ this._startAiImageLaneDrag(data?.objects);
745
+ };
746
+ const onGroupDragUpdate = (data) => {
747
+ this._updateAiImageLaneDrag(data?.objects);
748
+ };
749
+ const onGroupDragEnd = (data) => {
750
+ this._updateAiImageLaneDrag(data?.objects);
751
+ this._endAiImageLaneDrag(data?.objects);
752
+ };
753
+
754
+ this._aiImageLaneHandlers = {
755
+ onDragStart,
756
+ onDragUpdate,
757
+ onDragEnd,
758
+ onGroupDragStart,
759
+ onGroupDragUpdate,
760
+ onGroupDragEnd
761
+ };
762
+
763
+ eventBus.on(Events.Tool.DragStart, onDragStart);
764
+ eventBus.on(Events.Tool.DragUpdate, onDragUpdate);
765
+ eventBus.on(Events.Tool.DragEnd, onDragEnd);
766
+ eventBus.on(Events.Tool.GroupDragStart, onGroupDragStart);
767
+ eventBus.on(Events.Tool.GroupDragUpdate, onGroupDragUpdate);
768
+ eventBus.on(Events.Tool.GroupDragEnd, onGroupDragEnd);
769
+ }
770
+
771
+ _detachAiImageLaneEvents() {
772
+ const eventBus = this._boardCore?.eventBus;
773
+ const handlers = this._aiImageLaneHandlers;
774
+ if (!eventBus || typeof eventBus.off !== 'function' || !handlers) return;
775
+
776
+ eventBus.off(Events.Tool.DragStart, handlers.onDragStart);
777
+ eventBus.off(Events.Tool.DragUpdate, handlers.onDragUpdate);
778
+ eventBus.off(Events.Tool.DragEnd, handlers.onDragEnd);
779
+ eventBus.off(Events.Tool.GroupDragStart, handlers.onGroupDragStart);
780
+ eventBus.off(Events.Tool.GroupDragUpdate, handlers.onGroupDragUpdate);
781
+ eventBus.off(Events.Tool.GroupDragEnd, handlers.onGroupDragEnd);
782
+ this._aiImageLaneHandlers = null;
783
+ }
784
+
466
785
  _attachSelectionEvents() {
467
786
  const eventBus = this._boardCore?.eventBus;
468
787
  if (!eventBus || typeof eventBus.on !== 'function' || this._selectionHandlers) return;
@@ -563,7 +882,8 @@ export class ChatWindow {
563
882
  const step = Math.round(BOARD_IMAGE_STEP * scale);
564
883
  const count = Math.max(batch.count, 1);
565
884
  const index = Math.min(Math.max(batch.index, 0), count - 1);
566
- const leftmostCenter = anchor.x - ((count - 1) * step) / 2;
885
+ const batchOffset = batch.batchId ? (this._pendingBatchOffsets.get(batch.batchId) ?? 0) : 0;
886
+ const leftmostCenter = anchor.x - ((count - 1) * step) / 2 + batchOffset;
567
887
 
568
888
  return {
569
889
  x: Math.round(leftmostCenter + index * step),
@@ -574,9 +894,23 @@ export class ChatWindow {
574
894
  _getImageGroupAnchor() {
575
895
  const composerRect = this._refs?.composer?.getBoundingClientRect?.();
576
896
  if (composerRect) {
897
+ let y = composerRect.top - 250;
898
+
899
+ const errorBlock = this._refs?.errorBlock;
900
+ if (errorBlock && errorBlock.classList.contains('is-visible')) {
901
+ const errorRect = errorBlock.getBoundingClientRect();
902
+ // Низ картинок примерно y + 150.
903
+ // Хотим чтобы errorRect.top был хотя бы y + 150 + 16 (gap)
904
+ // То есть y + 166 <= errorRect.top
905
+ const minY = errorRect.top - 166;
906
+ if (y > minY) {
907
+ y = minY;
908
+ }
909
+ }
910
+
577
911
  return {
578
912
  x: Math.round(composerRect.left + composerRect.width / 2),
579
- y: Math.round(composerRect.top - 250)
913
+ y: Math.round(y)
580
914
  };
581
915
  }
582
916
 
@@ -621,7 +955,8 @@ export class ChatWindow {
621
955
  const aiObjects = this._getBoardAiImageObjects();
622
956
  if (aiObjects.length === 0 || !nextBatchBounds) return;
623
957
 
624
- const existingRight = Math.max(...aiObjects.map((object) => object.position.x + getBoardObjectWidth(object)));
958
+ const existingRight = this._getAiImageLaneRightBoundary(aiObjects);
959
+ if (!Number.isFinite(existingRight)) return;
625
960
  const shift = Math.ceil(existingRight + BOARD_IMAGE_GAP - nextBatchBounds.left);
626
961
  if (shift <= 0) return;
627
962
 
@@ -631,9 +966,28 @@ export class ChatWindow {
631
966
  for (const id of ids) {
632
967
  const obj = objects?.find((item) => item.id === id);
633
968
  if (obj?.position) {
969
+ const key = getAiImageLaneKeyForObject(obj);
970
+ const currentSlot = this._getAiImageLaneSlotForObject(obj);
634
971
  const from = { x: obj.position.x, y: obj.position.y };
635
- const to = { x: Math.round(obj.position.x - shift), y: obj.position.y };
636
- shiftRecord.push({ id, from });
972
+ if (this._draggingAiImageIds.has(id)) {
973
+ this._reserveAiImageLaneSlotForObject(obj);
974
+ continue;
975
+ }
976
+ const fromSlot = currentSlot ? { ...currentSlot } : null;
977
+ const to = {
978
+ x: Math.round((currentSlot?.x ?? obj.position.x) - shift),
979
+ y: currentSlot?.y ?? obj.position.y
980
+ };
981
+ const toSlot = {
982
+ x: to.x,
983
+ y: to.y,
984
+ width: currentSlot?.width ?? getBoardObjectWidth(obj),
985
+ height: currentSlot?.height ?? getBoardObjectHeight(obj)
986
+ };
987
+ if (key) {
988
+ this._aiImageLaneSlots.set(key, toSlot);
989
+ }
990
+ shiftRecord.push({ id, from, fromSlot });
637
991
  this._animateBoardImageToPosition(id, from, to);
638
992
  }
639
993
  }
@@ -648,9 +1002,13 @@ export class ChatWindow {
648
1002
  if (!record) return;
649
1003
 
650
1004
  const objects = this._boardCore?.state?.state?.objects;
651
- for (const { id, from } of record) {
1005
+ for (const { id, from, fromSlot } of record) {
652
1006
  const obj = objects?.find((item) => item.id === id);
653
1007
  if (obj?.position) {
1008
+ const key = getAiImageLaneKeyForObject(obj);
1009
+ if (key && fromSlot) {
1010
+ this._aiImageLaneSlots.set(key, fromSlot);
1011
+ }
654
1012
  this._animateBoardImageToPosition(id, obj.position, from);
655
1013
  }
656
1014
  }
@@ -681,6 +1039,58 @@ export class ChatWindow {
681
1039
  }
682
1040
  }
683
1041
 
1042
+ _pushBoardImagesUpIfNeeded() {
1043
+ const errorBlock = this._refs.errorBlock;
1044
+ if (!errorBlock) return;
1045
+ const errorRect = errorBlock.getBoundingClientRect();
1046
+
1047
+ const aiObjects = this._getBoardAiImageObjects();
1048
+ if (aiObjects.length === 0) return;
1049
+
1050
+ const world = this._boardCore?.pixi?.worldLayer || this._boardCore?.pixi?.app?.stage;
1051
+ const s = world?.scale?.x || 1;
1052
+
1053
+ let maxOverlap = 0;
1054
+ for (const obj of aiObjects) {
1055
+ if (!obj.position) continue;
1056
+ const height = getBoardObjectHeight(obj);
1057
+ const bottomWorld = obj.position.y + height;
1058
+ const bottomScreen = bottomWorld * s + (world?.y || 0);
1059
+
1060
+ const overlap = bottomScreen - (errorRect.top - 16);
1061
+ if (overlap > maxOverlap) {
1062
+ maxOverlap = overlap;
1063
+ }
1064
+ }
1065
+
1066
+ if (maxOverlap > 0) {
1067
+ const shiftWorld = Math.ceil(maxOverlap / s);
1068
+ this._errorShiftAmount = shiftWorld;
1069
+ for (const obj of aiObjects) {
1070
+ if (obj.position) {
1071
+ const from = { x: obj.position.x, y: obj.position.y };
1072
+ const to = { x: obj.position.x, y: obj.position.y - shiftWorld };
1073
+ this._animateBoardImageToPosition(obj.id, from, to);
1074
+ }
1075
+ }
1076
+ }
1077
+ }
1078
+
1079
+ _pullBoardImagesDownIfNeeded() {
1080
+ if (!this._errorShiftAmount) return;
1081
+ const shiftWorld = this._errorShiftAmount;
1082
+ this._errorShiftAmount = 0;
1083
+
1084
+ const aiObjects = this._getBoardAiImageObjects();
1085
+ for (const obj of aiObjects) {
1086
+ if (obj.position) {
1087
+ const from = { x: obj.position.x, y: obj.position.y };
1088
+ const to = { x: obj.position.x, y: obj.position.y + shiftWorld };
1089
+ this._animateBoardImageToPosition(obj.id, from, to);
1090
+ }
1091
+ }
1092
+ }
1093
+
684
1094
  _animateBoardImageToPosition(id, fromPosition, toPosition) {
685
1095
  const updatePosition = this._boardCore?.updateObjectPositionDirect;
686
1096
  if (!id || typeof updatePosition !== 'function' || !fromPosition || !toPosition) return;
@@ -755,6 +1165,156 @@ export class ChatWindow {
755
1165
  return objects.filter((object) => isBoardAiImageObject(object));
756
1166
  }
757
1167
 
1168
+ _getBoardObjectById(objectId) {
1169
+ const objects = this._boardCore?.state?.state?.objects;
1170
+ if (!objectId || !Array.isArray(objects)) return null;
1171
+
1172
+ return objects.find((object) => object?.id === objectId) || null;
1173
+ }
1174
+
1175
+ _startAiImageLaneDrag(ids) {
1176
+ const objects = this._getAiImageObjectsByIds(ids);
1177
+ if (objects.length === 0) return;
1178
+
1179
+ this._reserveCurrentAiImageLaneSlots();
1180
+ for (const object of objects) {
1181
+ this._draggingAiImageIds.add(object.id);
1182
+ }
1183
+ }
1184
+
1185
+ _updateAiImageLaneDrag(ids) {
1186
+ const objects = this._getAiImageObjectsByIds(ids);
1187
+ for (const object of objects) {
1188
+ this._reserveAiImageLaneSlotForObject(object);
1189
+ }
1190
+ }
1191
+
1192
+ _endAiImageLaneDrag(ids) {
1193
+ const objects = this._getAiImageObjectsByIds(ids);
1194
+ for (const object of objects) {
1195
+ this._draggingAiImageIds.delete(object.id);
1196
+ }
1197
+ }
1198
+
1199
+ _getAiImageObjectsByIds(ids) {
1200
+ const list = Array.isArray(ids) ? ids : [];
1201
+ const objects = [];
1202
+
1203
+ for (const id of list) {
1204
+ const object = this._getBoardObjectById(id);
1205
+ if (isBoardAiImageObject(object)) {
1206
+ objects.push(object);
1207
+ }
1208
+ }
1209
+
1210
+ return objects;
1211
+ }
1212
+
1213
+ _reserveCurrentAiImageLaneSlots() {
1214
+ for (const object of this._getBoardAiImageObjects()) {
1215
+ this._reserveAiImageLaneSlotForObject(object);
1216
+ }
1217
+ }
1218
+
1219
+ _reserveAiImageLaneSlotForObject(object) {
1220
+ const key = getAiImageLaneKeyForObject(object);
1221
+ if (!key || !object?.position) return null;
1222
+
1223
+ const slot = {
1224
+ x: Math.round(object.position.x),
1225
+ y: Math.round(object.position.y),
1226
+ width: getBoardObjectWidth(object),
1227
+ height: getBoardObjectHeight(object)
1228
+ };
1229
+ this._aiImageLaneSlots.set(key, slot);
1230
+
1231
+ return slot;
1232
+ }
1233
+
1234
+ _reserveAiImageLaneSlotForMessage(messageId, centerWorldX, centerWorldY) {
1235
+ if (!messageId || !Number.isFinite(centerWorldX) || !Number.isFinite(centerWorldY)) return null;
1236
+
1237
+ const [wr, hr] = parseFormatRatio(this._formatId);
1238
+ const width = BOARD_IMAGE_WIDTH;
1239
+ const height = Math.round(width / (wr / hr));
1240
+ const slot = {
1241
+ x: Math.round(centerWorldX - width / 2),
1242
+ y: Math.round(centerWorldY - height / 2),
1243
+ width,
1244
+ height
1245
+ };
1246
+ this._aiImageLaneSlots.set(messageId, slot);
1247
+
1248
+ return slot;
1249
+ }
1250
+
1251
+ _getAiImageLaneSlotForObject(object) {
1252
+ const key = getAiImageLaneKeyForObject(object);
1253
+ return (key && this._aiImageLaneSlots.get(key)) || this._reserveAiImageLaneSlotForObject(object);
1254
+ }
1255
+
1256
+ _getAiImageLaneRightBoundary(objects = this._getBoardAiImageObjects(), excludeKey = null) {
1257
+ const rights = [];
1258
+
1259
+ for (const object of objects) {
1260
+ const key = getAiImageLaneKeyForObject(object);
1261
+ if (key && key === excludeKey) continue;
1262
+
1263
+ const slot = this._getAiImageLaneSlotForObject(object);
1264
+ if (slot) {
1265
+ rights.push(slot.x + slot.width);
1266
+ }
1267
+ }
1268
+
1269
+ for (const [key, slot] of this._aiImageLaneSlots) {
1270
+ if (key === excludeKey) continue;
1271
+
1272
+ if (slot && Number.isFinite(slot.x) && Number.isFinite(slot.width)) {
1273
+ rights.push(slot.x + slot.width);
1274
+ }
1275
+ }
1276
+
1277
+ return rights.length > 0 ? Math.max(...rights) : null;
1278
+ }
1279
+
1280
+ _resolveAiImageInsertPoint(msg, x, y, scale) {
1281
+ const world = this._boardCore?.pixi?.worldLayer || this._boardCore?.pixi?.app?.stage;
1282
+ const s = scale || world?.scale?.x || 1;
1283
+ const centerWorldX = (x - (world?.x || 0)) / s;
1284
+ const centerWorldY = (y - (world?.y || 0)) / s;
1285
+ let slot = this._reserveAiImageLaneSlotForMessage(msg.id, centerWorldX, centerWorldY);
1286
+
1287
+ if (slot && this._doesAiImageLaneSlotOverlap(slot, msg.id)) {
1288
+ const right = this._getAiImageLaneRightBoundary(undefined, msg.id);
1289
+ if (Number.isFinite(right)) {
1290
+ slot = {
1291
+ ...slot,
1292
+ x: Math.round(right + BOARD_IMAGE_GAP)
1293
+ };
1294
+ this._aiImageLaneSlots.set(msg.id, slot);
1295
+ }
1296
+ }
1297
+
1298
+ if (!slot) return { x, y };
1299
+
1300
+ return {
1301
+ x: Math.round((slot.x + slot.width / 2) * s + (world?.x || 0)),
1302
+ y: Math.round((slot.y + slot.height / 2) * s + (world?.y || 0))
1303
+ };
1304
+ }
1305
+
1306
+ _doesAiImageLaneSlotOverlap(candidate, candidateKey) {
1307
+ for (const [key, slot] of this._aiImageLaneSlots) {
1308
+ if (key === candidateKey || !slot) continue;
1309
+
1310
+ if (rectsOverlap(candidate, slot)) {
1311
+ return true;
1312
+ }
1313
+ }
1314
+
1315
+ return false;
1316
+ }
1317
+
758
1318
  _addImageToBoard(msg) {
759
1319
  if (!this._boardCore?.eventBus) return;
760
1320
  const dataUrl = `data:${msg.mimeType || 'image/jpeg'};base64,${msg.imageBase64}`;
@@ -762,13 +1322,28 @@ export class ChatWindow {
762
1322
  const s = world?.scale?.x || 1;
763
1323
  const messages = this._session.getState().messages;
764
1324
  this._shiftExistingImagesForBatch(messages, msg.id, s);
765
- const slot = this._getImageBatchSlot(messages, msg.id, s);
766
-
1325
+
1326
+ // Pending-оверлей хранит worldX/worldY, зафиксированные в момент начала генерации.
1327
+ // Используем их чтобы разместить изображение в той же мировой точке,
1328
+ // независимо от того, сдвинул ли пользователь холст пока шла генерация.
1329
+ const pendingRecord = this._pendingOverlays.get(msg.id);
1330
+ let x, y;
1331
+ if (pendingRecord && typeof pendingRecord.worldX === 'number' && typeof pendingRecord.worldY === 'number') {
1332
+ x = Math.round(pendingRecord.worldX * s + (world?.x || 0));
1333
+ y = Math.round(pendingRecord.worldY * s + (world?.y || 0));
1334
+ } else {
1335
+ const slot = this._getImageBatchSlot(messages, msg.id, s);
1336
+ x = slot.x;
1337
+ y = slot.y;
1338
+ }
1339
+ const insertPoint = this._resolveAiImageInsertPoint(msg, x, y, s);
1340
+
767
1341
  this._boardCore.eventBus.emit(Events.UI.PasteImageAt, {
768
- x: slot.x,
769
- y: slot.y,
1342
+ x: insertPoint.x,
1343
+ y: insertPoint.y,
770
1344
  src: dataUrl,
771
1345
  name: 'ai-generated.jpg',
1346
+ aiMessageId: msg.id,
772
1347
  skipUpload: true
773
1348
  });
774
1349
  }
@@ -828,13 +1403,25 @@ function findImageGenerationBatch(messages, messageId) {
828
1403
  return { index: 0, count: 1 };
829
1404
  }
830
1405
 
1406
+ const target = list[targetIndex];
1407
+ if (target?.batchId) {
1408
+ const batchMessages = list.filter((m) => m.batchId === target.batchId);
1409
+ const index = batchMessages.findIndex((m) => m.id === messageId);
1410
+ return {
1411
+ index: Math.max(index, 0),
1412
+ count: batchMessages.length,
1413
+ ids: batchMessages.map((m) => m.id),
1414
+ batchId: target.batchId
1415
+ };
1416
+ }
1417
+
831
1418
  let start = targetIndex;
832
- while (start > 0 && isImageGenerationMessage(list[start - 1])) {
1419
+ while (start > 0 && isImageGenerationMessage(list[start - 1]) && !list[start - 1].batchId) {
833
1420
  start--;
834
1421
  }
835
1422
 
836
1423
  let end = targetIndex;
837
- while (end + 1 < list.length && isImageGenerationMessage(list[end + 1])) {
1424
+ while (end + 1 < list.length && isImageGenerationMessage(list[end + 1]) && !list[end + 1].batchId) {
838
1425
  end++;
839
1426
  }
840
1427
 
@@ -862,6 +1449,19 @@ function isBoardAiImageObject(object) {
862
1449
  && Number.isFinite(object.position.x);
863
1450
  }
864
1451
 
1452
+ function getAiImageLaneKeyForObject(object) {
1453
+ return object?.properties?.aiMessageId || object?.id || null;
1454
+ }
1455
+
1456
+ function rectsOverlap(a, b) {
1457
+ if (!a || !b) return false;
1458
+
1459
+ return a.x < b.x + b.width
1460
+ && a.x + a.width > b.x
1461
+ && a.y < b.y + b.height
1462
+ && a.y + a.height > b.y;
1463
+ }
1464
+
865
1465
  function isReferenceImageObject(object) {
866
1466
  return Boolean(object?.id)
867
1467
  && (object.type === 'image' || object.type === 'revit-screenshot-img')
@@ -937,6 +1537,11 @@ function getBoardObjectWidth(object) {
937
1537
  return Number.isFinite(width) ? Math.max(1, Math.round(width)) : BOARD_IMAGE_WIDTH;
938
1538
  }
939
1539
 
1540
+ function getBoardObjectHeight(object) {
1541
+ const height = object?.height ?? object?.properties?.height ?? BOARD_IMAGE_WIDTH;
1542
+ return Number.isFinite(height) ? Math.max(1, Math.round(height)) : BOARD_IMAGE_WIDTH;
1543
+ }
1544
+
940
1545
  function easeOutCubic(progress) {
941
1546
  return 1 - Math.pow(1 - progress, 3);
942
1547
  }
@@ -21,6 +21,9 @@ export function buildChatDom() {
21
21
  history.setAttribute('role', 'log');
22
22
  history.setAttribute('aria-live', 'polite');
23
23
 
24
+ const errorBlock = createDiv('moodboard-chat__error-block');
25
+ errorBlock.setAttribute('aria-live', 'assertive');
26
+
24
27
  const composer = createDiv('moodboard-chat__composer');
25
28
 
26
29
  const statusBar = createDiv('moodboard-chat__status-bar');
@@ -35,7 +38,8 @@ export function buildChatDom() {
35
38
  history,
36
39
  composer,
37
40
  statusBar,
38
- pendingImages
41
+ pendingImages,
42
+ errorBlock
39
43
  };
40
44
 
41
45
  composer.appendChild(buildInputRow(refs => Object.assign(rendererRefs, refs)));
@@ -43,6 +47,7 @@ export function buildChatDom() {
43
47
 
44
48
  root.appendChild(history);
45
49
  root.appendChild(pendingImages);
50
+ root.appendChild(errorBlock);
46
51
  root.appendChild(statusBar);
47
52
  root.appendChild(composer);
48
53
 
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Форматирование текста ошибки для moodboard-chat__error-block:
3
+ * русский текст пользователю, системный префикс AiClient.* (код) — в скобках в конце.
4
+ */
5
+
6
+ /** @type {Record<number, string>} */
7
+ export const HTTP_STATUS_RU = {
8
+ 400: 'Некорректный запрос',
9
+ 401: 'Требуется авторизация',
10
+ 403: 'Доступ запрещён',
11
+ 404: 'Ресурс не найден',
12
+ 408: 'Превышено время ожидания запроса',
13
+ 409: 'Конфликт запроса',
14
+ 413: 'Слишком большой запрос',
15
+ 422: 'Ошибка валидации данных',
16
+ 429: 'Слишком много запросов',
17
+ 500: 'Внутренняя ошибка сервера',
18
+ 501: 'Операция не поддерживается',
19
+ 502: 'Сбой шлюза',
20
+ 503: 'Сервис временно недоступен',
21
+ 504: 'Превышено время ожидания шлюза'
22
+ };
23
+
24
+ /** Англоязычные фразы statusText / Express — ключ в нижнем регистре */
25
+ /** @type {Record<string, string>} */
26
+ const HTTP_STATUS_PHRASE_RU = {
27
+ 'bad request': HTTP_STATUS_RU[400],
28
+ 'unauthorized': HTTP_STATUS_RU[401],
29
+ 'forbidden': HTTP_STATUS_RU[403],
30
+ 'not found': HTTP_STATUS_RU[404],
31
+ 'request timeout': HTTP_STATUS_RU[408],
32
+ 'conflict': HTTP_STATUS_RU[409],
33
+ 'payload too large': HTTP_STATUS_RU[413],
34
+ 'unprocessable entity': HTTP_STATUS_RU[422],
35
+ 'too many requests': HTTP_STATUS_RU[429],
36
+ 'internal server error': HTTP_STATUS_RU[500],
37
+ 'not implemented': HTTP_STATUS_RU[501],
38
+ 'bad gateway': HTTP_STATUS_RU[502],
39
+ 'service unavailable': HTTP_STATUS_RU[503],
40
+ 'gateway timeout': HTTP_STATUS_RU[504]
41
+ };
42
+
43
+ /** @type {Record<string, string>} */
44
+ const EXACT_MESSAGES_RU = {
45
+ 'YandexART provider does not support reference images':
46
+ 'Провайдер YandexART не поддерживает опорные изображения',
47
+ 'YandexART provider is not configured':
48
+ 'Провайдер YandexART не настроен',
49
+ 'YandexART response does not contain image data':
50
+ 'В ответе YandexART нет данных изображения',
51
+ 'YandexART returned non-JSON response':
52
+ 'YandexART вернул ответ не в формате JSON',
53
+ 'YandexART did not return operation id':
54
+ 'YandexART не вернул идентификатор операции',
55
+ 'YandexART operation failed':
56
+ 'Операция YandexART завершилась с ошибкой',
57
+ 'YandexART operation timed out':
58
+ 'Превышено время ожидания операции YandexART',
59
+ 'Yandex Operations API returned non-JSON response':
60
+ 'API операций Yandex вернул ответ не в формате JSON',
61
+ 'YandexART API error':
62
+ 'Ошибка API YandexART',
63
+ 'Yandex Operations API error':
64
+ 'Ошибка API операций Yandex',
65
+ 'DeepSeek provider is not configured':
66
+ 'Провайдер DeepSeek не настроен',
67
+ 'Yandex provider is not configured':
68
+ 'Провайдер Yandex не настроен',
69
+ 'Provider "yandex-art" is not configured':
70
+ 'Провайдер «yandex-art» не настроен',
71
+ 'AI stream error':
72
+ 'Ошибка потока ответа ИИ',
73
+ 'AiClient.chatStream: empty response body':
74
+ 'Пустое тело ответа при потоковой генерации',
75
+ 'Ошибка запроса':
76
+ 'Ошибка запроса',
77
+ 'Internal server error':
78
+ HTTP_STATUS_RU[500],
79
+ 'Internal Server Error':
80
+ HTTP_STATUS_RU[500],
81
+ 'stream error':
82
+ 'Ошибка потока ответа'
83
+ };
84
+
85
+ /** @type {Array<{ pattern: RegExp, format: (match: RegExpMatchArray) => string }>} */
86
+ const PREFIX_MESSAGES_RU = [
87
+ {
88
+ pattern: /^YandexART API unreachable: (.+)$/,
89
+ format: ([, detail]) => `API YandexART недоступен: ${detail}`
90
+ },
91
+ {
92
+ pattern: /^Yandex Operations API unreachable: (.+)$/,
93
+ format: ([, detail]) => `API операций Yandex недоступен: ${detail}`
94
+ },
95
+ {
96
+ pattern: /^YandexART API error \((\d+)\)$/,
97
+ format: ([, status]) => `Ошибка API YandexART (${status})`
98
+ },
99
+ {
100
+ pattern: /^Yandex Operations API error \((\d+)\)$/,
101
+ format: ([, status]) => `Ошибка API операций Yandex (${status})`
102
+ },
103
+ {
104
+ pattern: /^Unknown provider: (.+)$/,
105
+ format: ([, id]) => `Неизвестный провайдер: ${id}`
106
+ },
107
+ {
108
+ pattern: /^Provider "(.+)" is not configured$/,
109
+ format: ([, id]) => `Провайдер «${id}» не настроен`
110
+ },
111
+ {
112
+ pattern: /^Cannot load image reference \((\d+)\)$/,
113
+ format: ([, status]) => `Не удалось загрузить опорное изображение (${status})`
114
+ },
115
+ {
116
+ pattern: /^(\d{3}):\s*(.+)$/,
117
+ format: ([, status, detail]) => {
118
+ const ru = translateByHttpStatus(Number(status)) || detail;
119
+ return `${ru} (${status})`;
120
+ }
121
+ }
122
+ ];
123
+
124
+ const AI_CLIENT_WITH_STATUS_RE = /^([A-Za-z][\w.]*)\s*\((\d+)\):\s*(.+)$/s;
125
+ const AI_CLIENT_PLAIN_RE = /^([A-Za-z][\w.]*):\s*(.+)$/s;
126
+
127
+ /**
128
+ * @param {string|null|undefined} raw
129
+ * @returns {string}
130
+ */
131
+ export function formatChatErrorForDisplay(raw) {
132
+ if (raw == null) return '';
133
+ const trimmed = String(raw).trim();
134
+ if (!trimmed) return '';
135
+
136
+ if (trimmed === 'Отменено') {
137
+ return trimmed;
138
+ }
139
+
140
+ const withStatus = trimmed.match(AI_CLIENT_WITH_STATUS_RE);
141
+ if (withStatus) {
142
+ const [, system, status, message] = withStatus;
143
+ const ru = translateErrorMessage(message.trim(), Number(status));
144
+ return `${ru} (${system} (${status}))`;
145
+ }
146
+
147
+ const plain = trimmed.match(AI_CLIENT_PLAIN_RE);
148
+ if (plain) {
149
+ const [, system, message] = plain;
150
+ const statusOnly = /^\d{3}$/.test(message.trim()) ? Number(message.trim()) : undefined;
151
+ const ru = translateErrorMessage(message.trim(), statusOnly);
152
+ return `${ru} (${system})`;
153
+ }
154
+
155
+ return translateErrorMessage(trimmed);
156
+ }
157
+
158
+ /**
159
+ * @param {string} message
160
+ * @param {number} [httpStatus]
161
+ * @returns {string}
162
+ */
163
+ export function translateErrorMessage(message, httpStatus) {
164
+ if (!message && Number.isFinite(httpStatus)) {
165
+ return translateByHttpStatus(httpStatus) || message;
166
+ }
167
+
168
+ if (!message) return message;
169
+
170
+ if (EXACT_MESSAGES_RU[message]) {
171
+ return EXACT_MESSAGES_RU[message];
172
+ }
173
+
174
+ const phraseRu = HTTP_STATUS_PHRASE_RU[message.trim().toLowerCase()];
175
+ if (phraseRu) {
176
+ return phraseRu;
177
+ }
178
+
179
+ if (/^\d{3}$/.test(message.trim())) {
180
+ const byCode = translateByHttpStatus(Number(message.trim()));
181
+ if (byCode) return byCode;
182
+ }
183
+
184
+ for (const { pattern, format } of PREFIX_MESSAGES_RU) {
185
+ const match = message.match(pattern);
186
+ if (match) {
187
+ return format(match);
188
+ }
189
+ }
190
+
191
+ if (Number.isFinite(httpStatus)) {
192
+ const byStatus = translateByHttpStatus(httpStatus);
193
+ if (byStatus && isGenericHttpStatusMessage(message, httpStatus)) {
194
+ return byStatus;
195
+ }
196
+ }
197
+
198
+ return message;
199
+ }
200
+
201
+ /**
202
+ * @param {number} status
203
+ * @returns {string|undefined}
204
+ */
205
+ export function translateByHttpStatus(status) {
206
+ return HTTP_STATUS_RU[status];
207
+ }
208
+
209
+ /**
210
+ * @param {string} message
211
+ * @param {number} [httpStatus]
212
+ * @returns {boolean}
213
+ */
214
+ function isGenericHttpStatusMessage(message, httpStatus) {
215
+ const normalized = message.trim().toLowerCase();
216
+ if (HTTP_STATUS_PHRASE_RU[normalized]) {
217
+ return true;
218
+ }
219
+
220
+ if (/^\d{3}$/.test(normalized) && Number(normalized) === httpStatus) {
221
+ return true;
222
+ }
223
+
224
+ if (Number.isFinite(httpStatus) && normalized === String(httpStatus)) {
225
+ return true;
226
+ }
227
+
228
+ return false;
229
+ }
@@ -34,8 +34,9 @@
34
34
  scroll-behavior: smooth;
35
35
  }
36
36
 
37
+ /* TODO: установить flex, когда будем прикручивать историю */
37
38
  .moodboard-chat__history.is-visible {
38
- display: flex;
39
+ display: none;
39
40
  }
40
41
 
41
42
  .moodboard-chat__msg {
@@ -703,6 +704,7 @@
703
704
  pointer-events: none;
704
705
  z-index: 10;
705
706
  transition:
707
+ left var(--moodboard-chat-board-animation-ms) cubic-bezier(0.22, 1, 0.36, 1),
706
708
  transform var(--moodboard-chat-board-animation-ms) cubic-bezier(0.22, 1, 0.36, 1),
707
709
  opacity var(--moodboard-chat-board-animation-ms) ease;
708
710
  will-change: transform, opacity;
@@ -729,6 +731,30 @@
729
731
  pointer-events: none;
730
732
  }
731
733
 
734
+ /* Блок ошибки сервера */
735
+ .moodboard-chat__error-block {
736
+ display: none;
737
+ background: #ffffff;
738
+ border-radius: 12px;
739
+ padding: 12px 16px;
740
+ margin-bottom: 8px;
741
+ --color-shadow: lab(5.0601% 0 0 / .04);
742
+ box-shadow:
743
+ 0 0 0 1px lab(5.0601% 0 0 / .0784),
744
+ 0 2px 8px -1.5px var(--color-shadow),
745
+ 0 5px 8px -3px var(--color-shadow),
746
+ 0 12px 12px -8px var(--color-shadow),
747
+ 0 24px 24px -8px var(--color-shadow);
748
+ color: #B91C1C;
749
+ font-size: 14px;
750
+ line-height: 1.5;
751
+ word-break: break-word;
752
+ }
753
+
754
+ .moodboard-chat__error-block.is-visible {
755
+ display: block;
756
+ }
757
+
732
758
  /* Попап настроек */
733
759
  .moodboard-chat__settings-popup {
734
760
  position: absolute;