@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 +1 -1
- package/src/core/flows/ClipboardFlow.js +2 -1
- package/src/services/ai/ChatSessionController.js +14 -8
- package/src/ui/chat/ChatComposer.js +5 -2
- package/src/ui/chat/ChatWindow.js +644 -39
- package/src/ui/chat/ChatWindowRenderer.js +6 -1
- package/src/ui/chat/formatChatError.js +229 -0
- package/src/ui/styles/chat.css +27 -1
package/package.json
CHANGED
|
@@ -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
|
-
|
|
107
|
-
try {
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
373
|
-
const
|
|
374
|
-
const
|
|
375
|
-
const
|
|
376
|
-
|
|
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
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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:${
|
|
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, {
|
|
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
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
636
|
-
|
|
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
|
-
|
|
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:
|
|
769
|
-
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
|
+
}
|
package/src/ui/styles/chat.css
CHANGED
|
@@ -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:
|
|
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;
|