@sequent-org/moodboard 1.4.26 → 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/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/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
|
+
}
|
|
@@ -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
|
+
}
|