@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.
- package/package.json +2 -1
- package/src/assets/fonts/geist/geist-sans-latin-100-normal.ttf +0 -0
- package/src/assets/fonts/geist/geist-sans-latin-200-normal.ttf +0 -0
- package/src/assets/fonts/geist/geist-sans-latin-300-normal.ttf +0 -0
- package/src/assets/fonts/geist/geist-sans-latin-400-normal.ttf +0 -0
- package/src/assets/fonts/geist/geist-sans-latin-500-normal.ttf +0 -0
- package/src/assets/fonts/geist/geist-sans-latin-600-normal.ttf +0 -0
- package/src/assets/fonts/geist/geist-sans-latin-700-normal.ttf +0 -0
- package/src/assets/fonts/geist/geist-sans-latin-800-normal.ttf +0 -0
- package/src/assets/fonts/geist/geist-sans-latin-900-normal.ttf +0 -0
- package/src/core/SaveManager.js +6 -0
- package/src/core/flows/ClipboardFlow.js +16 -6
- package/src/moodboard/bootstrap/MoodBoardUiFactory.js +10 -0
- package/src/moodboard/lifecycle/MoodBoardDestroyer.js +3 -0
- package/src/services/ai/AiClient.js +220 -0
- package/src/services/ai/ChatHistoryStore.js +55 -0
- package/src/services/ai/ChatPresets.js +40 -0
- package/src/services/ai/ChatSessionController.js +220 -0
- package/src/tools/object-tools/selection/TextEditorInteractionController.js +10 -0
- package/src/ui/TextPropertiesPanel.js +7 -1
- package/src/ui/chat/ChatComposer.js +198 -0
- package/src/ui/chat/ChatExtendedPromptModal.js +131 -0
- package/src/ui/chat/ChatMessageList.js +92 -0
- package/src/ui/chat/ChatPillMenu.js +141 -0
- package/src/ui/chat/ChatSettingsPopup.js +171 -0
- package/src/ui/chat/ChatWindow.js +407 -0
- package/src/ui/chat/ChatWindowRenderer.js +214 -0
- package/src/ui/chat/icons.js +113 -0
- package/src/ui/styles/chat.css +920 -0
- package/src/ui/styles/index.css +1 -0
- package/src/ui/styles/workspace.css +73 -1
- package/src/ui/text-properties/TextPropertiesPanelState.js +2 -0
- package/src/utils/styleLoader.js +2 -1
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { CHAT_PRESETS, DEFAULT_PRESET_ID, getPresetById } from './ChatPresets.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Контроллер сессии чата.
|
|
5
|
+
*
|
|
6
|
+
* Одна ответственность: держит состояние диалога и оркестрирует
|
|
7
|
+
* вызовы AiClient + сохранение в ChatHistoryStore. Не знает про DOM.
|
|
8
|
+
*
|
|
9
|
+
* Связь с UI — через слушателей (subscribe), а не через прямые ссылки.
|
|
10
|
+
*
|
|
11
|
+
* Состояние:
|
|
12
|
+
* - messages: список сообщений (с временным assistant-сообщением во время стриминга)
|
|
13
|
+
* - providerId: текущий провайдер (yandex-art)
|
|
14
|
+
* - presetId: текущий пресет промпта
|
|
15
|
+
* - settings: { systemPrompt, temperature, maxTokens }
|
|
16
|
+
* - status: 'idle' | 'streaming' | 'error'
|
|
17
|
+
* - error: string|null
|
|
18
|
+
*
|
|
19
|
+
* События для подписчиков (один колбэк на всё, для простоты):
|
|
20
|
+
* - 'state' — любое изменение состояния (UI делает rerender)
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
export const DEFAULT_SETTINGS = {
|
|
24
|
+
systemPrompt: '',
|
|
25
|
+
temperature: 0.7,
|
|
26
|
+
maxTokens: 2000
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const SETTINGS_STORAGE_KEY = 'moodboard.ai.chat.settings.v1';
|
|
30
|
+
|
|
31
|
+
export class ChatSessionController {
|
|
32
|
+
/**
|
|
33
|
+
* @param {object} deps
|
|
34
|
+
* @param {import('./AiClient.js').AiClient} deps.aiClient
|
|
35
|
+
* @param {import('./ChatHistoryStore.js').ChatHistoryStore} deps.historyStore
|
|
36
|
+
* @param {Storage} [deps.settingsStorage]
|
|
37
|
+
*/
|
|
38
|
+
constructor({ aiClient, historyStore, settingsStorage }) {
|
|
39
|
+
this._client = aiClient;
|
|
40
|
+
this._history = historyStore;
|
|
41
|
+
this._settingsStorage = settingsStorage || (typeof localStorage !== 'undefined' ? localStorage : null);
|
|
42
|
+
this._listeners = new Set();
|
|
43
|
+
this._abort = null;
|
|
44
|
+
|
|
45
|
+
this._state = {
|
|
46
|
+
messages: this._history.load(),
|
|
47
|
+
providerId: 'yandex-art',
|
|
48
|
+
presetId: DEFAULT_PRESET_ID,
|
|
49
|
+
settings: this._loadSettings(),
|
|
50
|
+
status: 'idle',
|
|
51
|
+
error: null,
|
|
52
|
+
availableProviders: []
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
getState() {
|
|
57
|
+
return this._state;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
subscribe(listener) {
|
|
61
|
+
this._listeners.add(listener);
|
|
62
|
+
return () => this._listeners.delete(listener);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
setProvider(providerId) {
|
|
66
|
+
if (!providerId || providerId === this._state.providerId) return;
|
|
67
|
+
this._state = { ...this._state, providerId };
|
|
68
|
+
this._emit();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
setPreset(presetId) {
|
|
72
|
+
const preset = getPresetById(presetId);
|
|
73
|
+
const next = { ...this._state, presetId: preset.id };
|
|
74
|
+
if (!this._state.settings.systemPrompt || this._isPresetSystemPrompt(this._state.settings.systemPrompt)) {
|
|
75
|
+
next.settings = { ...this._state.settings, systemPrompt: preset.systemPrompt };
|
|
76
|
+
this._saveSettings(next.settings);
|
|
77
|
+
}
|
|
78
|
+
this._state = next;
|
|
79
|
+
this._emit();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
updateSettings(patch) {
|
|
83
|
+
const settings = { ...this._state.settings, ...patch };
|
|
84
|
+
this._state = { ...this._state, settings };
|
|
85
|
+
this._saveSettings(settings);
|
|
86
|
+
this._emit();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
setAvailableProviders(list) {
|
|
90
|
+
this._state = { ...this._state, availableProviders: Array.isArray(list) ? list : [] };
|
|
91
|
+
const enabled = this._state.availableProviders.filter((p) => p.enabled);
|
|
92
|
+
if (enabled.length > 0 && !enabled.some((p) => p.id === this._state.providerId)) {
|
|
93
|
+
this._state = { ...this._state, providerId: enabled[0].id };
|
|
94
|
+
}
|
|
95
|
+
this._emit();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
clearHistory() {
|
|
99
|
+
if (this._abort) this.abort();
|
|
100
|
+
this._state = { ...this._state, messages: [], status: 'idle', error: null };
|
|
101
|
+
this._history.save([]);
|
|
102
|
+
this._emit();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
abort() {
|
|
106
|
+
if (this._abort) {
|
|
107
|
+
try { this._abort.abort(); } catch { /* noop */ }
|
|
108
|
+
this._abort = null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Отправляет user-сообщение и создаёт изображение через YandexART.
|
|
114
|
+
* @param {string} text
|
|
115
|
+
* @param {{widthRatio?: number, heightRatio?: number, model?: string}} [options]
|
|
116
|
+
*/
|
|
117
|
+
async send(text, options = {}) {
|
|
118
|
+
const trimmed = (text || '').trim();
|
|
119
|
+
if (!trimmed || this._state.status === 'streaming') return;
|
|
120
|
+
|
|
121
|
+
const userMsg = makeMessage('user', trimmed);
|
|
122
|
+
const assistantMsg = makeMessage('assistant', '', { provider: 'yandex-art', pending: true, kind: 'image' });
|
|
123
|
+
|
|
124
|
+
this._state = {
|
|
125
|
+
...this._state,
|
|
126
|
+
messages: [...this._state.messages, userMsg, assistantMsg],
|
|
127
|
+
status: 'streaming',
|
|
128
|
+
error: null
|
|
129
|
+
};
|
|
130
|
+
this._history.save(this._state.messages);
|
|
131
|
+
this._emit();
|
|
132
|
+
|
|
133
|
+
const abort = new AbortController();
|
|
134
|
+
this._abort = abort;
|
|
135
|
+
|
|
136
|
+
try {
|
|
137
|
+
const result = await this._client.generateImage({
|
|
138
|
+
prompt: trimmed,
|
|
139
|
+
widthRatio: options.widthRatio,
|
|
140
|
+
heightRatio: options.heightRatio,
|
|
141
|
+
model: options.model,
|
|
142
|
+
signal: abort.signal
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
this._finalizeAssistant(assistantMsg.id, {
|
|
146
|
+
error: null,
|
|
147
|
+
imageBase64: result.imageBase64,
|
|
148
|
+
mimeType: result.mimeType,
|
|
149
|
+
operationId: result.operationId
|
|
150
|
+
});
|
|
151
|
+
} catch (err) {
|
|
152
|
+
const message = err?.name === 'AbortError' ? 'Отменено' : (err?.message || 'Ошибка запроса');
|
|
153
|
+
this._finalizeAssistant(assistantMsg.id, { error: message });
|
|
154
|
+
} finally {
|
|
155
|
+
this._abort = null;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
_finalizeAssistant(id, { error, imageBase64, mimeType, operationId }) {
|
|
160
|
+
const messages = this._state.messages.map((m) =>
|
|
161
|
+
m.id === id
|
|
162
|
+
? {
|
|
163
|
+
...m,
|
|
164
|
+
pending: false,
|
|
165
|
+
error: error || undefined,
|
|
166
|
+
imageBase64: imageBase64 || m.imageBase64,
|
|
167
|
+
mimeType: mimeType || m.mimeType,
|
|
168
|
+
operationId: operationId || m.operationId
|
|
169
|
+
}
|
|
170
|
+
: m
|
|
171
|
+
);
|
|
172
|
+
this._state = {
|
|
173
|
+
...this._state,
|
|
174
|
+
messages,
|
|
175
|
+
status: error ? 'error' : 'idle',
|
|
176
|
+
error: error || null
|
|
177
|
+
};
|
|
178
|
+
this._history.save(messages);
|
|
179
|
+
this._emit();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
_emit() {
|
|
183
|
+
for (const listener of this._listeners) {
|
|
184
|
+
try { listener(this._state); } catch (err) { console.error('[ChatSession] listener error:', err); }
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
_loadSettings() {
|
|
189
|
+
if (!this._settingsStorage) return { ...DEFAULT_SETTINGS };
|
|
190
|
+
try {
|
|
191
|
+
const raw = this._settingsStorage.getItem(SETTINGS_STORAGE_KEY);
|
|
192
|
+
if (!raw) return { ...DEFAULT_SETTINGS };
|
|
193
|
+
const parsed = JSON.parse(raw);
|
|
194
|
+
return { ...DEFAULT_SETTINGS, ...(parsed || {}) };
|
|
195
|
+
} catch {
|
|
196
|
+
return { ...DEFAULT_SETTINGS };
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
_saveSettings(settings) {
|
|
201
|
+
if (!this._settingsStorage) return;
|
|
202
|
+
try {
|
|
203
|
+
this._settingsStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(settings));
|
|
204
|
+
} catch { /* noop */ }
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
_isPresetSystemPrompt(text) {
|
|
208
|
+
return CHAT_PRESETS.some((p) => p.systemPrompt === text);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function makeMessage(role, content, extra = {}) {
|
|
213
|
+
return {
|
|
214
|
+
id: `msg_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
|
215
|
+
role,
|
|
216
|
+
content,
|
|
217
|
+
ts: Date.now(),
|
|
218
|
+
...extra
|
|
219
|
+
};
|
|
220
|
+
}
|
|
@@ -267,6 +267,16 @@ export function closeTextEditorFromState(controller, commit) {
|
|
|
267
267
|
textarea.remove();
|
|
268
268
|
}
|
|
269
269
|
controller.textEditor = { active: false, objectId: null, textarea: null, world: null, objectType: 'text' };
|
|
270
|
+
|
|
271
|
+
// Синхронно с createTextEditorFinalize: UI (в т.ч. панель свойств текста) ждёт окончание редактирования.
|
|
272
|
+
if (objectType === 'note') {
|
|
273
|
+
controller.eventBus.emit(Events.UI.NoteEditEnd, { objectId: objectId || null });
|
|
274
|
+
showNotePixiText(controller, objectId);
|
|
275
|
+
} else {
|
|
276
|
+
controller.eventBus.emit(Events.UI.TextEditEnd, { objectId: objectId || null });
|
|
277
|
+
}
|
|
278
|
+
updateGlobalTextEditorHandlesLayer();
|
|
279
|
+
|
|
270
280
|
if (!commitValue) {
|
|
271
281
|
if (shouldDeleteEmptyNewCreation) {
|
|
272
282
|
controller.eventBus.emit(Events.Tool.ObjectsDelete, { objects: [objectId] });
|
|
@@ -99,7 +99,10 @@ export class TextPropertiesPanel {
|
|
|
99
99
|
this.panel = createTextPropertiesPanelRenderer(this);
|
|
100
100
|
this.layer.appendChild(this.panel);
|
|
101
101
|
bindTextPropertiesPanelControls(this);
|
|
102
|
+
}
|
|
103
|
+
if (!this._docMouseDownAttached) {
|
|
102
104
|
document.addEventListener('mousedown', this._onDocMouseDown, true);
|
|
105
|
+
this._docMouseDownAttached = true;
|
|
103
106
|
}
|
|
104
107
|
|
|
105
108
|
this.panel.style.display = 'flex';
|
|
@@ -116,7 +119,10 @@ export class TextPropertiesPanel {
|
|
|
116
119
|
|
|
117
120
|
this._hideColorDropdown();
|
|
118
121
|
this._hideBgColorDropdown();
|
|
119
|
-
|
|
122
|
+
if (this._docMouseDownAttached) {
|
|
123
|
+
document.removeEventListener('mousedown', this._onDocMouseDown, true);
|
|
124
|
+
this._docMouseDownAttached = false;
|
|
125
|
+
}
|
|
120
126
|
}
|
|
121
127
|
|
|
122
128
|
_toggleColorDropdown() {
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Композер: textarea + кнопка отправки + вложения.
|
|
3
|
+
*
|
|
4
|
+
* Одна ответственность: события ввода и отправки. Не знает про пиллы,
|
|
5
|
+
* меню провайдеров и настройки — этим занимаются отдельные модули.
|
|
6
|
+
*
|
|
7
|
+
* Контракт колбэков:
|
|
8
|
+
* onSubmit(text, attachments) — нажат Enter без Shift или клик по send в состоянии 'ready'
|
|
9
|
+
* onAbort() — клик по send в состоянии 'streaming'
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const CLOSE_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="none" viewBox="0 0 12 12"><path stroke="currentColor" stroke-linecap="round" stroke-width="1.2" d="m2.5 2.5 7 7m0-7-7 7"></path></svg>`;
|
|
13
|
+
|
|
14
|
+
export class ChatComposer {
|
|
15
|
+
/**
|
|
16
|
+
* @param {{ textarea: HTMLTextAreaElement, send: HTMLButtonElement, attach: HTMLButtonElement, fileInput: HTMLInputElement, attachmentsPreview: HTMLElement, enhancePrompt?: HTMLButtonElement, statusBar?: HTMLElement }} refs
|
|
17
|
+
* @param {{ onSubmit: (text: string, attachments: File[]) => void, onAbort: () => void }} handlers
|
|
18
|
+
*/
|
|
19
|
+
constructor(refs, handlers) {
|
|
20
|
+
this._textarea = refs.textarea;
|
|
21
|
+
this._send = refs.send;
|
|
22
|
+
this._attach = refs.attach ?? null;
|
|
23
|
+
this._fileInput = refs.fileInput ?? null;
|
|
24
|
+
this._attachmentsPreview = refs.attachmentsPreview ?? null;
|
|
25
|
+
this._enhancePrompt = refs.enhancePrompt ?? null;
|
|
26
|
+
this._statusBar = refs.statusBar ?? null;
|
|
27
|
+
this._handlers = handlers;
|
|
28
|
+
this._listeners = [];
|
|
29
|
+
/** @type {File[]} */
|
|
30
|
+
this._attachments = [];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
attach() {
|
|
34
|
+
this._on(this._textarea, 'input', () => {
|
|
35
|
+
this._resizeTextarea();
|
|
36
|
+
this._refreshSendState();
|
|
37
|
+
});
|
|
38
|
+
this._on(this._textarea, 'keydown', (e) => {
|
|
39
|
+
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
|
|
40
|
+
e.preventDefault();
|
|
41
|
+
this._submit();
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
this._on(this._send, 'click', () => {
|
|
45
|
+
if (this._send.dataset.state === 'streaming') {
|
|
46
|
+
this._handlers.onAbort?.();
|
|
47
|
+
} else {
|
|
48
|
+
this._submit();
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
if (this._attach && this._fileInput) {
|
|
52
|
+
this._on(this._attach, 'click', () => this._fileInput.click());
|
|
53
|
+
this._on(this._fileInput, 'change', () => this._handleFileChange());
|
|
54
|
+
}
|
|
55
|
+
this._resizeTextarea();
|
|
56
|
+
this._refreshSendState();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Внешнее состояние стриминга — управляет иконкой кнопки send.
|
|
61
|
+
* @param {'idle'|'streaming'} status
|
|
62
|
+
*/
|
|
63
|
+
setStreaming(isStreaming) {
|
|
64
|
+
if (isStreaming) {
|
|
65
|
+
this._send.dataset.state = 'streaming';
|
|
66
|
+
this._send.disabled = false;
|
|
67
|
+
} else {
|
|
68
|
+
this._refreshSendState();
|
|
69
|
+
}
|
|
70
|
+
if (this._statusBar) {
|
|
71
|
+
this._statusBar.classList.toggle('is-visible', isStreaming);
|
|
72
|
+
this._statusBar.classList.toggle('is-generating', isStreaming);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
focus() {
|
|
77
|
+
this._textarea.focus();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
destroy() {
|
|
81
|
+
for (const off of this._listeners) off();
|
|
82
|
+
this._listeners = [];
|
|
83
|
+
this._attachments = [];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
_submit() {
|
|
87
|
+
const text = this._textarea.value;
|
|
88
|
+
const trimmed = text.trim();
|
|
89
|
+
const hasAttachments = this._attachments.length > 0;
|
|
90
|
+
if (!trimmed && !hasAttachments) return;
|
|
91
|
+
if (this._send.dataset.state === 'streaming') return;
|
|
92
|
+
const attachments = [...this._attachments];
|
|
93
|
+
this._attachments = [];
|
|
94
|
+
this._textarea.value = '';
|
|
95
|
+
this._resizeTextarea();
|
|
96
|
+
this._renderAttachmentsPreview();
|
|
97
|
+
this._refreshSendState();
|
|
98
|
+
this._handlers.onSubmit?.(trimmed, attachments);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
_refreshSendState() {
|
|
102
|
+
const hasText = this._textarea.value.trim().length > 0;
|
|
103
|
+
const hasAttachments = this._attachments.length > 0;
|
|
104
|
+
this._send.dataset.state = (hasText || hasAttachments) ? 'ready' : 'idle';
|
|
105
|
+
this._send.disabled = false;
|
|
106
|
+
if (this._enhancePrompt) {
|
|
107
|
+
this._enhancePrompt.dataset.empty = hasText ? 'false' : 'true';
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
_handleFileChange() {
|
|
112
|
+
const files = Array.from(this._fileInput.files || []);
|
|
113
|
+
if (!files.length) return;
|
|
114
|
+
for (const file of files) {
|
|
115
|
+
this._attachments.push(file);
|
|
116
|
+
}
|
|
117
|
+
this._fileInput.value = '';
|
|
118
|
+
this._renderAttachmentsPreview();
|
|
119
|
+
this._refreshSendState();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
_renderAttachmentsPreview() {
|
|
123
|
+
const container = this._attachmentsPreview;
|
|
124
|
+
if (!container) return;
|
|
125
|
+
|
|
126
|
+
container.innerHTML = '';
|
|
127
|
+
const inputRow = container.closest('.moodboard-chat__input-row');
|
|
128
|
+
if (this._attachments.length === 0) {
|
|
129
|
+
container.classList.remove('is-visible');
|
|
130
|
+
inputRow?.classList.remove('has-attachments');
|
|
131
|
+
this._textarea.placeholder = 'Опишите то, что хотите сгенерировать';
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
container.classList.add('is-visible');
|
|
136
|
+
inputRow?.classList.add('has-attachments');
|
|
137
|
+
this._textarea.placeholder = 'Опишите правку, изменение или стилевое направление эталонного изображения';
|
|
138
|
+
for (let i = 0; i < this._attachments.length; i++) {
|
|
139
|
+
const file = this._attachments[i];
|
|
140
|
+
const item = this._buildAttachmentItem(file, i);
|
|
141
|
+
container.appendChild(item);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
_buildAttachmentItem(file, index) {
|
|
146
|
+
const item = document.createElement('div');
|
|
147
|
+
item.className = 'moodboard-chat__attachment-item';
|
|
148
|
+
item.title = file.name;
|
|
149
|
+
|
|
150
|
+
const isImage = file.type.startsWith('image/');
|
|
151
|
+
|
|
152
|
+
if (isImage) {
|
|
153
|
+
const img = document.createElement('img');
|
|
154
|
+
img.className = 'moodboard-chat__attachment-thumb';
|
|
155
|
+
img.alt = file.name;
|
|
156
|
+
const url = URL.createObjectURL(file);
|
|
157
|
+
img.src = url;
|
|
158
|
+
img.addEventListener('load', () => URL.revokeObjectURL(url), { once: true });
|
|
159
|
+
item.appendChild(img);
|
|
160
|
+
} else {
|
|
161
|
+
const icon = document.createElement('div');
|
|
162
|
+
icon.className = 'moodboard-chat__attachment-icon';
|
|
163
|
+
const ext = file.name.split('.').pop()?.toUpperCase() ?? '?';
|
|
164
|
+
icon.textContent = ext;
|
|
165
|
+
item.appendChild(icon);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const badge = document.createElement('div');
|
|
169
|
+
badge.className = 'moodboard-chat__attachment-badge';
|
|
170
|
+
badge.textContent = String(index + 1);
|
|
171
|
+
item.appendChild(badge);
|
|
172
|
+
|
|
173
|
+
const remove = document.createElement('button');
|
|
174
|
+
remove.type = 'button';
|
|
175
|
+
remove.className = 'moodboard-chat__attachment-remove';
|
|
176
|
+
remove.setAttribute('aria-label', `Удалить ${file.name}`);
|
|
177
|
+
remove.innerHTML = CLOSE_SVG;
|
|
178
|
+
remove.addEventListener('click', () => {
|
|
179
|
+
this._attachments.splice(index, 1);
|
|
180
|
+
this._renderAttachmentsPreview();
|
|
181
|
+
this._refreshSendState();
|
|
182
|
+
});
|
|
183
|
+
item.appendChild(remove);
|
|
184
|
+
|
|
185
|
+
return item;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
_resizeTextarea() {
|
|
189
|
+
this._textarea.style.height = 'auto';
|
|
190
|
+
const nextHeight = Math.max(47, Math.ceil(this._textarea.scrollHeight));
|
|
191
|
+
this._textarea.style.height = `${nextHeight}px`;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
_on(el, type, handler) {
|
|
195
|
+
el.addEventListener(type, handler);
|
|
196
|
+
this._listeners.push(() => el.removeEventListener(type, handler));
|
|
197
|
+
}
|
|
198
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { ICONS } from './icons.js';
|
|
2
|
+
|
|
3
|
+
export class ChatExtendedPromptModal {
|
|
4
|
+
/**
|
|
5
|
+
* @param {HTMLElement} container - workspace-контейнер
|
|
6
|
+
* @param {HTMLTextAreaElement} sourceTextarea
|
|
7
|
+
* @param {HTMLElement} triggerBtn
|
|
8
|
+
*/
|
|
9
|
+
constructor(container, sourceTextarea, triggerBtn) {
|
|
10
|
+
this._container = container;
|
|
11
|
+
this._sourceTextarea = sourceTextarea;
|
|
12
|
+
this._triggerBtn = triggerBtn;
|
|
13
|
+
this._refs = null;
|
|
14
|
+
this._listeners = [];
|
|
15
|
+
this._isVisible = false;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
attach() {
|
|
19
|
+
this._refs = this._buildDom();
|
|
20
|
+
this._container.appendChild(this._refs.overlay);
|
|
21
|
+
|
|
22
|
+
this._on(this._triggerBtn, 'click', () => this.show());
|
|
23
|
+
this._on(this._refs.closeBtn, 'click', () => this.hide());
|
|
24
|
+
this._on(this._refs.overlay, 'click', (e) => {
|
|
25
|
+
if (e.target === this._refs.overlay) this.hide();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
this._on(this._refs.textarea, 'input', () => {
|
|
29
|
+
this._sourceTextarea.value = this._refs.textarea.value;
|
|
30
|
+
this._sourceTextarea.dispatchEvent(new Event('input', { bubbles: true }));
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
this._on(this._refs.clearBtn, 'click', () => {
|
|
34
|
+
this._refs.textarea.value = '';
|
|
35
|
+
this._refs.textarea.focus();
|
|
36
|
+
this._sourceTextarea.value = '';
|
|
37
|
+
this._sourceTextarea.dispatchEvent(new Event('input', { bubbles: true }));
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
this._on(this._refs.enhanceBtn, 'click', () => {
|
|
41
|
+
const originalEnhance = this._sourceTextarea.parentElement?.querySelector('.moodboard-chat__input-icon-btn--enhance-prompt');
|
|
42
|
+
if (originalEnhance) originalEnhance.click();
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
show() {
|
|
47
|
+
this._refs.textarea.value = this._sourceTextarea.value;
|
|
48
|
+
this._refs.overlay.classList.add('is-visible');
|
|
49
|
+
this._isVisible = true;
|
|
50
|
+
|
|
51
|
+
setTimeout(() => {
|
|
52
|
+
this._refs.textarea.focus();
|
|
53
|
+
this._refs.textarea.selectionStart = this._refs.textarea.value.length;
|
|
54
|
+
}, 10);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
hide() {
|
|
58
|
+
this._refs.overlay.classList.remove('is-visible');
|
|
59
|
+
this._isVisible = false;
|
|
60
|
+
this._sourceTextarea.focus();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
destroy() {
|
|
64
|
+
for (const off of this._listeners) off();
|
|
65
|
+
this._listeners = [];
|
|
66
|
+
if (this._refs?.overlay && this._refs.overlay.parentNode === this._container) {
|
|
67
|
+
this._container.removeChild(this._refs.overlay);
|
|
68
|
+
}
|
|
69
|
+
this._refs = null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
_on(el, type, handler) {
|
|
73
|
+
el.addEventListener(type, handler);
|
|
74
|
+
this._listeners.push(() => el.removeEventListener(type, handler));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
_buildDom() {
|
|
78
|
+
const overlay = document.createElement('div');
|
|
79
|
+
overlay.className = 'moodboard-chat__extended-overlay';
|
|
80
|
+
|
|
81
|
+
const modal = document.createElement('div');
|
|
82
|
+
modal.className = 'moodboard-chat__extended-modal';
|
|
83
|
+
|
|
84
|
+
const header = document.createElement('div');
|
|
85
|
+
header.className = 'moodboard-chat__extended-header';
|
|
86
|
+
|
|
87
|
+
const title = document.createElement('div');
|
|
88
|
+
title.className = 'moodboard-chat__extended-title';
|
|
89
|
+
title.textContent = 'Запрос';
|
|
90
|
+
|
|
91
|
+
const closeBtn = document.createElement('button');
|
|
92
|
+
closeBtn.type = 'button';
|
|
93
|
+
closeBtn.className = 'moodboard-chat__extended-close';
|
|
94
|
+
closeBtn.innerHTML = ICONS.close || 'Закрыть';
|
|
95
|
+
|
|
96
|
+
header.appendChild(title);
|
|
97
|
+
header.appendChild(closeBtn);
|
|
98
|
+
|
|
99
|
+
const body = document.createElement('div');
|
|
100
|
+
body.className = 'moodboard-chat__extended-body';
|
|
101
|
+
|
|
102
|
+
const textarea = document.createElement('textarea');
|
|
103
|
+
textarea.className = 'moodboard-chat__extended-textarea';
|
|
104
|
+
textarea.placeholder = 'Опишите то, что хотите сгенерировать';
|
|
105
|
+
|
|
106
|
+
const actions = document.createElement('div');
|
|
107
|
+
actions.className = 'moodboard-chat__extended-actions';
|
|
108
|
+
|
|
109
|
+
const clearBtn = document.createElement('button');
|
|
110
|
+
clearBtn.type = 'button';
|
|
111
|
+
clearBtn.className = 'moodboard-chat__extended-clear';
|
|
112
|
+
clearBtn.textContent = 'Очистить';
|
|
113
|
+
|
|
114
|
+
const enhanceBtn = document.createElement('button');
|
|
115
|
+
enhanceBtn.type = 'button';
|
|
116
|
+
enhanceBtn.className = 'moodboard-chat__extended-enhance';
|
|
117
|
+
enhanceBtn.innerHTML = `<span class="moodboard-chat__extended-enhance-icon">${ICONS.enhancePrompt}</span> Улучшить`;
|
|
118
|
+
|
|
119
|
+
actions.appendChild(clearBtn);
|
|
120
|
+
actions.appendChild(enhanceBtn);
|
|
121
|
+
|
|
122
|
+
body.appendChild(textarea);
|
|
123
|
+
body.appendChild(actions);
|
|
124
|
+
|
|
125
|
+
modal.appendChild(header);
|
|
126
|
+
modal.appendChild(body);
|
|
127
|
+
overlay.appendChild(modal);
|
|
128
|
+
|
|
129
|
+
return { overlay, modal, header, title, closeBtn, body, textarea, clearBtn, enhanceBtn };
|
|
130
|
+
}
|
|
131
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Рендер ленты сообщений чата.
|
|
3
|
+
*
|
|
4
|
+
* Одна ответственность: превратить массив messages в DOM с автоскроллом.
|
|
5
|
+
* Не управляет состоянием, не слушает события — только render(messages).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const ROLE_LABELS = {
|
|
9
|
+
user: 'Вы',
|
|
10
|
+
assistant: 'Ассистент',
|
|
11
|
+
system: 'Системный'
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export class ChatMessageList {
|
|
15
|
+
/**
|
|
16
|
+
* @param {HTMLElement} root - контейнер с классом .moodboard-chat__history
|
|
17
|
+
*/
|
|
18
|
+
constructor(root) {
|
|
19
|
+
this._root = root;
|
|
20
|
+
this._lastCount = 0;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {Array<{id: string, role: string, content: string, pending?: boolean, error?: string, imageBase64?: string, mimeType?: string}>} messages
|
|
25
|
+
*/
|
|
26
|
+
render(messages) {
|
|
27
|
+
const visible = messages.filter((m) => m.role !== 'system');
|
|
28
|
+
|
|
29
|
+
if (visible.length === 0) {
|
|
30
|
+
this._root.classList.remove('is-visible');
|
|
31
|
+
this._root.replaceChildren();
|
|
32
|
+
this._lastCount = 0;
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
this._root.classList.add('is-visible');
|
|
37
|
+
|
|
38
|
+
const fragment = document.createDocumentFragment();
|
|
39
|
+
for (const msg of visible) {
|
|
40
|
+
fragment.appendChild(this._renderMessage(msg));
|
|
41
|
+
}
|
|
42
|
+
this._root.replaceChildren(fragment);
|
|
43
|
+
|
|
44
|
+
if (visible.length !== this._lastCount) {
|
|
45
|
+
this._scrollToBottom();
|
|
46
|
+
} else {
|
|
47
|
+
this._scrollToBottomIfNearBottom();
|
|
48
|
+
}
|
|
49
|
+
this._lastCount = visible.length;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
_renderMessage(msg) {
|
|
53
|
+
const wrap = document.createElement('div');
|
|
54
|
+
wrap.className = `moodboard-chat__msg moodboard-chat__msg--${msg.role}`;
|
|
55
|
+
if (msg.error) wrap.classList.add('moodboard-chat__msg--error');
|
|
56
|
+
|
|
57
|
+
const role = document.createElement('div');
|
|
58
|
+
role.className = 'moodboard-chat__msg-role';
|
|
59
|
+
role.textContent = ROLE_LABELS[msg.role] || msg.role;
|
|
60
|
+
wrap.appendChild(role);
|
|
61
|
+
|
|
62
|
+
const body = document.createElement('div');
|
|
63
|
+
body.className = 'moodboard-chat__msg-body';
|
|
64
|
+
|
|
65
|
+
if (msg.error) {
|
|
66
|
+
body.textContent = msg.error;
|
|
67
|
+
} else if (msg.imageBase64) {
|
|
68
|
+
body.textContent = msg.content || 'Изображение добавлено на доску.';
|
|
69
|
+
} else if (msg.pending && !msg.content) {
|
|
70
|
+
body.textContent = '…';
|
|
71
|
+
} else {
|
|
72
|
+
body.textContent = msg.content;
|
|
73
|
+
if (msg.pending) {
|
|
74
|
+
const cursor = document.createElement('span');
|
|
75
|
+
cursor.className = 'moodboard-chat__msg-cursor';
|
|
76
|
+
body.appendChild(cursor);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
wrap.appendChild(body);
|
|
81
|
+
return wrap;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
_scrollToBottom() {
|
|
85
|
+
this._root.scrollTop = this._root.scrollHeight;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
_scrollToBottomIfNearBottom() {
|
|
89
|
+
const distanceFromBottom = this._root.scrollHeight - this._root.scrollTop - this._root.clientHeight;
|
|
90
|
+
if (distanceFromBottom < 80) this._scrollToBottom();
|
|
91
|
+
}
|
|
92
|
+
}
|