@sequent-org/moodboard 1.4.34 → 1.4.37

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.34",
3
+ "version": "1.4.37",
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(
@@ -1,4 +1,5 @@
1
1
  import { CoreMoodBoard } from '../../core/index.js';
2
+ import { BOARD_PALETTE } from '../../ui/boardPalette.js';
2
3
  import { createMoodBoardManagers, wireMoodBoardServices } from './MoodBoardManagersFactory.js';
3
4
  import { createMoodBoardUi } from './MoodBoardUiFactory.js';
4
5
  import { bindSaveCallbacks } from '../integration/MoodBoardEventBindings.js';
@@ -77,7 +78,7 @@ export async function initCoreMoodBoard(board) {
77
78
  boardId: board.options.boardId || 'workspace-board',
78
79
  width: canvasSize.width,
79
80
  height: canvasSize.height,
80
- backgroundColor: board.options.theme === 'dark' ? 0x2a2a2a : 0xDAEEFB,
81
+ backgroundColor: board.options.theme === 'dark' ? 0x2a2a2a : parseInt(BOARD_PALETTE[0].board.replace('#', ''), 16),
81
82
  saveEndpoint: board.options.saveEndpoint,
82
83
  loadEndpoint: board.options.loadEndpoint,
83
84
  };
package/src/ui/Topbar.js CHANGED
@@ -3,6 +3,7 @@
3
3
  */
4
4
  import { Events } from '../core/events/Events.js';
5
5
  import { TopbarIconLoader } from '../utils/topbarIconLoader.js';
6
+ import { BOARD_PALETTE } from './boardPalette.js';
6
7
 
7
8
  export class Topbar {
8
9
  constructor(container, eventBus, theme = 'light') {
@@ -15,13 +16,7 @@ export class Topbar {
15
16
  this.iconLoader = new TopbarIconLoader();
16
17
  this.icons = this.iconLoader.icons;
17
18
  // Палитра кнопки заливки и соответствие цвету фона доски
18
- this._palette = [
19
- { id: 1, name: 'default-light', btnHex: '#d6e8f7', board: '#f0f6fc' },
20
- { id: 2, name: 'mint-light', btnHex: '#E8F5E9', board: '#f8fff7' },
21
- { id: 3, name: 'peach-light', btnHex: '#FFF3E0', board: '#fffcf7' },
22
- { id: 4, name: 'gray-light', btnHex: '#f5f5f5', board: '#f5f5f5' },
23
- { id: 5, name: 'white', btnHex: '#ffffff', board: '#ffffff' }
24
- ];
19
+ this._palette = BOARD_PALETTE;
25
20
  this._pendingPaintHex = null;
26
21
  this.init();
27
22
  }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Палитра фонов доски.
3
+ * Первый элемент используется как фон по умолчанию при создании/первом открытии доски.
4
+ * btnHex — цвет кнопки в Topbar; board — реальный цвет фона canvas.
5
+ */
6
+ export const BOARD_PALETTE = [
7
+ { id: 1, name: 'default-light', btnHex: '#d6e8f7', board: '#f0f6fc' },
8
+ { id: 2, name: 'mint-light', btnHex: '#E8F5E9', board: '#f8fff7' },
9
+ { id: 3, name: 'peach-light', btnHex: '#FFF3E0', board: '#fffcf7' },
10
+ { id: 4, name: 'gray-light', btnHex: '#f5f5f5', board: '#f5f5f5' },
11
+ { id: 5, name: 'white', btnHex: '#ffffff', board: '#ffffff' },
12
+ ];
@@ -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';
@@ -145,7 +146,9 @@ export class ChatWindow {
145
146
  this._pendingOverlayTimers = new Map();
146
147
  this._boardImageShiftAnimations = new Map();
147
148
  this._pendingBatchOffsets = new Map();
148
- this._clearSelectionOnSendClick = null;
149
+ this._aiImageLaneSlots = new Map();
150
+ this._draggingAiImageIds = new Set();
151
+ this._aiImageLaneHandlers = null;
149
152
  this._selectionHandlers = null;
150
153
  this._viewportHandlers = null;
151
154
  // Окно от BoxSelectStart до BoxSelectCommit: в это время SelectionAdd
@@ -178,11 +181,10 @@ export class ChatWindow {
178
181
  onAbort: () => this._session.abort()
179
182
  }
180
183
  );
181
- this._clearSelectionOnSendClick = () => this._clearBoardSelection();
182
- this._refs.send.addEventListener('click', this._clearSelectionOnSendClick);
183
184
  this._composer.attach();
184
185
  this._attachSelectionEvents();
185
186
  this._attachViewportSync();
187
+ this._attachAiImageLaneEvents();
186
188
 
187
189
  this._extendedPromptModal = new ChatExtendedPromptModal(
188
190
  this._container,
@@ -266,15 +268,14 @@ export class ChatWindow {
266
268
  this._clearPendingOverlays();
267
269
  this._cancelBoardImageShiftAnimations();
268
270
  if (this._unsubscribe) { this._unsubscribe(); this._unsubscribe = null; }
269
- if (this._clearSelectionOnSendClick && this._refs?.send) {
270
- this._refs.send.removeEventListener('click', this._clearSelectionOnSendClick);
271
- this._clearSelectionOnSendClick = null;
272
- }
273
271
  this._detachSelectionEvents();
274
272
  this._detachViewportSync();
273
+ this._detachAiImageLaneEvents();
275
274
  this._shiftedForImageBatchKeys.clear();
276
275
  this._boardImageShiftHistory.clear();
277
276
  this._pendingBatchOffsets.clear();
277
+ this._aiImageLaneSlots.clear();
278
+ this._draggingAiImageIds.clear();
278
279
  this._composer?.destroy();
279
280
  this._extendedPromptModal?.destroy();
280
281
  this._contentTypeMenu?.destroy();
@@ -354,6 +355,26 @@ export class ChatWindow {
354
355
  this._updateCountPillIcon();
355
356
  this._composer.setStreaming(state.status === 'streaming');
356
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
+ }
357
378
  }
358
379
 
359
380
  _updatePendingImages(messages) {
@@ -420,11 +441,13 @@ export class ChatWindow {
420
441
  worldX: existing.worldX,
421
442
  worldY: existing.worldY
422
443
  };
444
+ this._reserveAiImageLaneSlotForMessage(message.id, existing.worldX, existing.worldY);
423
445
  this._applyPendingOverlayScreenLayout(existing.el, screenLayout, { animate: true, enterDistance });
424
446
  return;
425
447
  }
426
448
 
427
449
  const layout = this._computePendingOverlayScreenLayout(messages, message.id, s);
450
+ this._reserveAiImageLaneSlotForMessage(message.id, layout.worldX, layout.worldY);
428
451
 
429
452
  const overlay = document.createElement('div');
430
453
  overlay.className = 'moodboard-chat__pending-overlay moodboard-chat__pending-overlay--enter';
@@ -498,7 +521,8 @@ export class ChatWindow {
498
521
 
499
522
  const world = this._boardCore?.pixi?.worldLayer || this._boardCore?.pixi?.app?.stage;
500
523
  const worldX = world?.x || 0;
501
- const existingRight_world = Math.max(...aiObjects.map((obj) => obj.position.x + getBoardObjectWidth(obj)));
524
+ const existingRight_world = this._getAiImageLaneRightBoundary(aiObjects);
525
+ if (!Number.isFinite(existingRight_world)) return baseEnter;
502
526
  const existingRight_screen = Math.round(existingRight_world * scale + worldX);
503
527
 
504
528
  const step = Math.round(BOARD_IMAGE_STEP * scale);
@@ -543,6 +567,7 @@ export class ChatWindow {
543
567
  const layout = this._computePendingOverlayScreenLayout(messages, messageId, scale);
544
568
  record.worldX = layout.worldX;
545
569
  record.worldY = layout.worldY;
570
+ this._reserveAiImageLaneSlotForMessage(messageId, layout.worldX, layout.worldY);
546
571
  this._applyPendingOverlayScreenLayout(record.el, layout, { animate: true });
547
572
  existingOverlaysShifted = true;
548
573
  }
@@ -555,8 +580,25 @@ export class ChatWindow {
555
580
  const worldShift = shiftAmount / scale;
556
581
  for (const obj of this._getBoardAiImageObjects()) {
557
582
  if (obj?.position) {
583
+ const key = getAiImageLaneKeyForObject(obj);
584
+ const currentSlot = this._getAiImageLaneSlotForObject(obj);
558
585
  const from = { x: obj.position.x, y: obj.position.y };
559
- const to = { x: Math.round(obj.position.x - worldShift), 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
+ }
560
602
  this._animateBoardImageToPosition(obj.id, from, to);
561
603
  }
562
604
  }
@@ -641,6 +683,7 @@ export class ChatWindow {
641
683
  const screenX = Math.round(record.worldX * s + (world?.x || 0));
642
684
  const screenY = Math.round(record.worldY * s + (world?.y || 0));
643
685
  const el = record.el;
686
+ this._reserveAiImageLaneSlotForMessage(messageId, record.worldX, record.worldY);
644
687
  if (disableTransition) {
645
688
  el.style.transition = 'none';
646
689
  }
@@ -683,6 +726,62 @@ export class ChatWindow {
683
726
  this._viewportHandlers = null;
684
727
  }
685
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
+
686
785
  _attachSelectionEvents() {
687
786
  const eventBus = this._boardCore?.eventBus;
688
787
  if (!eventBus || typeof eventBus.on !== 'function' || this._selectionHandlers) return;
@@ -795,9 +894,23 @@ export class ChatWindow {
795
894
  _getImageGroupAnchor() {
796
895
  const composerRect = this._refs?.composer?.getBoundingClientRect?.();
797
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
+
798
911
  return {
799
912
  x: Math.round(composerRect.left + composerRect.width / 2),
800
- y: Math.round(composerRect.top - 250)
913
+ y: Math.round(y)
801
914
  };
802
915
  }
803
916
 
@@ -842,7 +955,8 @@ export class ChatWindow {
842
955
  const aiObjects = this._getBoardAiImageObjects();
843
956
  if (aiObjects.length === 0 || !nextBatchBounds) return;
844
957
 
845
- const existingRight = Math.max(...aiObjects.map((object) => object.position.x + getBoardObjectWidth(object)));
958
+ const existingRight = this._getAiImageLaneRightBoundary(aiObjects);
959
+ if (!Number.isFinite(existingRight)) return;
846
960
  const shift = Math.ceil(existingRight + BOARD_IMAGE_GAP - nextBatchBounds.left);
847
961
  if (shift <= 0) return;
848
962
 
@@ -852,9 +966,28 @@ export class ChatWindow {
852
966
  for (const id of ids) {
853
967
  const obj = objects?.find((item) => item.id === id);
854
968
  if (obj?.position) {
969
+ const key = getAiImageLaneKeyForObject(obj);
970
+ const currentSlot = this._getAiImageLaneSlotForObject(obj);
855
971
  const from = { x: obj.position.x, y: obj.position.y };
856
- const to = { x: Math.round(obj.position.x - shift), y: obj.position.y };
857
- 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 });
858
991
  this._animateBoardImageToPosition(id, from, to);
859
992
  }
860
993
  }
@@ -869,9 +1002,13 @@ export class ChatWindow {
869
1002
  if (!record) return;
870
1003
 
871
1004
  const objects = this._boardCore?.state?.state?.objects;
872
- for (const { id, from } of record) {
1005
+ for (const { id, from, fromSlot } of record) {
873
1006
  const obj = objects?.find((item) => item.id === id);
874
1007
  if (obj?.position) {
1008
+ const key = getAiImageLaneKeyForObject(obj);
1009
+ if (key && fromSlot) {
1010
+ this._aiImageLaneSlots.set(key, fromSlot);
1011
+ }
875
1012
  this._animateBoardImageToPosition(id, obj.position, from);
876
1013
  }
877
1014
  }
@@ -902,6 +1039,58 @@ export class ChatWindow {
902
1039
  }
903
1040
  }
904
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
+
905
1094
  _animateBoardImageToPosition(id, fromPosition, toPosition) {
906
1095
  const updatePosition = this._boardCore?.updateObjectPositionDirect;
907
1096
  if (!id || typeof updatePosition !== 'function' || !fromPosition || !toPosition) return;
@@ -976,6 +1165,156 @@ export class ChatWindow {
976
1165
  return objects.filter((object) => isBoardAiImageObject(object));
977
1166
  }
978
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
+
979
1318
  _addImageToBoard(msg) {
980
1319
  if (!this._boardCore?.eventBus) return;
981
1320
  const dataUrl = `data:${msg.mimeType || 'image/jpeg'};base64,${msg.imageBase64}`;
@@ -997,12 +1336,14 @@ export class ChatWindow {
997
1336
  x = slot.x;
998
1337
  y = slot.y;
999
1338
  }
1339
+ const insertPoint = this._resolveAiImageInsertPoint(msg, x, y, s);
1000
1340
 
1001
1341
  this._boardCore.eventBus.emit(Events.UI.PasteImageAt, {
1002
- x,
1003
- y,
1342
+ x: insertPoint.x,
1343
+ y: insertPoint.y,
1004
1344
  src: dataUrl,
1005
1345
  name: 'ai-generated.jpg',
1346
+ aiMessageId: msg.id,
1006
1347
  skipUpload: true
1007
1348
  });
1008
1349
  }
@@ -1108,6 +1449,19 @@ function isBoardAiImageObject(object) {
1108
1449
  && Number.isFinite(object.position.x);
1109
1450
  }
1110
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
+
1111
1465
  function isReferenceImageObject(object) {
1112
1466
  return Boolean(object?.id)
1113
1467
  && (object.type === 'image' || object.type === 'revit-screenshot-img')
@@ -1183,6 +1537,11 @@ function getBoardObjectWidth(object) {
1183
1537
  return Number.isFinite(width) ? Math.max(1, Math.round(width)) : BOARD_IMAGE_WIDTH;
1184
1538
  }
1185
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
+
1186
1545
  function easeOutCubic(progress) {
1187
1546
  return 1 - Math.pow(1 - progress, 3);
1188
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
+ }
@@ -731,6 +731,30 @@
731
731
  pointer-events: none;
732
732
  }
733
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
+
734
758
  /* Попап настроек */
735
759
  .moodboard-chat__settings-popup {
736
760
  position: absolute;