@sequent-org/moodboard 1.4.25 → 1.4.27

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.
Files changed (33) hide show
  1. package/package.json +2 -1
  2. package/src/assets/fonts/geist/geist-sans-latin-100-normal.ttf +0 -0
  3. package/src/assets/fonts/geist/geist-sans-latin-200-normal.ttf +0 -0
  4. package/src/assets/fonts/geist/geist-sans-latin-300-normal.ttf +0 -0
  5. package/src/assets/fonts/geist/geist-sans-latin-400-normal.ttf +0 -0
  6. package/src/assets/fonts/geist/geist-sans-latin-500-normal.ttf +0 -0
  7. package/src/assets/fonts/geist/geist-sans-latin-600-normal.ttf +0 -0
  8. package/src/assets/fonts/geist/geist-sans-latin-700-normal.ttf +0 -0
  9. package/src/assets/fonts/geist/geist-sans-latin-800-normal.ttf +0 -0
  10. package/src/assets/fonts/geist/geist-sans-latin-900-normal.ttf +0 -0
  11. package/src/core/SaveManager.js +6 -0
  12. package/src/core/flows/ClipboardFlow.js +16 -6
  13. package/src/moodboard/bootstrap/MoodBoardUiFactory.js +10 -0
  14. package/src/moodboard/lifecycle/MoodBoardDestroyer.js +3 -0
  15. package/src/services/ai/AiClient.js +220 -0
  16. package/src/services/ai/ChatHistoryStore.js +55 -0
  17. package/src/services/ai/ChatPresets.js +40 -0
  18. package/src/services/ai/ChatSessionController.js +220 -0
  19. package/src/tools/object-tools/selection/TextEditorInteractionController.js +10 -0
  20. package/src/ui/TextPropertiesPanel.js +7 -1
  21. package/src/ui/chat/ChatComposer.js +198 -0
  22. package/src/ui/chat/ChatExtendedPromptModal.js +131 -0
  23. package/src/ui/chat/ChatMessageList.js +92 -0
  24. package/src/ui/chat/ChatPillMenu.js +141 -0
  25. package/src/ui/chat/ChatSettingsPopup.js +171 -0
  26. package/src/ui/chat/ChatWindow.js +407 -0
  27. package/src/ui/chat/ChatWindowRenderer.js +214 -0
  28. package/src/ui/chat/icons.js +113 -0
  29. package/src/ui/styles/chat.css +920 -0
  30. package/src/ui/styles/index.css +1 -0
  31. package/src/ui/styles/workspace.css +73 -1
  32. package/src/ui/text-properties/TextPropertiesPanelState.js +2 -0
  33. package/src/utils/styleLoader.js +2 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sequent-org/moodboard",
3
- "version": "1.4.25",
3
+ "version": "1.4.27",
4
4
  "type": "module",
5
5
  "description": "Interactive moodboard",
6
6
  "main": "./src/index.js",
@@ -41,6 +41,7 @@
41
41
  },
42
42
  "dependencies": {
43
43
  "axios": "^1.0.0",
44
+ "lucide-static": "^1.16.0",
44
45
  "pixi.js": "^7.0.0"
45
46
  },
46
47
  "devDependencies": {
@@ -150,6 +150,12 @@ export class SaveManager {
150
150
  }
151
151
 
152
152
  } catch (error) {
153
+ // Transient data:/blob: URL в объектах — retry бессмысленен, ошибку пользователю не показываем.
154
+ // Такое возникает когда AI-изображение размещено без upload-сервера (dev-режим).
155
+ if (error?.message?.includes('forbidden data/blob src')) {
156
+ console.warn('SaveManager: объект с data:/blob: URL пропущен при сохранении:', error.message);
157
+ return;
158
+ }
153
159
  console.error('Ошибка автосохранения:', error);
154
160
  this.handleSaveError(error, data);
155
161
  } finally {
@@ -19,11 +19,14 @@ export function setupClipboardFlow(core) {
19
19
  };
20
20
  };
21
21
 
