@tosk/gen-ui 1.0.1
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/gen-ui.js +382 -0
- package/package.json +18 -0
package/gen-ui.js
ADDED
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
class GenerativeUi extends HTMLElement {
|
|
2
|
+
// 1. 定数
|
|
3
|
+
|
|
4
|
+
static API_BASE_URL = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent';
|
|
5
|
+
|
|
6
|
+
static COPY_ICON_SVG = `
|
|
7
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
|
8
|
+
<path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/>
|
|
9
|
+
</svg>
|
|
10
|
+
`;
|
|
11
|
+
|
|
12
|
+
static TEMPLATE = (() => {
|
|
13
|
+
const template = document.createElement('template');
|
|
14
|
+
template.innerHTML = `
|
|
15
|
+
<style>
|
|
16
|
+
:host {
|
|
17
|
+
display: block;
|
|
18
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
19
|
+
border: 1px solid #d1d5db;
|
|
20
|
+
border-radius: 0.5rem;
|
|
21
|
+
padding: 1.5rem;
|
|
22
|
+
max-width: 800px;
|
|
23
|
+
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
|
24
|
+
min-height: 200px;
|
|
25
|
+
position: relative;
|
|
26
|
+
}
|
|
27
|
+
.loading-overlay {
|
|
28
|
+
position: absolute; top: 0; left: 0; right: 0; bottom: 0;
|
|
29
|
+
background: rgba(255, 255, 255, 0.8);
|
|
30
|
+
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
|
31
|
+
gap: 1rem; font-weight: 500; color: #333;
|
|
32
|
+
border-radius: 0.5rem; z-index: 20;
|
|
33
|
+
}
|
|
34
|
+
.spinner {
|
|
35
|
+
border: 4px solid rgba(0, 0, 0, .1);
|
|
36
|
+
border-left-color: #2563eb;
|
|
37
|
+
border-radius: 50%;
|
|
38
|
+
width: 36px; height: 36px;
|
|
39
|
+
animation: spin 1s linear infinite;
|
|
40
|
+
}
|
|
41
|
+
@keyframes spin { to { transform: rotate(360deg); } }
|
|
42
|
+
.tabs { display: flex; border-bottom: 1px solid #d1d5db; }
|
|
43
|
+
.tab { padding: 0.5rem 1rem; cursor: pointer; border: 1px solid transparent; border-bottom: none; margin-bottom: -1px; }
|
|
44
|
+
.tab.active { border-color: #d1d5db; border-bottom-color: white; border-radius: 0.375rem 0.375rem 0 0; background-color: white; }
|
|
45
|
+
.tab-content { display: none; border: 1px solid #d1d5db; border-top: none; padding: 1rem; border-radius: 0 0 0.375rem 0.375rem; }
|
|
46
|
+
.tab-content.active { display: block; }
|
|
47
|
+
.code-area { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
|
|
48
|
+
.output-box { position: relative; }
|
|
49
|
+
h3 { margin-top: 0; }
|
|
50
|
+
pre { background-color: #f3f4f6; padding: 1rem; border-radius: 0.375rem; max-height: 300px; overflow-y: auto; white-space: pre-wrap; word-wrap: break-word; }
|
|
51
|
+
.copy-btn { position: absolute; top: 0.5rem; right: 0.5rem; padding: 0.25rem; background-color: #e5e7eb; border: 1px solid #d1d5db; border-radius: 0.25rem; cursor: pointer; display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; transition: background-color 0.2s; z-index: 10; }
|
|
52
|
+
.copy-btn:hover { background-color: #d1d5db; }
|
|
53
|
+
.copy-btn svg { width: 16px; height: 16px; }
|
|
54
|
+
.copy-feedback { position: absolute; top: 0.5rem; right: 38px; background-color: #333; color: white; padding: 0.25rem 0.5rem; border-radius: 0.25rem; font-size: 0.75rem; opacity: 0; transition: opacity 0.3s ease-in-out; pointer-events: none; white-space: nowrap; }
|
|
55
|
+
.copy-feedback.show { opacity: 1; }
|
|
56
|
+
#preview-output { width: 100%; height: 400px; border: 1px solid #d1d5db; border-radius: 0.375rem; }
|
|
57
|
+
#error-display { color: #ef4444; font-weight: 500; padding: 1rem; }
|
|
58
|
+
.hidden { display: none !important; }
|
|
59
|
+
|
|
60
|
+
#response-time-display {
|
|
61
|
+
position: absolute;
|
|
62
|
+
bottom: 0.5rem;
|
|
63
|
+
right: 0.5rem;
|
|
64
|
+
font-size: 0.75rem;
|
|
65
|
+
color: #6b7280;
|
|
66
|
+
background-color: #f3f4f6;
|
|
67
|
+
padding: 0.25rem 0.5rem;
|
|
68
|
+
border-radius: 0.25rem;
|
|
69
|
+
z-index: 10;
|
|
70
|
+
}
|
|
71
|
+
</style>
|
|
72
|
+
<div class="container">
|
|
73
|
+
<div id="loading-overlay" class="loading-overlay hidden">
|
|
74
|
+
<div class="spinner"></div>
|
|
75
|
+
<div>UIを生成中...</div>
|
|
76
|
+
</div>
|
|
77
|
+
<div id="error-display" class="hidden"></div>
|
|
78
|
+
<div id="output-container" class="hidden">
|
|
79
|
+
<div class="tabs">
|
|
80
|
+
<div class="tab active" data-tab="code">コード</div>
|
|
81
|
+
<div class="tab" data-tab="preview">プレビュー</div>
|
|
82
|
+
</div>
|
|
83
|
+
<div id="code" class="tab-content active">
|
|
84
|
+
<div class="code-area">
|
|
85
|
+
<div class="output-box">
|
|
86
|
+
<h3>HTML</h3>
|
|
87
|
+
<button class="copy-btn" data-target="html-output" aria-label="HTMLコードをコピー">
|
|
88
|
+
${GeminiComponent.COPY_ICON_SVG}
|
|
89
|
+
</button>
|
|
90
|
+
<div class="copy-feedback" data-for="html-output">コピーしました</div>
|
|
91
|
+
<pre><code id="html-output"></code></pre>
|
|
92
|
+
</div>
|
|
93
|
+
<div class="output-box">
|
|
94
|
+
<h3>CSS</h3>
|
|
95
|
+
<button class="copy-btn" data-target="css-output" aria-label="CSSコードをコピー">
|
|
96
|
+
${GeminiComponent.COPY_ICON_SVG}
|
|
97
|
+
</button>
|
|
98
|
+
<div class="copy-feedback" data-for="css-output">コピーしました</div>
|
|
99
|
+
<pre><code id="css-output"></code></pre>
|
|
100
|
+
</div>
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
<div id="preview" class="tab-content">
|
|
104
|
+
<iframe id="preview-output" title="生成されたUIのプレビュー"></iframe>
|
|
105
|
+
</div>
|
|
106
|
+
</div>
|
|
107
|
+
</div>
|
|
108
|
+
`;
|
|
109
|
+
return template;
|
|
110
|
+
})();
|
|
111
|
+
|
|
112
|
+
static SELECTORS = {
|
|
113
|
+
loadingOverlay: '#loading-overlay',
|
|
114
|
+
errorDisplay: '#error-display',
|
|
115
|
+
outputContainer: '#output-container',
|
|
116
|
+
htmlOutput: '#html-output',
|
|
117
|
+
cssOutput: '#css-output',
|
|
118
|
+
previewOutput: '#preview-output',
|
|
119
|
+
copyButtons: '.copy-btn',
|
|
120
|
+
tabs: '.tab',
|
|
121
|
+
tabContents: '.tab-content',
|
|
122
|
+
responseTimeDisplay: '#response-time-display',
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
static CLASSES = {
|
|
126
|
+
ACTIVE: 'active',
|
|
127
|
+
HIDDEN: 'hidden',
|
|
128
|
+
SHOW: 'show',
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
static UI_STATES = {
|
|
132
|
+
LOADING: 'LOADING',
|
|
133
|
+
SUCCESS: 'SUCCESS',
|
|
134
|
+
ERROR: 'ERROR',
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
// 2. プライベートプロパティ
|
|
138
|
+
|
|
139
|
+
#apiKey = null;
|
|
140
|
+
#requestPrompt = null;
|
|
141
|
+
#originalHtml = '';
|
|
142
|
+
#elements = {};
|
|
143
|
+
#abortController = null;
|
|
144
|
+
|
|
145
|
+
// 3. ライフサイクル
|
|
146
|
+
|
|
147
|
+
constructor() {
|
|
148
|
+
super();
|
|
149
|
+
this.attachShadow({ mode: 'open' });
|
|
150
|
+
this.shadowRoot.appendChild(GeminiComponent.TEMPLATE.content.cloneNode(true));
|
|
151
|
+
this.#cacheElements();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
connectedCallback() {
|
|
155
|
+
this.#initializeProperties();
|
|
156
|
+
|
|
157
|
+
if (this.#validateAttributes()) {
|
|
158
|
+
this.#addEventListeners();
|
|
159
|
+
this.#processRequest();
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
disconnectedCallback() {
|
|
164
|
+
this.#removeEventListeners();
|
|
165
|
+
this.#abortController?.abort();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// 4. イベントハンドラ
|
|
169
|
+
|
|
170
|
+
#handleCopy = async(event) => {
|
|
171
|
+
const button = event.currentTarget;
|
|
172
|
+
const targetId = button.dataset.target;
|
|
173
|
+
const codeElement = this.shadowRoot.querySelector(`#${targetId}`);
|
|
174
|
+
if (!codeElement) return;
|
|
175
|
+
|
|
176
|
+
const feedbackElement = this.shadowRoot.querySelector(`.copy-feedback[data-for="${targetId}"]`);
|
|
177
|
+
|
|
178
|
+
try {
|
|
179
|
+
await navigator.clipboard.writeText(codeElement.textContent);
|
|
180
|
+
feedbackElement.classList.add(GeminiComponent.CLASSES.SHOW);
|
|
181
|
+
setTimeout(() => feedbackElement.classList.remove(GeminiComponent.CLASSES.SHOW), 2000);
|
|
182
|
+
} catch (err) {
|
|
183
|
+
console.error('コピーに失敗しました: ', err);
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
#handleTabClick = (event) => {
|
|
188
|
+
const tabName = event.currentTarget.dataset.tab;
|
|
189
|
+
this.#switchTab(tabName);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// 5. コアロジック
|
|
193
|
+
|
|
194
|
+
#processRequest = async () => {
|
|
195
|
+
this.#updateUIState(GeminiComponent.UI_STATES.LOADING);
|
|
196
|
+
this.#abortController = new AbortController();
|
|
197
|
+
|
|
198
|
+
const startTime = performance.now();
|
|
199
|
+
|
|
200
|
+
try {
|
|
201
|
+
const prompt = this.#buildPrompt(this.#originalHtml, this.#requestPrompt);
|
|
202
|
+
const responseText = await this.#callGeminiApi(prompt, this.#abortController.signal);
|
|
203
|
+
const jsonResponse = this.#parseApiResponse(responseText);
|
|
204
|
+
|
|
205
|
+
this.#updateUIState(GeminiComponent.UI_STATES.SUCCESS, jsonResponse);
|
|
206
|
+
} catch (error) {
|
|
207
|
+
if (error.name !== 'AboutError') {
|
|
208
|
+
console.error("処理中にエラーが発生しました:", error);
|
|
209
|
+
this.#updateUIState(GeminiComponent.UI_STATES.ERROR, { message: error.message });
|
|
210
|
+
}
|
|
211
|
+
} finally {
|
|
212
|
+
const endTime = performance.now();
|
|
213
|
+
const duration = ((endTime - startTime) / 1000).toFixed(2);
|
|
214
|
+
if (this.#elements.responseTimeDisplay) {
|
|
215
|
+
this.#elements.responseTimeDisplay.textContent = `応答速度: ${duration}秒`;
|
|
216
|
+
}
|
|
217
|
+
this.#abortController = null;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async #callGeminiApi(prompt, signal) {
|
|
222
|
+
const url = `${GeminiComponent.API_BASE_URL}?key=${this.#apiKey}`;
|
|
223
|
+
const body = {
|
|
224
|
+
contents: [{ parts: [{ text: prompt }] }],
|
|
225
|
+
generationConfig: { responseMimeType: "application/json" },
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
const response = await fetch(url, {
|
|
229
|
+
method: 'POST',
|
|
230
|
+
headers: { 'Content-Type': 'application/json' },
|
|
231
|
+
body: JSON.stringify(body),
|
|
232
|
+
signal,
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
if (!response.ok) {
|
|
236
|
+
const errorData = await response.json().catch(() => ({}));
|
|
237
|
+
throw new Error(`APIエラー (${response.status}): ${errorData?.error?.message || '不明なエラー'}`);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const data = await response.json();
|
|
241
|
+
return data?.candidates?.[0]?.content?.parts?.[0]?.text || null;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
#parseApiResponse(responseText) {
|
|
245
|
+
if (!responseText) {
|
|
246
|
+
throw new Error("APIは空のレスポンスを返しました。")
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
try {
|
|
250
|
+
const jsonResponse = JSON.parse(responseText);
|
|
251
|
+
if (typeof jsonResponse.html !== 'string' || typeof jsonResponse.css !== 'string') {
|
|
252
|
+
throw new Error("APIからのJSONフォーマットが無効です。「html」と「css」のキーが必要です。")
|
|
253
|
+
}
|
|
254
|
+
return jsonResponse;
|
|
255
|
+
} catch (error) {
|
|
256
|
+
console.error("APIレスポンスの解析に失敗しました: ", responseText);
|
|
257
|
+
throw new Error("APIからの応答を解析できませんでした。")
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// 6. UI操作
|
|
262
|
+
|
|
263
|
+
#updateUIState(state, payload = {}) {
|
|
264
|
+
const { loadingOverlay, outputContainer, errorDisplay, htmlOutput, cssOutput, previewOutput, responseTimeDisplay } = this.#elements;
|
|
265
|
+
const { HIDDEN } = GeminiComponent.CLASSES;
|
|
266
|
+
|
|
267
|
+
loadingOverlay.classList.add(HIDDEN)
|
|
268
|
+
outputContainer.classList.add(HIDDEN);
|
|
269
|
+
errorDisplay.classList.add(HIDDEN);
|
|
270
|
+
|
|
271
|
+
if (responseTimeDisplay) {
|
|
272
|
+
responseTimeDisplay.classList.add(HIDDEN);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
switch (state) {
|
|
276
|
+
case GeminiComponent.UI_STATES.LOADING:
|
|
277
|
+
loadingOverlay.classList.remove(HIDDEN);
|
|
278
|
+
break;
|
|
279
|
+
|
|
280
|
+
case GeminiComponent.UI_STATES.ERROR:
|
|
281
|
+
errorDisplay.textContent = `エラー: ${payload.message}`;
|
|
282
|
+
errorDisplay.classList.remove(HIDDEN);
|
|
283
|
+
if (responseTimeDisplay) {
|
|
284
|
+
responseTimeDisplay.classList.remove(HIDDEN);
|
|
285
|
+
}
|
|
286
|
+
break;
|
|
287
|
+
|
|
288
|
+
case GeminiComponent.UI_STATES.SUCCESS:
|
|
289
|
+
const { html, css } = payload;
|
|
290
|
+
htmlOutput.textContent = html;
|
|
291
|
+
cssOutput.textContent = css;
|
|
292
|
+
previewOutput.srcdoc = this.#createPreviewDoc(html, css);
|
|
293
|
+
outputContainer.classList.remove(HIDDEN);
|
|
294
|
+
this.#switchTab('code');
|
|
295
|
+
if (responseTimeDisplay) {
|
|
296
|
+
responseTimeDisplay.classList.remove(HIDDEN);
|
|
297
|
+
}
|
|
298
|
+
break;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
#switchTab = (tabName) => {
|
|
303
|
+
const { ACTIVE } = GeminiComponent.CLASSES;
|
|
304
|
+
this.#elements.tabs.forEach(tab => tab.classList.toggle(ACTIVE, tab.dataset.tab === tabName));
|
|
305
|
+
this.#elements.tabContents.forEach(content => content.classList.toggle(ACTIVE, content.id === tabName));
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
// 7. ヘルパー
|
|
309
|
+
|
|
310
|
+
#cacheElements() {
|
|
311
|
+
for (const key in GeminiComponent.SELECTORS) {
|
|
312
|
+
const elements = this.shadowRoot.querySelectorAll(GeminiComponent.SELECTORS[key]);
|
|
313
|
+
this.#elements[key] = elements.length > 1 ? Array.from(elements) : elements[0];
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
#initializeProperties() {
|
|
318
|
+
this.#apiKey = this.getAttribute('api-key');
|
|
319
|
+
this.#requestPrompt = this.getAttribute('request');
|
|
320
|
+
this.#originalHtml = this.innerHTML.trim();
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
#validateAttributes() {
|
|
324
|
+
if (!this.#apiKey) {
|
|
325
|
+
this.#updateUIState(GeminiComponent.UI_STATES.ERROR, { message: '「api-key」属性が必要です。'});
|
|
326
|
+
return false;
|
|
327
|
+
}
|
|
328
|
+
if (!this.#requestPrompt) {
|
|
329
|
+
this.#updateUIState(GeminiComponent.UI_STATES.ERROR, { message: '「request」属性が必要です。'});
|
|
330
|
+
return false;
|
|
331
|
+
}
|
|
332
|
+
if (!this.#originalHtml) {
|
|
333
|
+
this.#updateUIState(GeminiComponent.UI_STATES.ERROR, { message: 'HTMLの子要素が必要です。'});
|
|
334
|
+
return false;
|
|
335
|
+
}
|
|
336
|
+
return true;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
#addEventListeners() {
|
|
340
|
+
this.#elements.copyButtons.forEach(btn => btn.addEventListener('click', this.#handleCopy));
|
|
341
|
+
this.#elements.tabs.forEach(tab => tab.addEventListener('click', this.#handleTabClick));
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
#removeEventListeners() {
|
|
345
|
+
this.#elements.copyButtons.forEach(btn => btn.removeEventListener('click', this.#handleCopy));
|
|
346
|
+
this.#elements.tabs.forEach(tab => tab.removeEventListener('click', this.#handleTabClick));
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
#buildPrompt(html, request) {
|
|
350
|
+
return `
|
|
351
|
+
あなたはプロのフロントエンジニアです。
|
|
352
|
+
入力としてユーザーからの指示と、変更対象となるHTMLを受け取ります。
|
|
353
|
+
あなたはその指示に従って、新しいHTMLと、そのHTMLを装飾するためのCSSコードを生成してください。
|
|
354
|
+
出力は以下のルールに厳密に従ってください
|
|
355
|
+
- 回答は必ずJSON形式でなければなりません。
|
|
356
|
+
- JSONオブジェクトは 'html' と 'css' の2つのキーを持つ必要があります。
|
|
357
|
+
- 'html' の値は変更後のHTMLコード(文字列)です。
|
|
358
|
+
- 'css' の値は生成されたCSSコード(文字列)です。
|
|
359
|
+
- JSONを囲む\`\`\`jsonや\`\`\`のようなMarkdownのコードブロック識別子を絶対に含めないでください。
|
|
360
|
+
- 回答には純粋なJSONオブジェクトのみとしてください。説明や他のテキストは一切不要です。
|
|
361
|
+
ユーザーからの指示: ${request}
|
|
362
|
+
対象のHTML: ${html}
|
|
363
|
+
`;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
#createPreviewDoc(html, css) {
|
|
367
|
+
return `
|
|
368
|
+
<!DOCTYPE html>
|
|
369
|
+
<html lang="ja">
|
|
370
|
+
<head>
|
|
371
|
+
<meta charset="UTF-8">
|
|
372
|
+
<style>body { font-family: sans-serif; } ${css}</style>
|
|
373
|
+
</head>
|
|
374
|
+
<body>
|
|
375
|
+
${html}
|
|
376
|
+
</body>
|
|
377
|
+
</html>
|
|
378
|
+
`;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
customElements.define('gen-ui', GenerativeUi);
|
package/package.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tosk/gen-ui",
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"main": "gen-ui.js",
|
|
5
|
+
"files": [
|
|
6
|
+
"gen-ui.js"
|
|
7
|
+
],
|
|
8
|
+
"scripts": {
|
|
9
|
+
"test": "echo \"Error: no test specified\" && exit 1"
|
|
10
|
+
},
|
|
11
|
+
"keywords": [],
|
|
12
|
+
"author": "",
|
|
13
|
+
"license": "ISC",
|
|
14
|
+
"description": "",
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@tosk/gemini-test": "^1.0.0"
|
|
17
|
+
}
|
|
18
|
+
}
|