@sequent-org/moodboard 1.4.27 → 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,17 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Тонкий HTTP-клиент к /api/ai.
|
|
2
|
+
* Тонкий HTTP-клиент к /api/v2/ai.
|
|
3
|
+
*
|
|
4
|
+
* Одна ответственность: общение с backend-эндпоинтами AI.
|
|
5
|
+
* В dev-режиме за same-origin стоит Node-заглушка (server/), в проде —
|
|
6
|
+
* Laravel-пакет futurello/moodboard (контроллер AiController).
|
|
7
|
+
* Контракт payload и SSE-формат у них одинаковый.
|
|
3
8
|
*
|
|
4
|
-
* Одна ответственность: общение с прокси-сервером (server/).
|
|
5
9
|
* Не знает ни про UI, ни про localStorage. Возвращает обычные данные
|
|
6
10
|
* и async generator для стриминга.
|
|
7
11
|
*/
|
|
8
12
|
|
|
9
|
-
const DEFAULT_BASE_URL = '/api/ai';
|
|
13
|
+
const DEFAULT_BASE_URL = '/api/v2/ai';
|
|
10
14
|
|
|
11
15
|
export class AiClient {
|
|
12
16
|
/**
|
|
13
17
|
* @param {object} options
|
|
14
|
-
* @param {string} [options.baseUrl='/api/ai']
|
|
18
|
+
* @param {string} [options.baseUrl='/api/v2/ai']
|
|
15
19
|
* @param {typeof fetch} [options.fetchImpl]
|
|
16
20
|
*/
|
|
17
21
|
constructor(options = {}) {
|
|
@@ -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
|
|
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,
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
334
|
-
//
|
|
335
|
-
|
|
336
|
-
if (this.
|
|
337
|
-
|
|
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
|
|
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
|
|
package/src/ui/styles/chat.css
CHANGED
|
@@ -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;
|