22
- const ensureServerImage = async ({ src, name }) => {
22
+ const ensureServerImage = async ({ src, name, skipUpload }) => {
23
23
  const srcValue = typeof src === 'string' ? src.trim() : '';
24
24
  if (srcValue && !/^data:image\//i.test(srcValue) && !/^blob:/i.test(srcValue)) {
25
25
  return { src: srcValue, name };
26
26
  }
27
+ if (skipUpload && srcValue) {
28
+ return { src: srcValue, name };
29
+ }
27
30
  if (!core.imageUploadService) {
28
31
  alert('Сервис загрузки изображений недоступен. Изображение не добавлено.');
29
32
  return null;
@@ -222,9 +225,9 @@ export function setupClipboardFlow(core) {
222
225
  core._cursor.y = y;
223
226
  });
224
227
 
225
- core.eventBus.on(Events.UI.PasteImage, async ({ src, name }) => {
228
+ core.eventBus.on(Events.UI.PasteImage, async ({ src, name, skipUpload }) => {
226
229
  if (!src) return;
227
- const uploaded = await ensureServerImage({ src, name });
230
+ const uploaded = await ensureServerImage({ src, name, skipUpload });
228
231
  if (!uploaded?.src) return;
229
232
  const view = core.pixi.app.view;
230
233
  const world = core.pixi.worldLayer || core.pixi.app.stage;
@@ -283,9 +286,9 @@ export function setupClipboardFlow(core) {
283
286
  }
284
287
  });
285
288
 
286
- core.eventBus.on(Events.UI.PasteImageAt, async ({ x, y, src, name }) => {
289
+ core.eventBus.on(Events.UI.PasteImageAt, async ({ x, y, src, name, skipUpload }) => {
287
290
  if (!src) return;
288
- const uploaded = await ensureServerImage({ src, name });
291
+ const uploaded = await ensureServerImage({ src, name, skipUpload });
289
292
  if (!uploaded?.src) return;
290
293
  const world = core.pixi.worldLayer || core.pixi.app.stage;
291
294
  const s = world?.scale?.x || 1;
@@ -311,11 +314,18 @@ export function setupClipboardFlow(core) {
311
314
  height: h,
312
315
  ...revitPayload.properties
313
316
  };
314
- core.createObject(
317
+ const createdData = core.createObject(
315
318
  revitPayload.type,
316
319
  { x: Math.round(worldX - Math.round(w / 2)), y: Math.round(worldY - Math.round(h / 2)) },
317
320
  properties
318
321
  );
322
+ // data:-URL изображения (AI-генерация) не проходят через SaveManager,
323
+ // поэтому Events.Save.Success никогда не придёт и объект останется скрытым.
324
+ // Раскрываем сразу, минуя ожидание подтверждения сохранения.
325
+ if (skipUpload && createdData?.id) {
326
+ core._pendingPersistAckVisibilityIds?.delete(createdData.id);
327
+ core._setObjectVisibility?.(createdData.id, true);
328
+ }
319
329
  };
320
330
 
321
331
  try {
@@ -14,6 +14,7 @@ import { TextPropertiesPanel } from '../../ui/TextPropertiesPanel.js';
14
14
  import { FramePropertiesPanel } from '../../ui/FramePropertiesPanel.js';
15
15
  import { NotePropertiesPanel } from '../../ui/NotePropertiesPanel.js';
16
16
  import { FilePropertiesPanel } from '../../ui/FilePropertiesPanel.js';
17
+ import { ChatWindow } from '../../ui/chat/ChatWindow.js';
17
18
  import { bindToolbarEvents, bindTopbarEvents } from '../integration/MoodBoardEventBindings.js';
18
19
 
19
20
  function initToolbar(board) {
@@ -86,6 +87,14 @@ function initContextMenu(board) {
86
87
  );
87
88
  }
88
89
 
90
+ function initChatWindow(board) {
91
+ if (board?.options?.disableChat === true) return;
92
+ board.chatWindow = new ChatWindow(board.workspaceElement, {
93
+ boardCore: board.coreMoodboard
94
+ });
95
+ board.chatWindow.attach();
96
+ }
97
+
89
98
  function initHtmlLayersAndPanels(board) {
90
99
  board.htmlTextLayer = new HtmlTextLayer(board.canvasContainer, board.coreMoodboard.eventBus, board.coreMoodboard);
91
100
  board.htmlTextLayer.attach();
@@ -127,4 +136,5 @@ export function createMoodBoardUi(board) {
127
136
  }
128
137
  initContextMenu(board);
129
138
  initHtmlLayersAndPanels(board);
139
+ initChatWindow(board);
130
140
  }
@@ -73,6 +73,9 @@ export function destroyMoodBoard(board) {
73
73
  safeDestroy(board.dotGridDebugPanel, 'dotGridDebugPanel');
74
74
  board.dotGridDebugPanel = null;
75
75
 
76
+ safeDestroy(board.chatWindow, 'chatWindow');
77
+ board.chatWindow = null;
78
+
76
79
  safeDestroy(board.coreMoodboard, 'coreMoodboard');
77
80
  board.coreMoodboard = null;
78
81
 
@@ -0,0 +1,220 @@
1
+ /**
2
+ * Тонкий HTTP-клиент к /api/ai.
3
+ *
4
+ * Одна ответственность: общение с прокси-сервером (server/).
5
+ * Не знает ни про UI, ни про localStorage. Возвращает обычные данные
6
+ * и async generator для стриминга.
7
+ */
8
+
9
+ const DEFAULT_BASE_URL = '/api/ai';
10
+
11
+ export class AiClient {
12
+ /**
13
+ * @param {object} options
14
+ * @param {string} [options.baseUrl='/api/ai']
15
+ * @param {typeof fetch} [options.fetchImpl]
16
+ */
17
+ constructor(options = {}) {
18
+ this._baseUrl = options.baseUrl || DEFAULT_BASE_URL;
19
+ this._fetch = options.fetchImpl || (typeof fetch !== 'undefined' ? fetch.bind(globalThis) : null);
20
+ if (!this._fetch) {
21
+ throw new Error('AiClient: fetch is not available in this environment');
22
+ }
23
+ }
24
+
25
+ /**
26
+ * Список доступных провайдеров.
27
+ * @returns {Promise<Array<{id: string, label: string, enabled: boolean}>>}
28
+ */
29
+ async listProviders() {
30
+ const res = await this._fetch(`${this._baseUrl}/providers`, {
31
+ method: 'GET',
32
+ headers: { 'Accept': 'application/json' }
33
+ });
34
+ if (!res.ok) {
35
+ throw new Error(`AiClient.listProviders: ${res.status}`);
36
+ }
37
+ const json = await res.json();
38
+ return Array.isArray(json?.providers) ? json.providers : [];
39
+ }
40
+
41
+ /**
42
+ * Не-стриминговый чат.
43
+ * @param {object} args
44
+ * @param {string} args.provider
45
+ * @param {Array<{role: string, content: string}>} args.messages
46
+ * @param {string} [args.system]
47
+ * @param {number} [args.temperature]
48
+ * @param {number} [args.maxTokens]
49
+ * @param {string} [args.model]
50
+ * @param {AbortSignal} [args.signal]
51
+ * @returns {Promise<{text: string}>}
52
+ */
53
+ async chat({ provider, signal, ...payload }) {
54
+ const res = await this._fetch(`${this._baseUrl}/${provider}/chat`, {
55
+ method: 'POST',
56
+ headers: {
57
+ 'Content-Type': 'application/json',
58
+ 'Accept': 'application/json'
59
+ },
60
+ body: JSON.stringify({ ...payload, stream: false }),
61
+ signal
62
+ });
63
+ if (!res.ok) {
64
+ const detail = await safeReadError(res);
65
+ throw new Error(`AiClient.chat (${res.status}): ${detail}`);
66
+ }
67
+ return res.json();
68
+ }
69
+
70
+ /**
71
+ * Стриминговый чат. Возвращает объект с async iterable для дельт.
72
+ * Отмена — через переданный AbortSignal.
73
+ *
74
+ * @param {object} args
75
+ * @param {string} args.provider
76
+ * @param {Array<{role: string, content: string}>} args.messages
77
+ * @param {string} [args.system]
78
+ * @param {number} [args.temperature]
79
+ * @param {number} [args.maxTokens]
80
+ * @param {string} [args.model]
81
+ * @param {AbortSignal} [args.signal]
82
+ * @returns {Promise<{ deltas: AsyncGenerator<string> }>}
83
+ */
84
+ async chatStream({ provider, signal, ...payload }) {
85
+ const res = await this._fetch(`${this._baseUrl}/${provider}/chat`, {
86
+ method: 'POST',
87
+ headers: {
88
+ 'Content-Type': 'application/json',
89
+ 'Accept': 'text/event-stream'
90
+ },
91
+ body: JSON.stringify({ ...payload, stream: true }),
92
+ signal
93
+ });
94
+ if (!res.ok) {
95
+ const detail = await safeReadError(res);
96
+ throw new Error(`AiClient.chatStream (${res.status}): ${detail}`);
97
+ }
98
+ if (!res.body) {
99
+ throw new Error('AiClient.chatStream: empty response body');
100
+ }
101
+ return { deltas: parseClientSse(res.body, signal) };
102
+ }
103
+
104
+ /**
105
+ * Генерация изображения через YandexART.
106
+ * @param {object} args
107
+ * @param {string} args.prompt
108
+ * @param {string} [args.negativePrompt]
109
+ * @param {number} [args.widthRatio]
110
+ * @param {number} [args.heightRatio]
111
+ * @param {number} [args.seed]
112
+ * @param {string} [args.mimeType]
113
+ * @param {string} [args.model]
114
+ * @param {AbortSignal} [args.signal]
115
+ * @returns {Promise<{operationId: string, imageBase64: string, mimeType: string}>}
116
+ */
117
+ async generateImage({ signal, ...payload }) {
118
+ const res = await this._fetch(`${this._baseUrl}/yandex-art/image`, {
119
+ method: 'POST',
120
+ headers: {
121
+ 'Content-Type': 'application/json',
122
+ 'Accept': 'application/json'
123
+ },
124
+ body: JSON.stringify(payload),
125
+ signal
126
+ });
127
+ if (!res.ok) {
128
+ const detail = await safeReadError(res);
129
+ throw new Error(`AiClient.generateImage (${res.status}): ${detail}`);
130
+ }
131
+ return res.json();
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Минимальный парсер SSE на клиенте.
137
+ * Контракт сервера (см. server/src/utils/sseWriter.js):
138
+ * data: {"delta":"..."}
139
+ * data: [DONE]
140
+ * event: error
141
+ * data: {"error":"..."}
142
+ *
143
+ * @param {ReadableStream<Uint8Array>} stream
144
+ * @param {AbortSignal} [signal]
145
+ */
146
+ async function* parseClientSse(stream, signal) {
147
+ const reader = stream.getReader();
148
+ const decoder = new TextDecoder('utf-8');
149
+ let buffer = '';
150
+
151
+ const onAbort = () => {
152
+ try { reader.cancel(); } catch (_) { /* noop */ }
153
+ };
154
+ if (signal) {
155
+ if (signal.aborted) onAbort();
156
+ else signal.addEventListener('abort', onAbort, { once: true });
157
+ }
158
+
159
+ try {
160
+ while (true) {
161
+ const { value, done } = await reader.read();
162
+ if (done) break;
163
+ buffer += decoder.decode(value, { stream: true });
164
+
165
+ let idx;
166
+ while ((idx = buffer.indexOf('\n\n')) !== -1) {
167
+ const rawEvent = buffer.slice(0, idx);
168
+ buffer = buffer.slice(idx + 2);
169
+
170
+ const parsed = parseSseEvent(rawEvent);
171
+ if (!parsed) continue;
172
+
173
+ if (parsed.event === 'error') {
174
+ const err = safeJson(parsed.data);
175
+ throw new Error(err?.error || 'AI stream error');
176
+ }
177
+
178
+ if (parsed.data === '[DONE]') return;
179
+
180
+ const json = safeJson(parsed.data);
181
+ if (json && typeof json.delta === 'string' && json.delta.length > 0) {
182
+ yield json.delta;
183
+ }
184
+ }
185
+ }
186
+ } finally {
187
+ if (signal) signal.removeEventListener('abort', onAbort);
188
+ try { reader.releaseLock(); } catch (_) { /* noop */ }
189
+ }
190
+ }
191
+
192
+ function parseSseEvent(raw) {
193
+ const lines = raw.split(/\r?\n/);
194
+ let event = 'message';
195
+ const dataParts = [];
196
+ for (const line of lines) {
197
+ if (!line || line.startsWith(':')) continue;
198
+ if (line.startsWith('event:')) {
199
+ event = line.slice(6).trim();
200
+ } else if (line.startsWith('data:')) {
201
+ dataParts.push(line.slice(5).trimStart());
202
+ }
203
+ }
204
+ if (dataParts.length === 0) return null;
205
+ return { event, data: dataParts.join('\n') };
206
+ }
207
+
208
+ function safeJson(text) {
209
+ try { return JSON.parse(text); } catch { return null; }
210
+ }
211
+
212
+ async function safeReadError(res) {
213
+ try {
214
+ const text = await res.text();
215
+ const json = safeJson(text);
216
+ return json?.error ? json.error : (text || res.statusText);
217
+ } catch {
218
+ return res.statusText;
219
+ }
220
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Хранилище истории чата в localStorage.
3
+ *
4
+ * Одна ответственность: CRUD сообщений в localStorage по ключу.
5
+ * Не зависит ни от UI, ни от транспорта — легко тестируется.
6
+ *
7
+ * Формат сообщения:
8
+ * { id, role: 'user'|'assistant'|'system', content, ts, provider? }
9
+ */
10
+
11
+ const DEFAULT_KEY = 'moodboard.ai.chat.history.v1';
12
+ const MAX_MESSAGES = 200;
13
+
14
+ export class ChatHistoryStore {
15
+ /**
16
+ * @param {object} options
17
+ * @param {Storage} [options.storage] - localStorage по умолчанию
18
+ * @param {string} [options.key]
19
+ * @param {number} [options.maxMessages]
20
+ */
21
+ constructor(options = {}) {
22
+ this._storage = options.storage || (typeof localStorage !== 'undefined' ? localStorage : null);
23
+ this._key = options.key || DEFAULT_KEY;
24
+ this._max = options.maxMessages || MAX_MESSAGES;
25
+ }
26
+
27
+ load() {
28
+ if (!this._storage) return [];
29
+ try {
30
+ const raw = this._storage.getItem(this._key);
31
+ if (!raw) return [];
32
+ const parsed = JSON.parse(raw);
33
+ return Array.isArray(parsed) ? parsed : [];
34
+ } catch {
35
+ return [];
36
+ }
37
+ }
38
+
39
+ save(messages) {
40
+ if (!this._storage) return;
41
+ const trimmed = Array.isArray(messages)
42
+ ? messages.slice(-this._max)
43
+ : [];
44
+ try {
45
+ this._storage.setItem(this._key, JSON.stringify(trimmed));
46
+ } catch {
47
+ /* квоты/недоступность — игнорируем */
48
+ }
49
+ }
50
+
51
+ clear() {
52
+ if (!this._storage) return;
53
+ try { this._storage.removeItem(this._key); } catch { /* noop */ }
54
+ }
55
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Пресеты системного промпта для чата.
3
+ *
4
+ * Используются кнопками "Manual" / "Style" в композере.
5
+ * Список можно расширять — основное правило: id уникален и стабилен,
6
+ * label короткий (помещается в пилл).
7
+ */
8
+
9
+ export const CHAT_PRESETS = [
10
+ {
11
+ id: 'default',
12
+ label: 'Default',
13
+ kind: 'manual',
14
+ systemPrompt: 'Ты — ассистент внутри инструмента moodboard. Отвечай по делу, кратко и на русском.'
15
+ },
16
+ {
17
+ id: 'design-helper',
18
+ label: 'Design helper',
19
+ kind: 'style',
20
+ systemPrompt: 'Ты — ассистент по визуальному дизайну и мудбордам. Помогай с подбором палитр, сетки, типографики, референсов и подачи. Пиши кратко, давай конкретные значения (HEX, размеры, шрифты).'
21
+ },
22
+ {
23
+ id: 'copywriter',
24
+ label: 'Copywriter',
25
+ kind: 'style',
26
+ systemPrompt: 'Ты — ассистент-копирайтер. Помогай формулировать заголовки, описания, тексты для слайдов и презентаций. Сохраняй смысл, делай короче и яснее.'
27
+ },
28
+ {
29
+ id: 'strict-editor',
30
+ label: 'Strict editor',
31
+ kind: 'manual',
32
+ systemPrompt: 'Ты — строгий редактор. Проверяй текст на ошибки, тавтологии, штампы. Возвращай исправленный вариант и список изменений.'
33
+ }
34
+ ];
35
+
36
+ export const DEFAULT_PRESET_ID = 'default';
37
+
38
+ export function getPresetById(id) {
39
+ return CHAT_PRESETS.find((p) => p.id === id) || CHAT_PRESETS[0];
40
+ }