@sequent-org/moodboard 1.4.28 → 1.4.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sequent-org/moodboard",
3
- "version": "1.4.28",
3
+ "version": "1.4.29",
4
4
  "type": "module",
5
5
  "description": "Interactive moodboard",
6
6
  "main": "./src/index.js",
@@ -43,7 +43,7 @@ export class ChatSessionController {
43
43
  this._abort = null;
44
44
 
45
45
  this._state = {
46
- messages: this._history.load(),
46
+ messages: this._history.load().map((m) => (m.pending ? { ...m, pending: false, error: m.error || 'Прервано' } : m)),
47
47
  providerId: 'yandex-art',
48
48
  presetId: DEFAULT_PRESET_ID,
49
49
  settings: this._loadSettings(),
@@ -112,18 +112,23 @@ export class ChatSessionController {
112
112
  /**
113
113
  * Отправляет user-сообщение и создаёт изображение через YandexART.
114
114
  * @param {string} text
115
- * @param {{widthRatio?: number, heightRatio?: number, model?: string}} [options]
115
+ * @param {{widthRatio?: number, heightRatio?: number, model?: string, imageCount?: number}} [options]
116
116
  */
117
117
  async send(text, options = {}) {
118
118
  const trimmed = (text || '').trim();
119
119
  if (!trimmed || this._state.status === 'streaming') return;
120
120
 
121
+ const imageCount = normalizeImageCount(options.imageCount);
121
122
  const userMsg = makeMessage('user', trimmed);
122
- const assistantMsg = makeMessage('assistant', '', { provider: 'yandex-art', pending: true, kind: 'image' });
123
+ const assistantMsgs = Array.from({ length: imageCount }, (_, index) => makeMessage(
124
+ 'assistant',
125
+ imageCount > 1 ? `Генерируется изображение ${index + 1} из ${imageCount}…` : '',
126
+ { provider: 'yandex-art', pending: true, kind: 'image' }
127
+ ));
123
128
 
124
129
  this._state = {
125
130
  ...this._state,
126
- messages: [...this._state.messages, userMsg, assistantMsg],
131
+ messages: [...this._state.messages, userMsg, ...assistantMsgs],
127
132
  status: 'streaming',
128
133
  error: null
129
134
  };
@@ -132,31 +137,54 @@ export class ChatSessionController {
132
137
 
133
138
  const abort = new AbortController();
134
139
  this._abort = abort;
140
+ let lastError = null;
135
141
 
136
142
  try {
137
- const result = await this._client.generateImage({
138
- prompt: trimmed,
139
- widthRatio: options.widthRatio,
140
- heightRatio: options.heightRatio,
141
- model: options.model,
142
- signal: abort.signal
143
- });
144
-
145
- this._finalizeAssistant(assistantMsg.id, {
146
- error: null,
147
- imageBase64: result.imageBase64,
148
- mimeType: result.mimeType,
149
- operationId: result.operationId
150
- });
151
- } catch (err) {
152
- const message = err?.name === 'AbortError' ? 'Отменено' : (err?.message || 'Ошибка запроса');
153
- this._finalizeAssistant(assistantMsg.id, { error: message });
143
+ await Promise.all(
144
+ assistantMsgs.map((assistantMsg, index) => {
145
+ if (abort.signal.aborted) {
146
+ lastError = 'Отменено';
147
+ this._updateAssistant(assistantMsg.id, { error: lastError });
148
+ return Promise.resolve();
149
+ }
150
+
151
+ return this._client
152
+ .generateImage({
153
+ prompt: trimmed,
154
+ widthRatio: options.widthRatio,
155
+ heightRatio: options.heightRatio,
156
+ model: options.model,
157
+ signal: abort.signal
158
+ })
159
+ .then((result) => {
160
+ this._updateAssistant(assistantMsg.id, {
161
+ error: null,
162
+ imageBase64: result.imageBase64,
163
+ mimeType: result.mimeType,
164
+ operationId: result.operationId,
165
+ content: imageCount > 1 ? `Изображение ${index + 1} из ${imageCount} добавлено на доску.` : ''
166
+ });
167
+ })
168
+ .catch((err) => {
169
+ const msg = err?.name === 'AbortError' ? 'Отменено' : (err?.message || 'Ошибка запроса');
170
+ lastError = msg;
171
+ this._updateAssistant(assistantMsg.id, { error: msg });
172
+ });
173
+ })
174
+ );
154
175
  } finally {
155
176
  this._abort = null;
177
+ this._state = {
178
+ ...this._state,
179
+ status: lastError ? 'error' : 'idle',
180
+ error: lastError
181
+ };
182
+ this._history.save(this._state.messages);
183
+ this._emit();
156
184
  }
157
185
  }
158
186
 
159
- _finalizeAssistant(id, { error, imageBase64, mimeType, operationId }) {
187
+ _updateAssistant(id, { error, imageBase64, mimeType, operationId, content }) {
160
188
  const messages = this._state.messages.map((m) =>
161
189
  m.id === id
162
190
  ? {
@@ -165,15 +193,14 @@ export class ChatSessionController {
165
193
  error: error || undefined,
166
194
  imageBase64: imageBase64 || m.imageBase64,
167
195
  mimeType: mimeType || m.mimeType,
168
- operationId: operationId || m.operationId
196
+ operationId: operationId || m.operationId,
197
+ content: content ?? m.content
169
198
  }
170
199
  : m
171
200
  );
172
201
  this._state = {
173
202
  ...this._state,
174
- messages,
175
- status: error ? 'error' : 'idle',
176
- error: error || null
203
+ messages
177
204
  };
178
205
  this._history.save(messages);
179
206
  this._emit();
@@ -218,3 +245,12 @@ function makeMessage(role, content, extra = {}) {
218
245
  ...extra
219
246
  };
220
247
  }
248
+
249
+ function normalizeImageCount(value) {
250
+ const count = Number.parseInt(value, 10);
251
+ if (!Number.isFinite(count)) {
252
+ return 1;
253
+ }
254
+
255
+ return Math.min(Math.max(count, 1), 4);
256
+ }
@@ -132,6 +132,9 @@ export class ChatWindow {
132
132
  // Упорядоченный список ID объектов на доске, размещённых через AI-генерацию.
133
133
  // Используется для сдвига предыдущих изображений влево при новой генерации.
134
134
  this._boardAiImageIds = [];
135
+ this._shiftedForPendingMessageIds = new Set();
136
+ this._pendingShiftCount = 0;
137
+ this._pendingOverlayEls = [];
135
138
  this._onBoardObjectCreated = (data) => {
136
139
  if (data?.objectData?.properties?.name === 'ai-generated.jpg') {
137
140
  this._boardAiImageIds.push(data.objectId);
@@ -244,9 +247,12 @@ export class ChatWindow {
244
247
 
245
248
  detach() {
246
249
  if (!this._attached) return;
250
+ this._clearPendingOverlays();
247
251
  if (this._unsubscribe) { this._unsubscribe(); this._unsubscribe = null; }
248
252
  this._boardCore?.eventBus?.off?.(Events.Object.Created, this._onBoardObjectCreated);
249
253
  this._boardAiImageIds = [];
254
+ this._shiftedForPendingMessageIds.clear();
255
+ this._pendingShiftCount = 0;
250
256
  this._composer?.destroy();
251
257
  this._extendedPromptModal?.destroy();
252
258
  this._contentTypeMenu?.destroy();
@@ -312,6 +318,83 @@ export class ChatWindow {
312
318
  this._countMenu.refresh();
313
319
  this._updateCountPillIcon();
314
320
  this._composer.setStreaming(state.status === 'streaming');
321
+ this._updatePendingImages(state.status === 'streaming' ? state.messages : []);
322
+ }
323
+
324
+ _updatePendingImages(messages) {
325
+ this._clearPendingOverlays();
326
+
327
+ const pending = (messages || []).filter((m) => m.pending && m.kind === 'image');
328
+ if (pending.length === 0) return;
329
+
330
+ const world = this._boardCore?.pixi?.worldLayer || this._boardCore?.pixi?.app?.stage;
331
+ const s = world?.scale?.x || 1;
332
+
333
+ let newPendingCount = 0;
334
+ for (const p of pending) {
335
+ if (!this._shiftedForPendingMessageIds.has(p.id)) {
336
+ this._shiftedForPendingMessageIds.add(p.id);
337
+ newPendingCount++;
338
+ this._pendingShiftCount++;
339
+ }
340
+ }
341
+
342
+ // Сдвигаем все ранее размещённые AI-изображения влево сразу при появлении блока загрузки,
343
+ // чтобы блок не перекрывал их. 320 - ширина блока + отступ в мировых координатах.
344
+ if (newPendingCount > 0 && this._boardAiImageIds.length > 0) {
345
+ const worldShift = Math.round(320 * newPendingCount);
346
+ const objects = this._boardCore?.state?.state?.objects;
347
+ for (const id of this._boardAiImageIds) {
348
+ const obj = objects?.find((o) => o.id === id);
349
+ if (obj?.position) {
350
+ this._boardCore.updateObjectPositionDirect?.(
351
+ id,
352
+ { x: Math.round(obj.position.x - worldShift), y: obj.position.y },
353
+ { snap: false }
354
+ );
355
+ }
356
+ }
357
+ }
358
+
359
+ const chatRect = this._refs?.root?.getBoundingClientRect?.();
360
+ const composerRect = this._refs?.composer?.getBoundingClientRect?.();
361
+ const cx = chatRect
362
+ ? Math.round(chatRect.left + chatRect.width / 2)
363
+ : 400;
364
+ const cy = composerRect
365
+ ? Math.round(composerRect.top - 250)
366
+ : 200;
367
+
368
+ const [wr, hr] = parseFormatRatio(this._formatId);
369
+ const ratio = wr / hr;
370
+ const wScreen = Math.round(300 * s);
371
+ const hScreen = Math.round(wScreen / ratio);
372
+
373
+ for (let i = 0; i < pending.length; i++) {
374
+ // Более новые генерации появляются правее. i=0 — самая старая, должна быть левее.
375
+ const shiftX = (pending.length - 1 - i) * Math.round(320 * s);
376
+ const left = Math.round(cx - wScreen / 2 - shiftX);
377
+ const top = Math.round(cy - hScreen / 2);
378
+
379
+ const overlay = document.createElement('div');
380
+ overlay.className = 'moodboard-chat__pending-overlay';
381
+ overlay.style.cssText = `left:${left}px;top:${top}px;width:${wScreen}px;height:${hScreen}px`;
382
+
383
+ const label = document.createElement('span');
384
+ label.className = 'moodboard-chat__pending-image-label';
385
+ label.textContent = 'В процессе';
386
+ overlay.appendChild(label);
387
+
388
+ document.body.appendChild(overlay);
389
+ this._pendingOverlayEls.push(overlay);
390
+ }
391
+ }
392
+
393
+ _clearPendingOverlays() {
394
+ for (const el of this._pendingOverlayEls) {
395
+ el.remove();
396
+ }
397
+ this._pendingOverlayEls = [];
315
398
  }
316
399
 
317
400
  _getImageRequestOptions() {
@@ -319,7 +402,8 @@ export class ChatWindow {
319
402
  return {
320
403
  widthRatio,
321
404
  heightRatio,
322
- model: this._modelId === 'yandex' ? 'yandex-art' : undefined
405
+ model: this._modelId === 'yandex' ? 'yandex-art' : undefined,
406
+ imageCount: parseImageCount(this._countId)
323
407
  };
324
408
  }
325
409
 
@@ -330,11 +414,14 @@ export class ChatWindow {
330
414
  const world = this._boardCore.pixi?.worldLayer || this._boardCore.pixi?.app?.stage;
331
415
  const s = world?.scale?.x || 1;
332
416
 
333
- // Сдвигаем все ранее размещённые AI-изображения влево на 320 экранных пикселей
334
- // мировых единицах: 320 / масштаб), чтобы новое изображение всегда появлялось
335
- // на фиксированной базовой позиции, не перекрывая предыдущие.
336
- if (this._boardAiImageIds.length > 0) {
337
- const worldShift = Math.round(320 / s);
417
+ // Если генерация прошла без режима загрузки (например, мгновенный ответ),
418
+ // нужно сдвинуть существующие изображения сейчас. Иначе берем сохраненный индекс сдвига.
419
+ let myShiftIndex = 0;
420
+ if (this._pendingShiftCount > 0) {
421
+ this._pendingShiftCount--;
422
+ myShiftIndex = this._pendingShiftCount;
423
+ } else if (this._boardAiImageIds.length > 0) {
424
+ const worldShift = 320;
338
425
  const objects = this._boardCore.state?.state?.objects;
339
426
  for (const id of this._boardAiImageIds) {
340
427
  const obj = objects?.find((o) => o.id === id);
@@ -355,14 +442,18 @@ export class ChatWindow {
355
442
  // Чтобы нижний край изображения был на 100 px выше composer: y = composerTop - 250.
356
443
  const chatRect = this._refs?.root?.getBoundingClientRect?.();
357
444
  const composerRect = this._refs?.composer?.getBoundingClientRect?.();
358
- const x = chatRect
445
+ const xBase = chatRect
359
446
  ? Math.round(chatRect.left + chatRect.width / 2)
360
447
  : (view ? Math.round(view.clientWidth / 2) : 400);
448
+
449
+ const x = Math.round(xBase - myShiftIndex * 320 * s);
450
+
361
451
  const y = composerRect
362
452
  ? Math.round(composerRect.top - 250)
363
453
  : (chatRect
364
454
  ? Math.round(chatRect.top - 150)
365
455
  : (view ? Math.round(view.clientHeight * 0.3) : 200));
456
+
366
457
  this._boardCore.eventBus.emit(Events.UI.PasteImageAt, {
367
458
  x, y,
368
459
  src: dataUrl,
@@ -405,3 +496,16 @@ function parseFormatRatio(formatId) {
405
496
 
406
497
  return [width, height];
407
498
  }
499
+
500
+ function parseImageCount(countId) {
501
+ if (!countId || countId === 'auto') {
502
+ return 1;
503
+ }
504
+
505
+ const count = Number.parseInt(countId, 10);
506
+ if (!Number.isFinite(count)) {
507
+ return 1;
508
+ }
509
+
510
+ return Math.min(Math.max(count, 1), 4);
511
+ }
@@ -28,17 +28,21 @@ export function buildChatDom() {
28
28
  statusBar.setAttribute('aria-atomic', 'true');
29
29
  statusBar.innerHTML = '<span class="moodboard-chat__status-bar-text">Идёт процесс генерации изображения…</span>';
30
30
 
31
+ const pendingImages = createDiv('moodboard-chat__pending-images');
32
+
31
33
  const rendererRefs = {
32
34
  root,
33
35
  history,
34
36
  composer,
35
- statusBar
37
+ statusBar,
38
+ pendingImages
36
39
  };
37
40
 
38
41
  composer.appendChild(buildInputRow(refs => Object.assign(rendererRefs, refs)));
39
42
  composer.appendChild(buildActionsRow(refs => Object.assign(rendererRefs, refs)));
40
43
 
41
44
  root.appendChild(history);
45
+ root.appendChild(pendingImages);
42
46
  root.appendChild(statusBar);
43
47
  root.appendChild(composer);
44
48
 
@@ -661,6 +661,63 @@
661
661
  display: block;
662
662
  }
663
663
 
664
+ /* Контейнер блоков-заглушек при параллельной генерации изображений */
665
+ .moodboard-chat__pending-images {
666
+ display: none;
667
+ gap: 8px;
668
+ background: #ffffff;
669
+ border-radius: 12px;
670
+ padding: 8px;
671
+ margin-bottom: 8px;
672
+ box-shadow: 0 4px 24px rgba(0, 0, 0, 0.06);
673
+ }
674
+
675
+ .moodboard-chat__pending-images.is-visible {
676
+ display: flex;
677
+ }
678
+
679
+ .moodboard-chat__pending-image-block {
680
+ flex: 1;
681
+ min-height: 180px;
682
+ background: #6B7E87;
683
+ border-radius: 8px;
684
+ position: relative;
685
+ overflow: hidden;
686
+ }
687
+
688
+ /* Overlay-заглушка на полотне доски во время генерации изображения */
689
+ @keyframes moodboard-pending-shimmer {
690
+ 0% { background-position: -100% 0; }
691
+ 100% { background-position: 200% 0; }
692
+ }
693
+
694
+ .moodboard-chat__pending-overlay {
695
+ position: fixed;
696
+ background: linear-gradient(
697
+ 90deg,
698
+ #5F7179 0%,
699
+ #7A8D96 50%,
700
+ #5F7179 100%
701
+ );
702
+ background-size: 200% 100%;
703
+ animation: moodboard-pending-shimmer 5.76s linear infinite;
704
+ border-radius: 8px;
705
+ overflow: hidden;
706
+ pointer-events: none;
707
+ z-index: 10;
708
+ }
709
+
710
+ .moodboard-chat__pending-image-label {
711
+ position: absolute;
712
+ bottom: 10px;
713
+ left: 12px;
714
+ font-family: 'GeistSans', 'GeistSans Fallback', 'Roboto', Arial, sans-serif;
715
+ font-size: 20px;
716
+ font-weight: 400;
717
+ color: #ffffff;
718
+ pointer-events: none;
719
+ }
720
+
664
721
  /* Попап настроек */
665
722
  .moodboard-chat__settings-popup {
666
723
  position: absolute;