@sequent-org/moodboard 1.4.41 → 1.4.43

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.41",
3
+ "version": "1.4.43",
4
4
  "type": "module",
5
5
  "description": "Interactive moodboard",
6
6
  "main": "./src/index.js",
@@ -28,7 +28,10 @@ export class AiClient {
28
28
 
29
29
  /**
30
30
  * Список доступных провайдеров.
31
- * @returns {Promise<Array<{id: string, label: string, enabled: boolean}>>}
31
+ *
32
+ * @returns {Promise<Array<{id: string, label: string, enabled: boolean, supportedRatios: string[]|null}>>}
33
+ * supportedRatios — массив id форматов из FORMAT_OPTIONS (например ['1:1','3:2','2:3']),
34
+ * либо null, если провайдер не ограничивает доступные соотношения сторон.
32
35
  */
33
36
  async listProviders() {
34
37
  const res = await this._fetch(`${this._baseUrl}/providers`, {
@@ -26,7 +26,7 @@ const CONTENT_TYPE_OPTIONS = [
26
26
  }
27
27
  ];
28
28
 
29
- /** Порядок: портрет (строка 1), авто + альбом (строка 2) — соответствует грид-меню */
29
+ /** Порядок: портрет (строка 1), альбом (строка 2), авто крайнее правое */
30
30
  const FORMAT_OPTIONS = [
31
31
  { id: '1:1', label: '1:1', icon: RATIO_ICONS['1:1'] },
32
32
  { id: '4:5', label: '4:5', icon: RATIO_ICONS['4:5'] },
@@ -35,13 +35,13 @@ const FORMAT_OPTIONS = [
35
35
  { id: '2:3', label: '2:3', icon: RATIO_ICONS['2:3'] },
36
36
  { id: '9:16', label: '9:16', icon: RATIO_ICONS['9:16'] },
37
37
  { id: '1:2', label: '1:2', icon: RATIO_ICONS['1:2'] },
38
- { id: 'auto', label: 'Auto', icon: RATIO_ICONS['auto'] },
39
38
  { id: '5:4', label: '5:4', icon: RATIO_ICONS['5:4'] },
40
39
  { id: '4:3', label: '4:3', icon: RATIO_ICONS['4:3'] },
41
40
  { id: '14:10', label: '14:10', icon: RATIO_ICONS['14:10'] },
42
41
  { id: '3:2', label: '3:2', icon: RATIO_ICONS['3:2'] },
43
42
  { id: '16:9', label: '16:9', icon: RATIO_ICONS['16:9'] },
44
43
  { id: '2:1', label: '2:1', icon: RATIO_ICONS['2:1'] },
44
+ { id: 'auto', label: 'Auto', icon: RATIO_ICONS['auto'] },
45
45
  ];
46
46
 
47
47
  const COUNT_OPTIONS = [
@@ -61,12 +61,16 @@ const BOARD_IMAGE_REARRANGE_MS = 520;
61
61
  const BOARD_IMAGE_PENDING_ENTER_FACTOR = 1.6;
62
62
  // Каскад между блоками одного батча (мс): пользователь видит, что они приезжают друг за другом.
63
63
  const BOARD_IMAGE_PENDING_STAGGER_MS = 90;
64
+ // Референсная высота ряда AI-картинок: центры выравниваются по одной оси независимо от формата.
65
+ const BOARD_IMAGE_LANE_REFERENCE_RATIO = [2, 3];
66
+ const BOARD_IMAGE_LANE_CENTER_OFFSET = 250;
67
+ const BOARD_IMAGE_LANE_UI_GAP = 16;
64
68
 
65
69
  const MODEL_OPTIONS = [
66
70
  {
67
71
  id: 'auto',
68
72
  label: 'Автоматический режим',
69
- icon: ICONS.sparkles,
73
+ icon: ICONS.modelBot,
70
74
  description: 'Мы подберем модель для ваших задач.'
71
75
  },
72
76
  {
@@ -135,6 +139,7 @@ export class ChatWindow {
135
139
  this._modelMenu = null;
136
140
  this._formatId = 'auto';
137
141
  this._formatMenu = null;
142
+ this._providers = [];
138
143
  this._countId = 'auto';
139
144
  this._countMenu = null;
140
145
  this._unsubscribe = null;
@@ -219,6 +224,7 @@ export class ChatWindow {
219
224
  onSelect: (id) => {
220
225
  this._modelId = id;
221
226
  this._modelMenu.refresh();
227
+ this._clampFormatToModel();
222
228
  }
223
229
  }
224
230
  );
@@ -227,7 +233,7 @@ export class ChatWindow {
227
233
  this._formatMenu = new ChatPillMenu(
228
234
  { trigger: this._refs.formatPill, menu: this._refs.formatMenu, label: this._refs.formatLabel },
229
235
  {
230
- getOptions: () => FORMAT_OPTIONS,
236
+ getOptions: () => this._getSupportedFormatOptions(),
231
237
  getActiveId: () => this._formatId,
232
238
  onSelect: (id) => {
233
239
  this._formatId = id;
@@ -332,13 +338,65 @@ export class ChatWindow {
332
338
  async _loadProviders() {
333
339
  try {
334
340
  const list = await this._aiClient.listProviders();
341
+ this._providers = Array.isArray(list) ? list : [];
335
342
  this._session.setAvailableProviders(list);
343
+ this._clampFormatToModel();
336
344
  } catch (err) {
337
345
  console.warn('[ChatWindow] cannot load providers:', err.message);
346
+ this._providers = [];
338
347
  this._session.setAvailableProviders([]);
339
348
  }
340
349
  }
341
350
 
351
+ /**
352
+ * Возвращает id провайдера изображений для текущей модели.
353
+ * Дублирует логику из _getImageRequestOptions, чтобы не привязываться к payload-контексту.
354
+ *
355
+ * @param {string} modelId
356
+ * @returns {string}
357
+ */
358
+ _imageProviderForModel(modelId) {
359
+ return modelId === 'gpt' ? 'openai-image' : 'yandex-art';
360
+ }
361
+
362
+ /**
363
+ * Возвращает подмножество FORMAT_OPTIONS, поддерживаемое текущим провайдером.
364
+ * Если провайдер не ограничивает форматы (supportedRatios === null) — возвращает все.
365
+ * Формат 'auto' присутствует всегда.
366
+ *
367
+ * @returns {Array}
368
+ */
369
+ _getSupportedFormatOptions() {
370
+ const providerId = this._imageProviderForModel(this._modelId);
371
+ const provider = this._providers.find((p) => p.id === providerId);
372
+ const ratios = provider?.supportedRatios;
373
+
374
+ if (!Array.isArray(ratios) || ratios.length === 0) {
375
+ return FORMAT_OPTIONS;
376
+ }
377
+
378
+ return FORMAT_OPTIONS.filter((opt) => opt.id === 'auto' || ratios.includes(opt.id));
379
+ }
380
+
381
+ /**
382
+ * При смене модели проверяет, что текущий формат входит в список поддерживаемых.
383
+ * Если нет — сбрасывает на 'auto'.
384
+ */
385
+ _clampFormatToModel() {
386
+ const supported = this._getSupportedFormatOptions();
387
+ const stillValid = supported.some((opt) => opt.id === this._formatId);
388
+ if (!stillValid) {
389
+ this._formatId = 'auto';
390
+ }
391
+ // refresh обновляет лейбл из option.label ('Auto'), поэтому _updateFormatPillLabel
392
+ // вызывается после — она выставляет человекочитаемый текст 'Соотношение сторон'.
393
+ this._formatMenu?.refresh();
394
+ if (!stillValid) {
395
+ this._updateFormatPillIcon();
396
+ this._updateFormatPillLabel();
397
+ }
398
+ }
399
+
342
400
  _render(state) {
343
401
  if (!this._attached && !this._refs) return;
344
402
  this._syncGeneratedImagesToBoard(state.messages);
@@ -894,23 +952,74 @@ export class ChatWindow {
894
952
  };
895
953
  }
896
954
 
897
- _getImageGroupAnchor() {
898
- const composerRect = this._refs?.composer?.getBoundingClientRect?.();
899
- if (composerRect) {
900
- let y = composerRect.top - 250;
901
-
902
- const errorBlock = this._refs?.errorBlock;
903
- if (errorBlock && errorBlock.classList.contains('is-visible')) {
904
- const errorRect = errorBlock.getBoundingClientRect();
905
- // Низ картинок примерно y + 150.
906
- // Хотим чтобы errorRect.top был хотя бы y + 150 + 16 (gap)
907
- // То есть y + 166 <= errorRect.top
908
- const minY = errorRect.top - 166;
955
+ _getAiImageLaneCenterScreenY(scale = 1) {
956
+ const world = this._boardCore?.pixi?.worldLayer || this._boardCore?.pixi?.app?.stage;
957
+ const s = scale || world?.scale?.x || 1;
958
+ const worldOffsetY = world?.y || 0;
959
+
960
+ for (const record of this._pendingOverlays.values()) {
961
+ if (Number.isFinite(record.worldY)) {
962
+ return Math.round(record.worldY * s + worldOffsetY);
963
+ }
964
+ }
965
+
966
+ for (const object of this._getBoardAiImageObjects()) {
967
+ if (!object?.position) continue;
968
+
969
+ const height = getBoardObjectHeight(object);
970
+ return Math.round((object.position.y + height / 2) * s + worldOffsetY);
971
+ }
972
+
973
+ for (const slot of this._aiImageLaneSlots.values()) {
974
+ if (slot && Number.isFinite(slot.y) && Number.isFinite(slot.height)) {
975
+ return Math.round((slot.y + slot.height / 2) * s + worldOffsetY);
976
+ }
977
+ }
978
+
979
+ return null;
980
+ }
981
+
982
+ _clampImageGroupAnchorY(y, referenceHeight) {
983
+ const clearance = Math.round(referenceHeight / 2) + BOARD_IMAGE_LANE_UI_GAP;
984
+
985
+ const errorBlock = this._refs?.errorBlock;
986
+ if (errorBlock && errorBlock.classList.contains('is-visible')) {
987
+ const errorRect = errorBlock.getBoundingClientRect();
988
+ if (errorRect.height > 0) {
989
+ const minY = errorRect.top - clearance;
990
+ if (y > minY) {
991
+ y = minY;
992
+ }
993
+ }
994
+ }
995
+
996
+ const statusBar = this._refs?.statusBar;
997
+ if (statusBar && statusBar.classList.contains('is-visible')) {
998
+ const statusRect = statusBar.getBoundingClientRect();
999
+ // jsdom отдаёт нулевой rect — без реальной геометрии не сдвигаем ряд.
1000
+ if (statusRect.height > 0 && statusRect.top > 0) {
1001
+ const minY = statusRect.top - clearance;
909
1002
  if (y > minY) {
910
1003
  y = minY;
911
1004
  }
912
1005
  }
913
-
1006
+ }
1007
+
1008
+ return y;
1009
+ }
1010
+
1011
+ _getImageGroupAnchor() {
1012
+ const composerRect = this._refs?.composer?.getBoundingClientRect?.();
1013
+ if (composerRect) {
1014
+ const existingCenterY = this._getAiImageLaneCenterScreenY();
1015
+ const [wr, hr] = parseFormatRatio(this._formatId);
1016
+ const actualHeight = Math.round(BOARD_IMAGE_WIDTH / (wr / hr));
1017
+ let y = existingCenterY ?? (composerRect.top - Math.round(actualHeight / 2) - BOARD_IMAGE_LANE_UI_GAP);
1018
+
1019
+ if (existingCenterY == null) {
1020
+ y = this._clampImageGroupAnchorY(y, actualHeight);
1021
+ }
1022
+
914
1023
  return {
915
1024
  x: Math.round(composerRect.left + composerRect.width / 2),
916
1025
  y: Math.round(y)
@@ -1386,6 +1495,11 @@ export class ChatWindow {
1386
1495
  }
1387
1496
  }
1388
1497
 
1498
+ function getBoardImageReferenceHeight() {
1499
+ const [widthRatio, heightRatio] = BOARD_IMAGE_LANE_REFERENCE_RATIO;
1500
+ return Math.round(BOARD_IMAGE_WIDTH / (widthRatio / heightRatio));
1501
+ }
1502
+
1389
1503
  function parseFormatRatio(formatId) {
1390
1504
  if (!formatId || formatId === 'auto') {
1391
1505
  return [1, 1];
@@ -54,7 +54,12 @@ const MODEL_QWEN_ICON = `<svg xmlns="http://www.w3.org/2000/svg" width="36" heig
54
54
  /** Placeholder Yandex Alice — буква А в круге, 36×36 */
55
55
  const MODEL_ALICE_ICON = `<svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 36 36" fill="none" aria-hidden="true"><circle cx="18" cy="18" r="16" fill="#fc3f1d"/><text x="18" y="23" text-anchor="middle" font-size="16" font-family="Arial,sans-serif" fill="#fff" font-weight="bold">А</text></svg>`;
56
56
 
57
+ /** Иконка «Автоматический режим» — робот, 36×36, outline */
58
+ const MODEL_BOT_ICON = `<svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 8V4H8"/><rect width="16" height="12" x="4" y="8" rx="2"/><path d="M2 14h2"/><path d="M20 14h2"/><path d="M15 13v2"/><path d="M9 13v2"/></svg>`;
59
+
57
60
  export const ICONS = {
61
+ bot: svg('<path d="M12 8V4H8"/><rect width="16" height="12" x="4" y="8" rx="2"/><path d="M2 14h2"/><path d="M20 14h2"/><path d="M15 13v2"/><path d="M9 13v2"/>'),
62
+ modelBot: MODEL_BOT_ICON,
58
63
  image: IMAGE_ICON,
59
64
  video: VIDEO_ICON,
60
65
  bolt: svg('<path d="M13 3L4 14h6l-1 7 9-11h-6l1-7z"/>'),