@sequent-org/moodboard 1.4.45 → 1.4.47
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/events/Events.js +1 -0
- package/src/core/flows/ClipboardFlow.js +29 -1
- package/src/core/flows/Model3dFlow.js +15 -0
- package/src/core/index.js +4 -2
- package/src/moodboard/bootstrap/MoodBoardUiFactory.js +2 -0
- package/src/objects/Model3dScreenshotImageObject.js +8 -0
- package/src/objects/ObjectFactory.js +2 -0
- package/src/services/ai/AiClient.js +130 -2
- package/src/services/ai/Model3dSessionController.js +238 -0
- package/src/ui/ImagePropertiesPanel.js +8 -0
- package/src/ui/chat/ChatComposer.js +50 -3
- package/src/ui/chat/ChatWindow.js +452 -5
- package/src/ui/chat/Model3dBoardSkeleton.js +93 -0
- package/src/ui/chat/Model3dProgressOverlay.js +114 -0
- package/src/ui/chat/icons.js +1 -0
- package/src/ui/handles/HandlesDomRenderer.js +30 -0
- package/src/ui/styles/chat.css +173 -0
- package/src/ui/styles/panels.css +264 -0
package/package.json
CHANGED
|
@@ -70,6 +70,7 @@ export const Events = {
|
|
|
70
70
|
ZoomSelection: 'ui:zoom:selection',
|
|
71
71
|
ZoomPercent: 'ui:zoom:percent',
|
|
72
72
|
RevitShowInModel: 'ui:revit:show-in-model',
|
|
73
|
+
Model3dShowInViewer: 'ui:model3d:show-in-viewer',
|
|
73
74
|
MinimapGetData: 'ui:minimap:get-data',
|
|
74
75
|
MinimapCenterOn: 'ui:minimap:center-on',
|
|
75
76
|
TextEditStart: 'ui:text:edit:start',
|
|
@@ -286,8 +286,36 @@ export function setupClipboardFlow(core) {
|
|
|
286
286
|
}
|
|
287
287
|
});
|
|
288
288
|
|
|
289
|
-
core.eventBus.on(Events.UI.PasteImageAt, async ({ x, y, src, name, skipUpload, aiMessageId }) => {
|
|
289
|
+
core.eventBus.on(Events.UI.PasteImageAt, async ({ x, y, src, name, skipUpload, aiMessageId, objectType, modelUrl, format }) => {
|
|
290
290
|
if (!src) return;
|
|
291
|
+
|
|
292
|
+
// Отдельный путь для 3D-скрина: без resolveRevitImagePayload и без загрузки на сервер
|
|
293
|
+
if (objectType === 'model3d-screenshot-img') {
|
|
294
|
+
const world = core.pixi.worldLayer || core.pixi.app.stage;
|
|
295
|
+
const s = world?.scale?.x || 1;
|
|
296
|
+
const worldX = (x - (world?.x || 0)) / s;
|
|
297
|
+
const worldY = (y - (world?.y || 0)) / s;
|
|
298
|
+
const placeModel3d = (natW, natH) => {
|
|
299
|
+
const ar = (natW > 0 && natH > 0) ? natW / natH : 1;
|
|
300
|
+
const w = 300;
|
|
301
|
+
const h = Math.max(1, Math.round(w / ar));
|
|
302
|
+
const created = core.createObject(
|
|
303
|
+
'model3d-screenshot-img',
|
|
304
|
+
{ x: Math.round(worldX - Math.round(w / 2)), y: Math.round(worldY - Math.round(h / 2)) },
|
|
305
|
+
{ src, name: name || 'ai-3d-model', width: w, height: h, modelUrl, format }
|
|
306
|
+
);
|
|
307
|
+
if (skipUpload && created?.id) {
|
|
308
|
+
core._pendingPersistAckVisibilityIds?.delete(created.id);
|
|
309
|
+
core._setObjectVisibility?.(created.id, true);
|
|
310
|
+
}
|
|
311
|
+
};
|
|
312
|
+
const img = new Image();
|
|
313
|
+
img.onload = () => placeModel3d(img.naturalWidth, img.naturalHeight);
|
|
314
|
+
img.onerror = () => placeModel3d(0, 0);
|
|
315
|
+
img.src = src;
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
|
|
291
319
|
const uploaded = await ensureServerImage({ src, name, skipUpload });
|
|
292
320
|
if (!uploaded?.src) return;
|
|
293
321
|
const world = core.pixi.worldLayer || core.pixi.app.stage;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Events } from '../events/Events.js';
|
|
2
|
+
|
|
3
|
+
export function setupModel3dFlow(core) {
|
|
4
|
+
core.eventBus.on(Events.UI.Model3dShowInViewer, ({ objectId, modelUrl, format }) => {
|
|
5
|
+
if (!modelUrl || typeof modelUrl !== 'string') {
|
|
6
|
+
return;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
if (typeof core.options?.onShowModel3dInViewer === 'function') {
|
|
10
|
+
core.options.onShowModel3dInViewer({ objectId, modelUrl, format });
|
|
11
|
+
} else {
|
|
12
|
+
console.warn('[Model3dFlow] onShowModel3dInViewer не задан в options. objectId:', objectId, 'modelUrl:', modelUrl);
|
|
13
|
+
}
|
|
14
|
+
});
|
|
15
|
+
}
|
package/src/core/index.js
CHANGED
|
@@ -17,6 +17,7 @@ import { setupClipboardFlow, setupClipboardKeyboardFlow } from './flows/Clipboar
|
|
|
17
17
|
import { setupObjectLifecycleFlow } from './flows/ObjectLifecycleFlow.js';
|
|
18
18
|
import { setupLayerAndViewportFlow } from './flows/LayerAndViewportFlow.js';
|
|
19
19
|
import { setupRevitFlow } from './flows/RevitFlow.js';
|
|
20
|
+
import { setupModel3dFlow } from './flows/Model3dFlow.js';
|
|
20
21
|
import { setupSaveFlow } from './flows/SaveFlow.js';
|
|
21
22
|
import {
|
|
22
23
|
logMindmapCompoundDebug,
|
|
@@ -133,6 +134,7 @@ export class CoreMoodBoard {
|
|
|
133
134
|
setupTransformFlow(this);
|
|
134
135
|
setupObjectLifecycleFlow(this);
|
|
135
136
|
setupRevitFlow(this);
|
|
137
|
+
setupModel3dFlow(this);
|
|
136
138
|
}
|
|
137
139
|
|
|
138
140
|
/**
|
|
@@ -432,7 +434,7 @@ export class CoreMoodBoard {
|
|
|
432
434
|
},
|
|
433
435
|
...extraData
|
|
434
436
|
};
|
|
435
|
-
if (type === 'image' || type === 'revit-screenshot-img' || type === 'file') {
|
|
437
|
+
if (type === 'image' || type === 'revit-screenshot-img' || type === 'model3d-screenshot-img' || type === 'file') {
|
|
436
438
|
const propSrc = typeof properties?.src === 'string' ? properties.src.trim() : '';
|
|
437
439
|
const propUrl = typeof properties?.url === 'string' ? properties.url.trim() : '';
|
|
438
440
|
const normalizedSrc = propSrc || propUrl;
|
|
@@ -557,7 +559,7 @@ export class CoreMoodBoard {
|
|
|
557
559
|
properties: objectData.properties || {},
|
|
558
560
|
existingObjects: this.state?.state?.objects || [],
|
|
559
561
|
});
|
|
560
|
-
if (objectData.type === 'image' || objectData.type === 'revit-screenshot-img' || objectData.type === 'file') {
|
|
562
|
+
if (objectData.type === 'image' || objectData.type === 'revit-screenshot-img' || objectData.type === 'model3d-screenshot-img' || objectData.type === 'file') {
|
|
561
563
|
const topSrc = typeof objectData.src === 'string' ? objectData.src.trim() : '';
|
|
562
564
|
const propSrc = typeof objectData.properties?.src === 'string' ? objectData.properties.src.trim() : '';
|
|
563
565
|
const topUrl = typeof objectData.url === 'string' ? objectData.url.trim() : '';
|
|
@@ -18,6 +18,7 @@ import { TextPropertiesPanel } from '../../ui/TextPropertiesPanel.js';
|
|
|
18
18
|
import { FramePropertiesPanel } from '../../ui/FramePropertiesPanel.js';
|
|
19
19
|
import { NotePropertiesPanel } from '../../ui/NotePropertiesPanel.js';
|
|
20
20
|
import { FilePropertiesPanel } from '../../ui/FilePropertiesPanel.js';
|
|
21
|
+
import { ImagePropertiesPanel } from '../../ui/ImagePropertiesPanel.js';
|
|
21
22
|
import { ConnectorPropertiesPanel } from '../../ui/ConnectorPropertiesPanel.js';
|
|
22
23
|
import { ShapePropertiesPanel } from '../../ui/ShapePropertiesPanel.js';
|
|
23
24
|
import { DrawingPropertiesPanel } from '../../ui/DrawingPropertiesPanel.js';
|
|
@@ -151,6 +152,7 @@ function initHtmlLayersAndPanels(board) {
|
|
|
151
152
|
board.framePropertiesPanel = new FramePropertiesPanel(board.coreMoodboard.eventBus, board.canvasContainer, board.coreMoodboard);
|
|
152
153
|
board.notePropertiesPanel = new NotePropertiesPanel(board.coreMoodboard.eventBus, board.canvasContainer, board.coreMoodboard);
|
|
153
154
|
board.filePropertiesPanel = new FilePropertiesPanel(board.coreMoodboard.eventBus, board.canvasContainer, board.coreMoodboard);
|
|
155
|
+
board.imagePropertiesPanel = new ImagePropertiesPanel(board.coreMoodboard.eventBus, board.canvasContainer, board.coreMoodboard);
|
|
154
156
|
board.connectorPropertiesPanel = new ConnectorPropertiesPanel(board.coreMoodboard.eventBus, board.canvasContainer, board.coreMoodboard);
|
|
155
157
|
board.shapePropertiesPanel = new ShapePropertiesPanel(board.coreMoodboard.eventBus, board.canvasContainer, board.coreMoodboard);
|
|
156
158
|
board.drawingPropertiesPanel = new DrawingPropertiesPanel(board.coreMoodboard.eventBus, board.canvasContainer, board.coreMoodboard);
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { ImageObject } from './ImageObject.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Model3dScreenshotImageObject
|
|
5
|
+
* Специализированный тип для превью 3D-моделей.
|
|
6
|
+
* Визуально и по трансформациям полностью повторяет ImageObject.
|
|
7
|
+
*/
|
|
8
|
+
export class Model3dScreenshotImageObject extends ImageObject {}
|
|
@@ -5,6 +5,7 @@ import { TextObject } from './TextObject.js';
|
|
|
5
5
|
import { EmojiObject } from './EmojiObject.js';
|
|
6
6
|
import { ImageObject } from './ImageObject.js';
|
|
7
7
|
import { RevitScreenshotImageObject } from './RevitScreenshotImageObject.js';
|
|
8
|
+
import { Model3dScreenshotImageObject } from './Model3dScreenshotImageObject.js';
|
|
8
9
|
import { CommentObject } from './CommentObject.js';
|
|
9
10
|
import { NoteObject } from './NoteObject.js';
|
|
10
11
|
import { FileObject } from './FileObject.js';
|
|
@@ -25,6 +26,7 @@ export class ObjectFactory {
|
|
|
25
26
|
['emoji', EmojiObject],
|
|
26
27
|
['image', ImageObject],
|
|
27
28
|
['revit-screenshot-img', RevitScreenshotImageObject],
|
|
29
|
+
['model3d-screenshot-img', Model3dScreenshotImageObject],
|
|
28
30
|
['comment', CommentObject],
|
|
29
31
|
['note', NoteObject],
|
|
30
32
|
['file', FileObject],
|
|
@@ -141,6 +141,124 @@ export class AiClient {
|
|
|
141
141
|
}
|
|
142
142
|
return res.json();
|
|
143
143
|
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Отправляет джоб генерации 3D-модели.
|
|
147
|
+
* @param {object} args
|
|
148
|
+
* @param {string} [args.provider='hunyuan-3d']
|
|
149
|
+
* @param {string} [args.mode='image'] 'text'|'image'|'multi'
|
|
150
|
+
* @param {string} [args.prompt]
|
|
151
|
+
* @param {File} [args.image]
|
|
152
|
+
* @param {Array<{file: File, viewType: string}>} [args.multiViewImages]
|
|
153
|
+
* @param {string} [args.model='3.1']
|
|
154
|
+
* @param {string} [args.generateType]
|
|
155
|
+
* @param {number} [args.faceCount]
|
|
156
|
+
* @param {boolean} [args.pbr]
|
|
157
|
+
* @param {string} [args.downloadFormat]
|
|
158
|
+
* @param {AbortSignal} [args.signal]
|
|
159
|
+
* @returns {Promise<{jobId: string}>}
|
|
160
|
+
*/
|
|
161
|
+
async submit3dModel({ provider = 'hunyuan-3d', mode = 'image', prompt, image, multiViewImages, model = '3.1', generateType, faceCount, pbr, downloadFormat, signal }) {
|
|
162
|
+
const body = { mode, model, downloadFormat };
|
|
163
|
+
if (generateType !== undefined) body.generateType = generateType;
|
|
164
|
+
if (faceCount !== undefined) body.faceCount = faceCount;
|
|
165
|
+
if (pbr !== undefined) body.pbr = pbr;
|
|
166
|
+
|
|
167
|
+
if (mode === 'text') {
|
|
168
|
+
body.prompt = prompt;
|
|
169
|
+
} else if (mode === 'multi') {
|
|
170
|
+
if (Array.isArray(multiViewImages) && multiViewImages.length) {
|
|
171
|
+
body.multiViewImages = await Promise.all(
|
|
172
|
+
multiViewImages.map(async ({ file, viewType }) => {
|
|
173
|
+
const [encoded] = await filesToBase64([file]);
|
|
174
|
+
return { ...encoded, viewType };
|
|
175
|
+
})
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
} else {
|
|
179
|
+
if (image) {
|
|
180
|
+
const [encoded] = await filesToBase64([image]);
|
|
181
|
+
body.image = encoded;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const res = await this._fetch(`${this._baseUrl}/${provider}/model3d`, {
|
|
186
|
+
method: 'POST',
|
|
187
|
+
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
|
188
|
+
body: JSON.stringify(body),
|
|
189
|
+
signal
|
|
190
|
+
});
|
|
191
|
+
if (!res.ok) {
|
|
192
|
+
const detail = await safeReadError(res);
|
|
193
|
+
throw new Error(`AiClient.submit3dModel (${res.status}): ${detail}`);
|
|
194
|
+
}
|
|
195
|
+
return res.json();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Опрашивает статус джоба 3D-модели.
|
|
200
|
+
* @param {string} jobId
|
|
201
|
+
* @param {AbortSignal} [signal]
|
|
202
|
+
* @param {string} [provider='hunyuan-3d']
|
|
203
|
+
* @param {string} [format]
|
|
204
|
+
* @returns {Promise<object>}
|
|
205
|
+
*/
|
|
206
|
+
async poll3dModel(jobId, signal, provider = 'hunyuan-3d', format) {
|
|
207
|
+
const url = `${this._baseUrl}/${provider}/model3d/${jobId}${format ? `?format=${format}` : ''}`;
|
|
208
|
+
const res = await this._fetch(url, {
|
|
209
|
+
method: 'GET',
|
|
210
|
+
headers: { 'Accept': 'application/json' },
|
|
211
|
+
signal
|
|
212
|
+
});
|
|
213
|
+
if (!res.ok) {
|
|
214
|
+
const detail = await safeReadError(res);
|
|
215
|
+
throw new Error(`AiClient.poll3dModel (${res.status}): ${detail}`);
|
|
216
|
+
}
|
|
217
|
+
return res.json();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Отправляет джоб конвертации GLB -> FBX/STL.
|
|
222
|
+
* @param {object} args
|
|
223
|
+
* @param {string} args.glbUrl
|
|
224
|
+
* @param {string} args.format 'fbx'|'stl'
|
|
225
|
+
* @param {AbortSignal} [args.signal]
|
|
226
|
+
* @returns {Promise<{jobId: string}>}
|
|
227
|
+
*/
|
|
228
|
+
async submitConvert3d({ glbUrl, format, signal }) {
|
|
229
|
+
const res = await this._fetch(`${this._baseUrl}/convert3d`, {
|
|
230
|
+
method: 'POST',
|
|
231
|
+
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
|
232
|
+
body: JSON.stringify({ glbUrl, format }),
|
|
233
|
+
signal
|
|
234
|
+
});
|
|
235
|
+
if (!res.ok) {
|
|
236
|
+
const detail = await safeReadError(res);
|
|
237
|
+
throw new Error(`AiClient.submitConvert3d (${res.status}): ${detail}`);
|
|
238
|
+
}
|
|
239
|
+
return res.json();
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Опрашивает статус джоба конвертации.
|
|
244
|
+
* @param {string} jobId
|
|
245
|
+
* @param {AbortSignal} [signal]
|
|
246
|
+
* @param {string} [format]
|
|
247
|
+
* @returns {Promise<object>}
|
|
248
|
+
*/
|
|
249
|
+
async pollConvert3d(jobId, signal, format) {
|
|
250
|
+
const url = `${this._baseUrl}/convert3d/${jobId}${format ? `?format=${format}` : ''}`;
|
|
251
|
+
const res = await this._fetch(url, {
|
|
252
|
+
method: 'GET',
|
|
253
|
+
headers: { 'Accept': 'application/json' },
|
|
254
|
+
signal
|
|
255
|
+
});
|
|
256
|
+
if (!res.ok) {
|
|
257
|
+
const detail = await safeReadError(res);
|
|
258
|
+
throw new Error(`AiClient.pollConvert3d (${res.status}): ${detail}`);
|
|
259
|
+
}
|
|
260
|
+
return res.json();
|
|
261
|
+
}
|
|
144
262
|
}
|
|
145
263
|
|
|
146
264
|
/**
|
|
@@ -221,12 +339,22 @@ function safeJson(text) {
|
|
|
221
339
|
}
|
|
222
340
|
|
|
223
341
|
async function safeReadError(res) {
|
|
342
|
+
const fallback = res.statusText || `HTTP ${res.status}`;
|
|
224
343
|
try {
|
|
225
344
|
const text = await res.text();
|
|
226
345
|
const json = safeJson(text);
|
|
227
|
-
|
|
346
|
+
if (json?.error) {
|
|
347
|
+
return typeof json.error === 'string' ? json.error : fallback;
|
|
348
|
+
}
|
|
349
|
+
// Не отдаём в UI сырой HTML/стектрейс (Laravel debug-страница) или
|
|
350
|
+
// слишком длинное тело — показываем лаконичный статус вместо «простыни».
|
|
351
|
+
const trimmed = (text || '').trim();
|
|
352
|
+
if (!trimmed || trimmed.startsWith('<') || trimmed.length > 200) {
|
|
353
|
+
return fallback;
|
|
354
|
+
}
|
|
355
|
+
return trimmed;
|
|
228
356
|
} catch {
|
|
229
|
-
return
|
|
357
|
+
return fallback;
|
|
230
358
|
}
|
|
231
359
|
}
|
|
232
360
|
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Контроллер сессии генерации 3D-модели (Hunyuan 3D).
|
|
3
|
+
*
|
|
4
|
+
* Одна ответственность: оркестрирует submit -> poll-цикл через AiClient,
|
|
5
|
+
* держит состояние джоба и уведомляет подписчиков об изменениях.
|
|
6
|
+
* Не знает про DOM, доску и UI.
|
|
7
|
+
*
|
|
8
|
+
* Состояние:
|
|
9
|
+
* - status: 'idle' | 'submitting' | 'polling' | 'done' | 'error'
|
|
10
|
+
* - progress: number (0–100)
|
|
11
|
+
* - stage: 'geometry' | 'texture' | 'convert' | null
|
|
12
|
+
* - error: string | null
|
|
13
|
+
* - jobId: string | null
|
|
14
|
+
* - result: { previewBase64, mimeType, modelUrl, format } | null
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const POLL_INTERVAL_MS = 2500;
|
|
18
|
+
const JOB_TIMEOUT_MS = 600_000; // 600с: покрывает оба джоба (генерация + конвертация)
|
|
19
|
+
|
|
20
|
+
export class Model3dSessionController {
|
|
21
|
+
/**
|
|
22
|
+
* @param {object} deps
|
|
23
|
+
* @param {import('./AiClient.js').AiClient} deps.aiClient
|
|
24
|
+
*/
|
|
25
|
+
constructor({ aiClient }) {
|
|
26
|
+
this._client = aiClient;
|
|
27
|
+
this._listeners = new Set();
|
|
28
|
+
this._abort = null;
|
|
29
|
+
|
|
30
|
+
this._state = {
|
|
31
|
+
status: 'idle',
|
|
32
|
+
progress: 0,
|
|
33
|
+
stage: null,
|
|
34
|
+
error: null,
|
|
35
|
+
jobId: null,
|
|
36
|
+
result: null
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
getState() {
|
|
41
|
+
return this._state;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @param {function} listener
|
|
46
|
+
* @returns {function} unsubscribe
|
|
47
|
+
*/
|
|
48
|
+
subscribe(listener) {
|
|
49
|
+
this._listeners.add(listener);
|
|
50
|
+
return () => this._listeners.delete(listener);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Запускает двухфазный джоб: генерация -> (опционально) конвертация.
|
|
55
|
+
* @param {object} args
|
|
56
|
+
* @param {string} [args.mode='image'] 'text'|'image'|'multi'
|
|
57
|
+
* @param {string} [args.prompt]
|
|
58
|
+
* @param {File} [args.image]
|
|
59
|
+
* @param {Array<{file: File, viewType: string}>} [args.multiViewImages]
|
|
60
|
+
* @param {string} [args.model='3.1']
|
|
61
|
+
* @param {string} [args.generateType]
|
|
62
|
+
* @param {number} [args.faceCount]
|
|
63
|
+
* @param {boolean} [args.pbr]
|
|
64
|
+
* @param {string} [args.downloadFormat='glb']
|
|
65
|
+
* @param {AbortSignal} [args.signal]
|
|
66
|
+
* @returns {Promise<void>}
|
|
67
|
+
*/
|
|
68
|
+
async start({ mode = 'image', prompt, image, multiViewImages, model = '3.1', generateType, faceCount, pbr, downloadFormat = 'glb', signal: externalSignal } = {}) {
|
|
69
|
+
if (this._abort) this.abort();
|
|
70
|
+
|
|
71
|
+
const abort = new AbortController();
|
|
72
|
+
this._abort = abort;
|
|
73
|
+
const { signal } = abort;
|
|
74
|
+
|
|
75
|
+
if (externalSignal) {
|
|
76
|
+
if (externalSignal.aborted) { abort.abort(); return; }
|
|
77
|
+
externalSignal.addEventListener('abort', () => abort.abort(), { once: true });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
this._setState({ status: 'submitting', progress: 0, stage: null, error: null, jobId: null, result: null });
|
|
81
|
+
|
|
82
|
+
const timeoutId = setTimeout(() => {
|
|
83
|
+
if (this._abort === abort) {
|
|
84
|
+
abort.abort();
|
|
85
|
+
this._setState({ status: 'error', error: 'Таймаут: джоб превысил 600 секунд' });
|
|
86
|
+
}
|
|
87
|
+
}, JOB_TIMEOUT_MS);
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
const { jobId } = await this._client.submit3dModel({
|
|
91
|
+
mode, prompt, image, multiViewImages, model, generateType, faceCount, pbr, downloadFormat, signal
|
|
92
|
+
});
|
|
93
|
+
if (signal.aborted) return;
|
|
94
|
+
|
|
95
|
+
this._setState({ status: 'polling', jobId });
|
|
96
|
+
|
|
97
|
+
const genResult = await this._pollLoop(jobId, signal, downloadFormat);
|
|
98
|
+
if (!genResult || signal.aborted) return;
|
|
99
|
+
|
|
100
|
+
if (!genResult.needsConvert) {
|
|
101
|
+
const { previewBase64, mimeType, modelUrl, format } = genResult;
|
|
102
|
+
this._setState({ status: 'done', progress: 100, stage: null, result: { previewBase64, mimeType, modelUrl, format } });
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Фаза 2: конвертация GLB -> FBX/STL
|
|
107
|
+
const { previewBase64: genPreview, mimeType: genMime, sourceGlbUrl } = genResult;
|
|
108
|
+
this._setState({ stage: 'convert', progress: 0 });
|
|
109
|
+
|
|
110
|
+
const { jobId: convertJobId } = await this._client.submitConvert3d({
|
|
111
|
+
glbUrl: sourceGlbUrl, format: downloadFormat, signal
|
|
112
|
+
});
|
|
113
|
+
if (signal.aborted) return;
|
|
114
|
+
|
|
115
|
+
this._setState({ jobId: convertJobId });
|
|
116
|
+
|
|
117
|
+
const convResult = await this._pollConvertLoop(convertJobId, signal, downloadFormat);
|
|
118
|
+
if (!convResult || signal.aborted) return;
|
|
119
|
+
|
|
120
|
+
this._setState({
|
|
121
|
+
status: 'done',
|
|
122
|
+
progress: 100,
|
|
123
|
+
stage: null,
|
|
124
|
+
result: { previewBase64: genPreview, mimeType: genMime, modelUrl: convResult.modelUrl, format: convResult.format }
|
|
125
|
+
});
|
|
126
|
+
} catch (err) {
|
|
127
|
+
if (err?.name === 'AbortError' || signal.aborted) return;
|
|
128
|
+
this._setState({ status: 'error', error: err?.message || 'Ошибка запроса' });
|
|
129
|
+
} finally {
|
|
130
|
+
clearTimeout(timeoutId);
|
|
131
|
+
if (this._abort === abort) this._abort = null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Прерывает submit или поллинг. */
|
|
136
|
+
abort() {
|
|
137
|
+
if (this._abort) {
|
|
138
|
+
try { this._abort.abort(); } catch { /* noop */ }
|
|
139
|
+
this._abort = null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async _pollLoop(jobId, signal, format) {
|
|
144
|
+
while (!signal.aborted) {
|
|
145
|
+
await sleep(POLL_INTERVAL_MS, signal);
|
|
146
|
+
if (signal.aborted) return null;
|
|
147
|
+
|
|
148
|
+
let data;
|
|
149
|
+
try {
|
|
150
|
+
data = await this._client.poll3dModel(jobId, signal, undefined, format);
|
|
151
|
+
} catch (err) {
|
|
152
|
+
if (err?.name === 'AbortError' || signal.aborted) return null;
|
|
153
|
+
this._setState({ status: 'error', error: err?.message || 'Ошибка поллинга' });
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const { status, progress, stage, error } = data;
|
|
158
|
+
|
|
159
|
+
if (status === 'done') {
|
|
160
|
+
return data;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (status === 'error') {
|
|
164
|
+
this._setState({ status: 'error', error: error || 'Ошибка джоба' });
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// 'pending' | 'running' — обновляем прогресс
|
|
169
|
+
this._setState({
|
|
170
|
+
progress: progress ?? this._state.progress,
|
|
171
|
+
stage: stage ?? this._state.stage
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async _pollConvertLoop(jobId, signal, format) {
|
|
178
|
+
while (!signal.aborted) {
|
|
179
|
+
await sleep(POLL_INTERVAL_MS, signal);
|
|
180
|
+
if (signal.aborted) return null;
|
|
181
|
+
|
|
182
|
+
let data;
|
|
183
|
+
try {
|
|
184
|
+
data = await this._client.pollConvert3d(jobId, signal, format);
|
|
185
|
+
} catch (err) {
|
|
186
|
+
if (err?.name === 'AbortError' || signal.aborted) return null;
|
|
187
|
+
this._setState({ status: 'error', error: err?.message || 'Ошибка конвертации' });
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const { status, progress, error } = data;
|
|
192
|
+
|
|
193
|
+
if (status === 'done') {
|
|
194
|
+
return data;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (status === 'error') {
|
|
198
|
+
this._setState({ status: 'error', error: error || 'Ошибка конвертации' });
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
this._setState({ progress: progress ?? this._state.progress });
|
|
203
|
+
}
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
_setState(patch) {
|
|
208
|
+
this._state = { ...this._state, ...patch };
|
|
209
|
+
this._emit();
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
_emit() {
|
|
213
|
+
for (const listener of this._listeners) {
|
|
214
|
+
try { listener(this._state); } catch (err) {
|
|
215
|
+
console.error('[Model3dSession] listener error:', err);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Promise, который разрешается через ms миллисекунд или отклоняется по AbortSignal.
|
|
223
|
+
* @param {number} ms
|
|
224
|
+
* @param {AbortSignal} [signal]
|
|
225
|
+
*/
|
|
226
|
+
function sleep(ms, signal) {
|
|
227
|
+
return new Promise((resolve, reject) => {
|
|
228
|
+
if (signal?.aborted) {
|
|
229
|
+
reject(new DOMException('Aborted', 'AbortError'));
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
const id = setTimeout(resolve, ms);
|
|
233
|
+
signal?.addEventListener('abort', () => {
|
|
234
|
+
clearTimeout(id);
|
|
235
|
+
reject(new DOMException('Aborted', 'AbortError'));
|
|
236
|
+
}, { once: true });
|
|
237
|
+
});
|
|
238
|
+
}
|
|
@@ -32,6 +32,14 @@ export class ChatComposer {
|
|
|
32
32
|
* @type {{ file: File, sourceObjectId: string|null }[]}
|
|
33
33
|
*/
|
|
34
34
|
this._attachments = [];
|
|
35
|
+
this._attachmentLabelProvider = null;
|
|
36
|
+
/**
|
|
37
|
+
* Жёстко зафиксированный placeholder. Когда задан, логика вложений
|
|
38
|
+
* (`_renderAttachmentsPreview`) не перетирает текст. Нужно для 3D-режима
|
|
39
|
+
* «Изображение», где описание не требуется.
|
|
40
|
+
* @type {string|null}
|
|
41
|
+
*/
|
|
42
|
+
this._placeholderOverride = null;
|
|
35
43
|
}
|
|
36
44
|
|
|
37
45
|
attach() {
|
|
@@ -135,6 +143,39 @@ export class ChatComposer {
|
|
|
135
143
|
this._attachments = [];
|
|
136
144
|
}
|
|
137
145
|
|
|
146
|
+
/**
|
|
147
|
+
* Включает/выключает поле ввода текста.
|
|
148
|
+
* В 3D-режимах image/multi textarea должна быть заблокирована.
|
|
149
|
+
* @param {boolean} enabled
|
|
150
|
+
*/
|
|
151
|
+
setInputEnabled(enabled) {
|
|
152
|
+
this._textarea.disabled = !enabled;
|
|
153
|
+
this._textarea.style.opacity = enabled ? '' : '0.4';
|
|
154
|
+
this._textarea.style.cursor = enabled ? '' : 'not-allowed';
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Фиксирует placeholder, который не перетирается логикой вложений.
|
|
159
|
+
* `null` — вернуть обычное поведение (текст зависит от наличия вложений).
|
|
160
|
+
* @param {string|null} text
|
|
161
|
+
*/
|
|
162
|
+
setPlaceholderOverride(text) {
|
|
163
|
+
this._placeholderOverride = text ?? null;
|
|
164
|
+
if (this._placeholderOverride != null) {
|
|
165
|
+
this._textarea.placeholder = this._placeholderOverride;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Устанавливает провайдер пользовательских лейблов для превью вложений.
|
|
171
|
+
* Используется в мультивью: i===0 → 'Фронт', i>0 → ракурс по порядку.
|
|
172
|
+
* @param {((index: number) => string) | null} fn
|
|
173
|
+
*/
|
|
174
|
+
setAttachmentLabelProvider(fn) {
|
|
175
|
+
this._attachmentLabelProvider = fn ?? null;
|
|
176
|
+
this._renderAttachmentsPreview();
|
|
177
|
+
}
|
|
178
|
+
|
|
138
179
|
_submit() {
|
|
139
180
|
const text = this._textarea.value;
|
|
140
181
|
const trimmed = text.trim();
|
|
@@ -180,13 +221,17 @@ export class ChatComposer {
|
|
|
180
221
|
if (this._attachments.length === 0) {
|
|
181
222
|
container.classList.remove('is-visible');
|
|
182
223
|
inputRow?.classList.remove('has-attachments');
|
|
183
|
-
this.
|
|
224
|
+
if (this._placeholderOverride == null) {
|
|
225
|
+
this._textarea.placeholder = 'Опишите то, что хотите сгенерировать';
|
|
226
|
+
}
|
|
184
227
|
return;
|
|
185
228
|
}
|
|
186
229
|
|
|
187
230
|
container.classList.add('is-visible');
|
|
188
231
|
inputRow?.classList.add('has-attachments');
|
|
189
|
-
this.
|
|
232
|
+
if (this._placeholderOverride == null) {
|
|
233
|
+
this._textarea.placeholder = 'Опишите правку, изменение или стилевое направление эталонного изображения';
|
|
234
|
+
}
|
|
190
235
|
for (let i = 0; i < this._attachments.length; i++) {
|
|
191
236
|
const entry = this._attachments[i];
|
|
192
237
|
const item = this._buildAttachmentItem(entry.file, i);
|
|
@@ -219,7 +264,9 @@ export class ChatComposer {
|
|
|
219
264
|
|
|
220
265
|
const badge = document.createElement('div');
|
|
221
266
|
badge.className = 'moodboard-chat__attachment-badge';
|
|
222
|
-
badge.textContent =
|
|
267
|
+
badge.textContent = this._attachmentLabelProvider
|
|
268
|
+
? (this._attachmentLabelProvider(index) ?? String(index + 1))
|
|
269
|
+
: String(index + 1);
|
|
223
270
|
item.appendChild(badge);
|
|
224
271
|
|
|
225
272
|
const remove = document.createElement('button');
|