SeaWolf-AI commited on
Commit
24f90ad
Β·
verified Β·
1 Parent(s): 40bb061

feat: freeform product/service prompt (textarea + 10 preset chips)

Browse files
aether_ad/llm/prompts/generate_genome_from_prompt.txt ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [μ—­ν• ] 당신은 AETHER-Ad Genesis의 Genome Generatorλ‹€.
2
+ μ‚¬μš©μžμ˜ 자유둜운 ν•œ 쀄 ν”„λ‘¬ν”„νŠΈ(μ œν’ˆ λ˜λŠ” μ„œλΉ„μŠ€)μ—μ„œ μ™„μ „ν•œ ProductGenome JSON 객체 ν•˜λ‚˜λ₯Ό μƒμ„±ν•œλ‹€.
3
+
4
+ [μ‚¬μš©μž ν”„λ‘¬ν”„νŠΈ]
5
+ {prompt}
6
+
7
+ [과제]
8
+ μœ„ ν”„λ‘¬ν”„νŠΈμ—μ„œ μ•„λž˜ ν•„λ“œλ₯Ό 슀슀둜 μœ μΆ”Β·μ°½μž‘ν•˜μ—¬ ν•˜λ‚˜μ˜ JSON을 λ§Œλ“ λ‹€.
9
+ - brand: μž…λ ₯에 λΈŒλžœλ“œλͺ…이 λͺ…μ‹œλ˜λ©΄ κ·ΈλŒ€λ‘œ. μ—†μœΌλ©΄ μΉ΄ν…Œκ³ λ¦¬μ— μ–΄μšΈλ¦¬λŠ” 가상 λΈŒλžœλ“œλ₯Ό **μƒˆλ‘œ μ°½μž‘**.
10
+ μ‹€μ‘΄ λΈŒλžœλ“œλͺ…(μŠ€νƒ€λ²…μŠ€/μ‚Όμ„±/λ‚˜μ΄ν‚€/ν˜„λŒ€/μ• ν”Œ/ν•˜μ΄νŠΈ λ“±) μ‚¬μš© **μ ˆλŒ€ κΈˆμ§€**.
11
+ - category: ν•œκ΅­μ–΄ μΉ΄ν…Œκ³ λ¦¬ (μ œν’ˆ λ˜λŠ” μ„œλΉ„μŠ€ λͺ¨λ‘ κ°€λŠ₯ β€” "원두 컀피", "λŸ¬λ‹ν™”", "O2O 세탁 μ„œλΉ„μŠ€", "온라인 κ°•μ˜" λ“±).
12
+ - price_tier: budget | mid | premium | luxury 쀑 ν•˜λ‚˜ β€” ν”„λ‘¬ν”„νŠΈμ˜ ν†€μ—μ„œ μΆ”μ •.
13
+ - functional_atoms: **3~6개**. μž…λ ₯에 λΆ€ν’ˆ/κ΅¬μ„±μš”μ†Œκ°€ λͺ…μ‹œλ˜λ©΄ κ·ΈλŒ€λ‘œ μ‚¬μš©. μ—†μœΌλ©΄ ν•΄λ‹Ή μ œν’ˆΒ·μ„œλΉ„μŠ€μ˜ μ „ν˜• 핡심 μš”μ†Œλ₯Ό μΆ”μΆœΒ·μ°½μž‘.
14
+ μ„œλΉ„μŠ€ μΉ΄ν…Œκ³ λ¦¬λΌλ©΄ "접점(touchpoint)", "μΈν„°νŽ˜μ΄μŠ€", "μ•½μ†λœ μ‹œκ°„" λ“± 좔상 μš”μ†Œλ„ atom으둜 κ°€λŠ₯.
15
+
16
+ [μ—„μˆ˜ κ·œμΉ™ β€” ν˜•μ‹]
17
+ 1. 좜λ ₯은 **였직 JSON 객체 ν•˜λ‚˜**. λ‹€λ₯Έ ν…μŠ€νŠΈΒ·μ£Όμ„Β·ν•΄μ„€ 일체 κΈˆμ§€.
18
+ 2. 첫 κΈ€μžλŠ” `{{`, λ§ˆμ§€λ§‰ κΈ€μžλŠ” `}}`.
19
+ 3. Markdown μ½”λ“œ 펜슀(```json …```) **κΈˆμ§€** β€” 순수 JSON만.
20
+ 4. ν•œκ΅­μ–΄ 기반. 사고 κ³Όμ •Β·"μ‚¬μš©μžκ°€β€¦"Β·"The user is…" λ“± 일체 κΈˆμ§€.
21
+
22
+ [μ—„μˆ˜ κ·œμΉ™ β€” μŠ€ν‚€λ§ˆ]
23
+ - 각 `functional_atom`:
24
+ - `id`: "F01_english_snake" ν˜•μ‹ (F + 2자리 숫자 + 영문 snake_case).
25
+ - `name`: ν•œκ΅­μ–΄ 이름.
26
+ - `literal`: 문자 κ·ΈλŒ€λ‘œμ˜ κΈ°λŠ₯/μ—­ν• .
27
+ - `abstract_potentials`: **μ •ν™•νžˆ 3개** β€” μ€μœ Β·μƒμ§•Β·λ¬Έν™”μ  ν•¨μ˜ (κΈ°λŠ₯ 반볡 κΈˆμ§€, μ‹œμ  μˆ˜μ€€).
28
+ - `scamper_variants`: S, C, A, M, P, E, R 각 킀에 **짧은 ν•œκ΅­μ–΄ 1개**μ”©.
29
+ - `narrative_affordance`: "low" | "medium" | "high".
30
+ - `taboo_proximity`: "none" | "low" | "medium" | "high".
31
+ - `sensory_signatures`: visual / auditory / tactile / olfactory / kinesthetic 각 1개 ν•œκ΅­μ–΄.
32
+ - `cultural_symbols.korean` 2개, `cultural_symbols.global` 1개.
33
+ - `ludic_potential` 2개.
34
+ - `physical_parts`: atom 수 μ΄μƒμ˜ 이름 λ‚˜μ—΄.
35
+
36
+ [좜λ ₯ μŠ€ν‚€λ§ˆ]
37
+ {{
38
+ "product_id": "auto_generated_slug",
39
+ "brand": "...",
40
+ "category": "...",
41
+ "price_tier": "premium",
42
+ "functional_atoms": [
43
+ {{
44
+ "id": "F01_xxx",
45
+ "name": "λΆ€ν’ˆλͺ…",
46
+ "literal": "문자 κΈ°λŠ₯",
47
+ "abstract_potentials": ["...", "...", "..."],
48
+ "scamper_variants": {{
49
+ "S": [".."], "C": [".."], "A": [".."],
50
+ "M": [".."], "P": [".."], "E": [".."], "R": [".."]
51
+ }},
52
+ "narrative_affordance": "high",
53
+ "taboo_proximity": "none"
54
+ }}
55
+ ],
56
+ "physical_parts": ["...", "..."],
57
+ "sensory_signatures": {{
58
+ "visual": ["..."], "auditory": ["..."], "tactile": ["..."],
59
+ "olfactory": ["..."], "kinesthetic": ["..."]
60
+ }},
61
+ "cultural_symbols": {{ "korean": ["..", ".."], "global": [".."] }},
62
+ "ludic_potential": ["...", "..."],
63
+ "taboo_proximity": "none"
64
+ }}
65
+
66
+ JSON 객체 ν•˜λ‚˜λ§Œ 좜λ ₯.
app.py CHANGED
@@ -2,6 +2,7 @@
2
 
3
  from __future__ import annotations
4
 
 
5
  import json
6
  import logging
7
  import os
@@ -49,6 +50,10 @@ CONTEXT = load_context(TENSIONS_PATH)
49
  CUSTOM_PRODUCTS: dict[str, ProductGenome] = {}
50
  _CUSTOM_LOCK = threading.Lock()
51
 
 
 
 
 
52
 
53
  def _slugify(text: str) -> str:
54
  """Turn an arbitrary Korean/English string into a snake_case ASCII id."""
@@ -247,6 +252,97 @@ async def delete_custom(product_id: str) -> dict[str, bool]:
247
  return {"removed": removed}
248
 
249
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
250
  @app.get("/api/tensions")
251
  async def tensions() -> list[dict[str, Any]]:
252
  return [t.model_dump() for t in CONTEXT.tension_archetypes]
 
2
 
3
  from __future__ import annotations
4
 
5
+ import hashlib
6
  import json
7
  import logging
8
  import os
 
50
  CUSTOM_PRODUCTS: dict[str, ProductGenome] = {}
51
  _CUSTOM_LOCK = threading.Lock()
52
 
53
+ # Prompt-hash β†’ product_id cache so identical freeform prompts re-use expanded genomes.
54
+ _PROMPT_CACHE: dict[str, str] = {}
55
+ _PROMPT_LOCK = threading.Lock()
56
+
57
 
58
  def _slugify(text: str) -> str:
59
  """Turn an arbitrary Korean/English string into a snake_case ASCII id."""
 
252
  return {"removed": removed}
253
 
254
 
255
+ class FreeformProductPrompt(BaseModel):
256
+ prompt: str = Field(min_length=3, max_length=600)
257
+ backend: Literal["fireworks", "hf"] = "fireworks"
258
+ model: str | None = None
259
+
260
+
261
+ @app.post("/api/products/from_prompt")
262
+ async def from_prompt(req: FreeformProductPrompt) -> dict[str, Any]:
263
+ """Take a freeform product/service prompt, expand into a ProductGenome.
264
+
265
+ Results are cached by SHA-256(prompt) so the same prompt is expanded once
266
+ per Space lifetime. Returns the same summary shape as /api/products.
267
+ """
268
+ text = req.prompt.strip()
269
+ key = hashlib.sha256(text.encode("utf-8")).hexdigest()[:16]
270
+
271
+ with _PROMPT_LOCK:
272
+ cached_pid = _PROMPT_CACHE.get(key)
273
+ if cached_pid:
274
+ with _CUSTOM_LOCK:
275
+ genome = CUSTOM_PRODUCTS.get(cached_pid)
276
+ if genome is not None:
277
+ return _genome_summary(genome, custom=True)
278
+
279
+ if req.backend == "fireworks" and not os.getenv("FIREWORKS_API_KEY"):
280
+ raise HTTPException(status_code=400, detail="FIREWORKS_API_KEY not set")
281
+ if req.backend == "hf" and not os.getenv("HF_TOKEN"):
282
+ raise HTTPException(status_code=400, detail="HF_TOKEN not set")
283
+ try:
284
+ llm = (
285
+ FireworksKimiBackend(model=req.model or None)
286
+ if req.backend == "fireworks"
287
+ else HFInferenceBackend(model=req.model or None)
288
+ )
289
+ except Exception as e:
290
+ log.exception("backend init failed")
291
+ raise HTTPException(status_code=500, detail=f"Backend init failed: {e}") from e
292
+
293
+ prompt_tpl = _load_prompt("generate_genome_from_prompt.txt")
294
+ prompt = prompt_tpl.format(prompt=text)
295
+
296
+ try:
297
+ raw = await run_in_threadpool(llm.complete, prompt, 16384, 0.6)
298
+ except Exception as e:
299
+ log.exception("llm.complete failed for genome from_prompt")
300
+ raise HTTPException(status_code=502, detail=f"LLM failed: {e}") from e
301
+
302
+ cleaned, _ = strip_reasoning(raw)
303
+ body = cleaned or raw
304
+
305
+ try:
306
+ data = _extract_json_object(body)
307
+ except Exception as e:
308
+ log.warning("genome JSON parse failed: %s\n--raw--\n%s", e, raw[:2000])
309
+ raise HTTPException(
310
+ status_code=422,
311
+ detail="LLM did not return valid JSON. Try rephrasing the prompt "
312
+ "with brand Β· category Β· λΆ€ν’ˆ hints.",
313
+ ) from e
314
+
315
+ brand_hint = str(data.get("brand") or "custom")
316
+ base_pid = _slugify(brand_hint)
317
+ pid = base_pid
318
+ n = 1
319
+ with _CUSTOM_LOCK:
320
+ while pid in CUSTOM_PRODUCTS:
321
+ n += 1
322
+ pid = f"{base_pid}_{n}"
323
+ data["product_id"] = pid
324
+ data.setdefault("brand", brand_hint)
325
+ data.setdefault("category", "custom")
326
+ data.setdefault("price_tier", "premium")
327
+
328
+ try:
329
+ genome = ProductGenome.model_validate(data)
330
+ except Exception as e:
331
+ log.warning("genome validation failed: %s\n--data--\n%s", e, json.dumps(data, ensure_ascii=False)[:2000])
332
+ raise HTTPException(
333
+ status_code=422,
334
+ detail=f"Expanded genome failed schema validation: {e}",
335
+ ) from e
336
+
337
+ with _CUSTOM_LOCK:
338
+ CUSTOM_PRODUCTS[pid] = genome
339
+ with _PROMPT_LOCK:
340
+ _PROMPT_CACHE[key] = pid
341
+
342
+ log.info("registered custom product %s (from prompt, %d atoms)", pid, len(genome.functional_atoms))
343
+ return _genome_summary(genome, custom=True)
344
+
345
+
346
  @app.get("/api/tensions")
347
  async def tensions() -> list[dict[str, Any]]:
348
  return [t.model_dump() for t in CONTEXT.tension_archetypes]
static/app.js CHANGED
@@ -16,87 +16,38 @@ const _post = (url, body) => fetch(url, {
16
  });
17
 
18
  const API = {
19
- meta: () => fetch('/api/meta').then(r => r.json()),
20
- products: () => fetch('/api/products').then(r => r.json()),
21
- tensions: () => fetch('/api/tensions').then(r => r.json()),
22
- rules: () => fetch('/api/rules').then(r => r.json()),
23
- generate: (body) => _post('/api/generate', body),
24
- expand: (body) => _post('/api/products/expand', body),
25
  };
26
 
27
- const PRESETS = {
28
- coffee: {
29
- brand: 'NOVA-Bean', category: '원두 컀피', price_tier: 'premium',
30
- parts: [
31
- { name: '원두', description: 'λ‘œμŠ€νŒ…λœ 컀피 콩' },
32
- { name: '그라인더', description: '원두λ₯Ό κ°€λŠ” μž₯치' },
33
- { name: 'ν•„ν„°', description: '컀피 가루λ₯Ό κ±ΈλŸ¬λ‚΄λŠ” 쒅이/천' },
34
- { name: 'μΆ”μΆœμˆ˜', description: '92~96β„ƒμ˜ μ •μ œμˆ˜' },
35
- { name: 'μž”', description: '컀피λ₯Ό λ‹΄λŠ” 세라믹 μ»΅' },
36
- ],
37
- },
38
- phone: {
39
- brand: 'AETHER-Phone', category: '슀마트폰', price_tier: 'premium',
40
- parts: [
41
- { name: 'λ””μŠ€ν”Œλ ˆμ΄', description: '6.3인치 OLED ν„°μΉ˜ νŒ¨λ„' },
42
- { name: '카메라', description: 'ν›„λ©΄ νŠΈλ¦¬ν”Œ 카메라' },
43
- { name: '배터리', description: '4500mAh 리튬이온 배터리' },
44
- { name: 'ν”„λ‘œμ„Έμ„œ', description: '자체 섀계 AI μΉ©μ…‹' },
45
- { name: '진동λͺ¨ν„°', description: 'ν–…ν‹± ν”Όλ“œλ°±μš© λ―Έλ‹ˆ λͺ¨ν„°' },
46
- ],
47
- },
48
- sneaker: {
49
- brand: 'PULSE-Run', category: 'λŸ¬λ‹ν™”', price_tier: 'mid',
50
- parts: [
51
- { name: 'λ―Έλ“œμ†”', description: '발포폼 μ™„μΆ©μž¬' },
52
- { name: '아웃솔', description: '고무 μ ‘μ§€λ©΄' },
53
- { name: '어퍼', description: '톡기성 λ‹ˆνŠΈ 직물' },
54
- { name: '끈', description: 'λΌμš΄λ“œ 레이슀' },
55
- { name: 'λ’€κΏˆμΉ˜μ»΅', description: 'μ•„ν‚¬λ ˆμŠ€λ₯Ό κ°μ‹ΈλŠ” TPU' },
56
- ],
57
- },
58
- beer: {
59
- brand: 'HOP-Haru', category: '수제λ§₯μ£Ό IPA', price_tier: 'mid',
60
- parts: [
61
- { name: '병', description: 'κ°ˆμƒ‰ 유리 330ml' },
62
- { name: 'λšœκ»‘', description: '크라운 μΊ‘' },
63
- { name: 'κ±°ν’ˆ', description: '상뢀 ν•˜μ–€ ν—€λ“œ' },
64
- { name: '라벨', description: '가상 λΈŒλžœλ“œ λ ˆν„°λ§' },
65
- { name: '홉', description: 'μ“΄λ§›κ³Ό μ‹œνŠΈλŸ¬μŠ€ ν–₯' },
66
- ],
67
- },
68
- cosmetic: {
69
- brand: 'GLOW-Han', category: 'μŠ€ν‚¨μΌ€μ–΄ μ•°ν”Œ', price_tier: 'luxury',
70
- parts: [
71
- { name: 'μŠ€ν¬μ΄λ“œ', description: '유리 λ“œλ‘œνΌ' },
72
- { name: '병', description: 'κ°ˆμƒ‰ μžμ™Έμ„  차단 유리' },
73
- { name: 'μ„ΈλŸΌ', description: '점성 μžˆλŠ” 투λͺ… 앑체' },
74
- { name: 'ν–₯', description: 'ν•œλ°© ν—ˆλΈŒ λ…ΈνŠΈ' },
75
- { name: 'μΌ€μ΄μŠ€', description: 'ν•œμ§€ 질감 μ™Έκ³½ λ°•μŠ€' },
76
- ],
77
- },
78
- car: {
79
- brand: 'SPIRA-EV', category: 'μ „κΈ°μ°¨', price_tier: 'luxury',
80
- parts: [
81
- { name: 'λ°°ν„°λ¦¬νŒ©', description: '차체 λ°”λ‹₯ μΌμ²΄ν˜•' },
82
- { name: 'λͺ¨ν„°', description: 'λ“€μ–Ό νΌλ¨Έλ„ŒνŠΈ λ§ˆκ·Έλ„·' },
83
- { name: '슀크린', description: '17인치 쀑앙 ν„°μΉ˜' },
84
- { name: 'ν•Έλ“€', description: 'μš”ν¬ ν˜• μŠ€ν‹°μ–΄λ§' },
85
- { name: 'μΆ©μ „ 포트', description: '800V 급속 μΆ©μ „' },
86
- ],
87
- },
88
  };
89
 
90
  let TENSIONS = [];
91
- let PRODUCTS = [];
92
  let CHARTS = [];
93
  let PIPELINE_TIMER = null;
 
 
94
 
95
  document.addEventListener('DOMContentLoaded', init);
96
 
97
  async function init() {
98
  wireInputs();
99
- await Promise.all([loadMeta(), loadProducts(), loadTensions(), loadRules()]);
100
  }
101
 
102
  async function loadMeta() {
@@ -117,29 +68,6 @@ async function loadMeta() {
117
  } catch (e) { console.error('meta failed', e); }
118
  }
119
 
120
- async function loadProducts() {
121
- PRODUCTS = await API.products();
122
- const sel = $('#productSel'); sel.innerHTML = '';
123
- PRODUCTS.forEach(p => {
124
- const o = document.createElement('option');
125
- o.value = p.product_id;
126
- o.textContent = `${p.brand} Β· ${p.category}`;
127
- sel.appendChild(o);
128
- });
129
- updateProductMeta();
130
- }
131
-
132
- function updateProductMeta() {
133
- const pid = $('#productSel').value;
134
- const p = PRODUCTS.find(x => x.product_id === pid);
135
- if (!p) return;
136
- const atomList = (p.atoms || []).map(a => a.name).join(' Β· ');
137
- $('#productMeta').innerHTML = `
138
- <strong>${p.product_id}</strong> Β· tier: <strong>${p.price_tier || 'β€”'}</strong> Β· ${p.atom_count} functional atoms
139
- <br/><span style="color: var(--text-subtle);">${atomList}</span>
140
- `;
141
- }
142
-
143
  async function loadTensions() {
144
  TENSIONS = await API.tensions();
145
  const sel = $('#tensionSel'); sel.innerHTML = '';
@@ -160,7 +88,8 @@ function updateGreimas() {
160
  $('[data-pos="s2"]').textContent = sq.S2;
161
  $('[data-pos="ns1"]').textContent = sq.not_S1;
162
  $('[data-pos="ns2"]').textContent = sq.not_S2;
163
- $('#tensionPayoff').innerHTML = `${escapeHtml(t.description || '')}<br/><strong>payoff:</strong> ${escapeHtml(t.emotional_payoff)}`;
 
164
  }
165
 
166
  async function loadRules() {
@@ -183,145 +112,85 @@ async function loadRules() {
183
  }
184
 
185
  function wireInputs() {
186
- $('#productSel').addEventListener('change', updateProductMeta);
187
  $('#tensionSel').addEventListener('change', updateGreimas);
188
  $('#nSeeds').addEventListener('input', e => $('#nSeedsVal').textContent = e.target.value);
189
  $('#topK').addEventListener('input', e => $('#topKVal').textContent = e.target.value);
190
  $('#backendSel').addEventListener('change', syncBackendPlaceholder);
191
  $('#genBtn').addEventListener('click', runGenerate);
192
- wireModal();
193
- }
194
 
195
- /* ── Custom product modal ─────────────────────────────────── */
196
-
197
- function wireModal() {
198
- $('#newProductBtn').addEventListener('click', openModal);
199
- $('#productModal').querySelectorAll('[data-close]').forEach(el => {
200
- el.addEventListener('click', closeModal);
201
- });
202
- document.addEventListener('keydown', e => {
203
- if (e.key === 'Escape' && !$('#productModal').hidden) closeModal();
204
- });
205
- $('#npAddPart').addEventListener('click', () => addPartRow('', ''));
206
- $('#npSubmit').addEventListener('click', submitCustomProduct);
207
- $$('.preset-chip').forEach(btn => {
208
- btn.addEventListener('click', () => applyPreset(btn.dataset.preset));
209
  });
210
- // Seed with 2 empty rows
211
- addPartRow('', '');
212
- addPartRow('', '');
213
- }
214
 
215
- function openModal() {
216
- $('#productModal').hidden = false;
217
- $('#npErr').hidden = true;
218
- document.body.style.overflow = 'hidden';
219
- setTimeout(() => $('#npBrand').focus(), 50);
220
- }
221
-
222
- function closeModal() {
223
- $('#productModal').hidden = true;
224
- document.body.style.overflow = '';
225
- }
226
-
227
- function addPartRow(name, desc) {
228
- const rows = $$('#npParts .part-row');
229
- if (rows.length >= 8) return;
230
- const row = document.createElement('div');
231
- row.className = 'part-row';
232
- row.innerHTML = `
233
- <input type="text" class="part-name" placeholder="λΆ€ν’ˆ 이름" value="${escapeAttr(name)}" maxlength="30" />
234
- <input type="text" class="part-desc" placeholder="ν•œ 쀄 μ„€λͺ… (선택)" value="${escapeAttr(desc)}" maxlength="80" />
235
- <button type="button" class="part-del" title="μ‚­μ œ">βœ•</button>
236
- `;
237
- row.querySelector('.part-del').addEventListener('click', () => {
238
- if ($$('#npParts .part-row').length > 2) row.remove();
239
- updatePartCount();
240
  });
241
- $('#npParts').appendChild(row);
242
- updatePartCount();
243
  }
244
 
245
- function updatePartCount() {
246
- const n = $$('#npParts .part-row').length;
247
- $('#npPartCount').textContent = `(${n}/8)`;
 
 
 
 
 
248
  }
249
 
250
- function applyPreset(key) {
251
- const p = PRESETS[key];
252
- if (!p) return;
253
- $('#npBrand').value = p.brand;
254
- $('#npCategory').value = p.category;
255
- $('#npTier').value = p.price_tier;
256
- $('#npParts').innerHTML = '';
257
- p.parts.forEach(pt => addPartRow(pt.name, pt.description));
258
- $('#npErr').hidden = true;
259
  }
260
 
261
- async function submitCustomProduct() {
262
- const brand = $('#npBrand').value.trim();
263
- const category = $('#npCategory').value.trim();
264
- const tier = $('#npTier').value;
265
- const parts = $$('#npParts .part-row').map(row => ({
266
- name: row.querySelector('.part-name').value.trim(),
267
- description: row.querySelector('.part-desc').value.trim(),
268
- })).filter(p => p.name);
269
-
270
- if (!brand) return showModalErr('λΈŒλžœλ“œλ₯Ό μž…λ ₯ν•˜μ„Έμš”. (가상 λΈŒλžœλ“œλ§Œ)');
271
- if (!category) return showModalErr('μΉ΄ν…Œκ³ λ¦¬λ₯Ό μž…λ ₯ν•˜μ„Έμš”.');
272
- if (parts.length < 2) return showModalErr('λΆ€ν’ˆμ„ 2개 이상 μž…λ ₯ν•˜μ„Έμš”.');
273
 
274
  const backend = $('#backendSel').value === 'none' ? 'fireworks' : $('#backendSel').value;
275
-
276
- $('#npSubmit').disabled = true;
277
- $('#npSubmit .btn-spinner').hidden = false;
278
- $('#npSubmit .btn-label').textContent = 'LLM ν™•μž₯ 쀑… (30~60s)';
279
- $('#npErr').hidden = true;
280
 
281
  try {
282
- const result = await API.expand({ brand, category, price_tier: tier, parts, backend });
283
- PRODUCTS.push(result);
284
- const sel = $('#productSel');
285
- const opt = document.createElement('option');
286
- opt.value = result.product_id;
287
- opt.textContent = `${result.brand} Β· ${result.category} [custom]`;
288
- sel.appendChild(opt);
289
- sel.value = result.product_id;
290
- updateProductMeta();
291
- closeModal();
292
- } catch (err) {
293
- const msg = err && err.message ? err.message : String(err);
294
- showModalErr(`❌ ν™•μž₯ μ‹€νŒ¨: ${msg}`);
295
- } finally {
296
- $('#npSubmit').disabled = false;
297
- $('#npSubmit .btn-spinner').hidden = true;
298
- $('#npSubmit .btn-label').textContent = 'β†’ LLM으둜 ν™•μž₯';
299
  }
300
  }
301
 
302
- function showModalErr(msg) {
303
- const box = $('#npErr');
304
- box.hidden = false;
305
- box.textContent = msg;
306
- }
307
-
308
- function escapeAttr(s) {
309
- return String(s || '').replace(/"/g, '&quot;');
310
- }
311
 
312
- function syncBackendPlaceholder() {
313
- const v = $('#backendSel').value;
314
- const placeholders = {
315
- fireworks: 'accounts/fireworks/models/kimi-k2p6',
316
- hf: 'Qwen/Qwen2.5-72B-Instruct',
317
- none: '(not used)',
318
- };
319
- $('#modelIn').placeholder = placeholders[v] || '';
320
- }
321
 
322
- async function runGenerate() {
323
  const body = {
324
- product_id: $('#productSel').value,
325
  tension_id: $('#tensionSel').value,
326
  persona: $('#personaIn').value.trim() || '일반 μ†ŒλΉ„μž',
327
  duration: parseInt($$('input[name="duration"]').find(r => r.checked).value, 10),
@@ -333,7 +202,6 @@ async function runGenerate() {
333
  };
334
 
335
  setRunning(true);
336
- $('#errBox').hidden = true;
337
  $('#emptyState').style.display = 'none';
338
  animatePipeline();
339
 
@@ -346,8 +214,7 @@ async function runGenerate() {
346
  const msg = err && err.message ? err.message : String(err);
347
  $('#errBox').hidden = false;
348
  $('#errBox').textContent = `❌ ${msg}`;
349
- if (!$('#summaryBar').hidden) { /* keep previous results visible */ }
350
- else $('#emptyState').style.display = '';
351
  console.error(err);
352
  } finally {
353
  setRunning(false);
@@ -393,7 +260,9 @@ function renderResults(data) {
393
  $('#sumCount').textContent = data.summary.count;
394
  $('#sumAvg').textContent = (data.summary.avg_final || 0).toFixed(3);
395
  $('#sumTop').textContent = (data.summary.top_final || 0).toFixed(3);
396
- $('#sumBackend').textContent = data.summary.backend + (data.summary.model ? ' Β· ' + data.summary.model.split('/').pop() : '');
 
 
397
 
398
  CHARTS.forEach(c => { try { c.destroy(); } catch(_){} });
399
  CHARTS = [];
@@ -470,14 +339,14 @@ function renderSeedCard(s, rank) {
470
  <div class="scores-section">
471
  <div class="radar-wrap"><canvas></canvas></div>
472
  <div class="scores-table">
473
- ${scoreItem('Novelty', score.novelty)}
474
- ${scoreItem('Utility', score.utility)}
475
- ${scoreItem('Affect', score.affect)}
476
- ${scoreItem('Humor/Pathos', score.humor_or_pathos)}
477
- ${scoreItem('Surprise', score.surprise)}
478
- ${scoreItem('Risk', score.risk, { risk: true })}
479
- ${scoreItem('Gating', (score.gating_passed || 0) + '/5', { raw: true })}
480
- ${scoreItem('Final', score.final, { bold: true })}
481
  </div>
482
  </div>
483
  `;
@@ -506,10 +375,10 @@ function renderSeedCard(s, rank) {
506
  scales: {
507
  r: {
508
  suggestedMin: 0, suggestedMax: 1,
509
- ticks: { display: false, stepSize: 0.25 },
510
- grid: { color: '#e6e9f0' },
511
- angleLines: { color: '#e6e9f0' },
512
- pointLabels:{ font: { size: 10, weight: '600' }, color: '#475569' },
513
  },
514
  },
515
  },
@@ -564,8 +433,8 @@ function renderTicks(duration) {
564
  function scoreItem(label, val, opts = {}) {
565
  const v = opts.raw ? val : (typeof val === 'number' ? val.toFixed(3) : val);
566
  const style = [];
567
- if (opts.risk && Number(val) > 0) style.push('color: var(--danger);');
568
- if (opts.bold) style.push('color: var(--primary); font-size: 15px;');
569
  return `
570
  <div class="score-item">
571
  <span class="score-item-label">${label}</span>
 
16
  });
17
 
18
  const API = {
19
+ meta: () => fetch('/api/meta').then(r => r.json()),
20
+ tensions: () => fetch('/api/tensions').then(r => r.json()),
21
+ rules: () => fetch('/api/rules').then(r => r.json()),
22
+ generate: (body) => _post('/api/generate', body),
23
+ fromPrompt: (body) => _post('/api/products/from_prompt', body),
 
24
  };
25
 
26
+ /* Preset prompt strings β€” one-liners for the textarea. */
27
+ const PROMPT_PRESETS = {
28
+ watch: 'μ•„λ‚ λ‘œκ·Έ 손λͺ©μ‹œκ³„ AETHER-A Β· μš©λ‘, 초침, μ‚¬νŒŒμ΄μ–΄ ν¬λ¦¬μŠ€νƒˆ, 방수, κ°€μ£½ 슀트랩',
29
+ coffee: '수제 λ‘œμŠ€νŒ… 원두 컀피 NOVA-Bean Β· 원두, 그라인더, ν•„ν„°, μΆ”μΆœμˆ˜, 세라믹 μž”',
30
+ phone: '슀마트폰 AETHER-Phone Β· OLED λ””μŠ€ν”Œλ ˆμ΄, νŠΈλ¦¬ν”Œ 카메라, 배터리, AI μΉ©μ…‹, 진동λͺ¨ν„°',
31
+ sneaker: 'λŸ¬λ‹ν™” PULSE-Run Β· λ―Έλ“œμ†” 폼, 아웃솔 고무, λ‹ˆνŠΈ 어퍼, 레이슀, TPU λ’€κΏˆμΉ˜μ»΅',
32
+ beer: '수제 IPA λ§₯μ£Ό HOP-Haru Β· κ°ˆμƒ‰ 병, 크라운 μΊ‘, ν—€λ“œ κ±°ν’ˆ, 라벨, 홉',
33
+ cosmetic: 'μŠ€ν‚¨μΌ€μ–΄ μ•°ν”Œ GLOW-Han Β· 유리 μŠ€ν¬μ΄λ“œ, κ°ˆμƒ‰ μžμ™Έμ„  병, μ„ΈλŸΌ, ν•œλ°© ν—ˆλΈŒ ν–₯, ν•œμ§€ λ°•μŠ€',
34
+ car: 'μ „κΈ°μ°¨ SPIRA-EV Β· λ°”λ‹₯ λ°°ν„°λ¦¬νŒ©, λ“€μ–Ό λ§ˆκ·Έλ„· λͺ¨ν„°, 17인치 슀크린, μš”ν¬ ν•Έλ“€, 800V μΆ©μ „ν¬νŠΈ',
35
+ laundry: 'O2O 프리미엄 세탁 μ„œλΉ„μŠ€ MOON-Fold Β· μˆ˜κ±°κΈ°μ‚¬, μ•± μ‹€μ‹œκ°„ μ•Œλ¦Ό, 비닐 컀버, ν–₯κΈ° 캑슐, 옷걸이',
36
+ edu: '온라인 μ½”λ”© λΆ€νŠΈμΊ ν”„ CODE-Muze Β· μ˜μƒ κ°•μ˜, 과제 ν”Όλ“œλ°±, 1:1 μ½”λ“œλ¦¬λ·°, μŠ¬λž™ λ°©, 수료증',
37
+ banking: 'λͺ¨λ°”일 은행 μ•± HANA-Flow Β· ν™ˆ ν™”λ©΄ μœ„μ ―, μ†‘κΈˆ λ²„νŠΌ, 생체 인증, μ•Œλ¦Ό λ°°λ„ˆ, 챗봇',
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  };
39
 
40
  let TENSIONS = [];
 
41
  let CHARTS = [];
42
  let PIPELINE_TIMER = null;
43
+ let CURRENT_PRODUCT = null; // {product_id, brand, category, price_tier, atom_count, atoms}
44
+ let LAST_PROMPT = null; // string for which CURRENT_PRODUCT was expanded
45
 
46
  document.addEventListener('DOMContentLoaded', init);
47
 
48
  async function init() {
49
  wireInputs();
50
+ await Promise.all([loadMeta(), loadTensions(), loadRules()]);
51
  }
52
 
53
  async function loadMeta() {
 
68
  } catch (e) { console.error('meta failed', e); }
69
  }
70
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
  async function loadTensions() {
72
  TENSIONS = await API.tensions();
73
  const sel = $('#tensionSel'); sel.innerHTML = '';
 
88
  $('[data-pos="s2"]').textContent = sq.S2;
89
  $('[data-pos="ns1"]').textContent = sq.not_S1;
90
  $('[data-pos="ns2"]').textContent = sq.not_S2;
91
+ $('#tensionPayoff').innerHTML =
92
+ `${escapeHtml(t.description || '')}<br/><strong>payoff:</strong> ${escapeHtml(t.emotional_payoff)}`;
93
  }
94
 
95
  async function loadRules() {
 
112
  }
113
 
114
  function wireInputs() {
 
115
  $('#tensionSel').addEventListener('change', updateGreimas);
116
  $('#nSeeds').addEventListener('input', e => $('#nSeedsVal').textContent = e.target.value);
117
  $('#topK').addEventListener('input', e => $('#topKVal').textContent = e.target.value);
118
  $('#backendSel').addEventListener('change', syncBackendPlaceholder);
119
  $('#genBtn').addEventListener('click', runGenerate);
 
 
120
 
121
+ $('#productPrompt').addEventListener('input', () => {
122
+ if ($('#productPrompt').value.trim() !== LAST_PROMPT) {
123
+ setStatus('idle', 'Generateλ₯Ό λˆ„λ₯΄λ©΄ Kimi-K2P6κ°€ genome을 ν™•μž₯ν•©λ‹ˆλ‹€.');
124
+ }
 
 
 
 
 
 
 
 
 
 
125
  });
 
 
 
 
126
 
127
+ $$('.preset-chip-inline').forEach(btn => {
128
+ btn.addEventListener('click', () => {
129
+ const key = btn.dataset.preset;
130
+ const val = PROMPT_PRESETS[key];
131
+ if (val) {
132
+ $('#productPrompt').value = val;
133
+ $('#productPrompt').dispatchEvent(new Event('input'));
134
+ $('#productPrompt').focus();
135
+ }
136
+ });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
137
  });
 
 
138
  }
139
 
140
+ function syncBackendPlaceholder() {
141
+ const v = $('#backendSel').value;
142
+ const placeholders = {
143
+ fireworks: 'accounts/fireworks/models/kimi-k2p6',
144
+ hf: 'Qwen/Qwen2.5-72B-Instruct',
145
+ none: '(not used)',
146
+ };
147
+ $('#modelIn').placeholder = placeholders[v] || '';
148
  }
149
 
150
+ function setStatus(kind, html) {
151
+ $('#productStatus .status-dot').className = `status-dot status-${kind}`;
152
+ $('#productStatus .status-text').innerHTML = html;
 
 
 
 
 
 
153
  }
154
 
155
+ /** Expand the prompt into a ProductGenome via LLM. Cached by (prompt text) locally. */
156
+ async function ensureProductReady() {
157
+ const text = $('#productPrompt').value.trim();
158
+ if (!text) throw new Error('μ œν’ˆ λ˜λŠ” μ„œλΉ„μŠ€ μ„€λͺ…을 μž…λ ₯ν•˜μ„Έμš”.');
159
+ if (CURRENT_PRODUCT && LAST_PROMPT === text) return CURRENT_PRODUCT;
 
 
 
 
 
 
 
160
 
161
  const backend = $('#backendSel').value === 'none' ? 'fireworks' : $('#backendSel').value;
162
+ setStatus('loading', '🧬 Kimi-K2P6κ°€ genome을 ν™•μž₯ 쀑… (30~60초)');
 
 
 
 
163
 
164
  try {
165
+ const product = await API.fromPrompt({ prompt: text, backend });
166
+ CURRENT_PRODUCT = product;
167
+ LAST_PROMPT = text;
168
+ const atoms = (product.atoms || []).map(a => a.name).join(' Β· ');
169
+ setStatus('ready',
170
+ `βœ“ <strong>${escapeHtml(product.brand)}</strong> Β· ${escapeHtml(product.category)} Β· ${product.atom_count} atoms` +
171
+ (atoms ? `<br/><span style="color: var(--text-subtle); font-size: 11px;">${escapeHtml(atoms)}</span>` : '')
172
+ );
173
+ return product;
174
+ } catch (e) {
175
+ setStatus('error', `❌ ν™•μž₯ μ‹€νŒ¨: ${escapeHtml(e.message || String(e))}`);
176
+ throw e;
 
 
 
 
 
177
  }
178
  }
179
 
180
+ async function runGenerate() {
181
+ $('#errBox').hidden = true;
 
 
 
 
 
 
 
182
 
183
+ let product;
184
+ try {
185
+ product = await ensureProductReady();
186
+ } catch (err) {
187
+ $('#errBox').hidden = false;
188
+ $('#errBox').textContent = `❌ ${err.message || err}`;
189
+ return;
190
+ }
 
191
 
 
192
  const body = {
193
+ product_id: product.product_id,
194
  tension_id: $('#tensionSel').value,
195
  persona: $('#personaIn').value.trim() || '일반 μ†ŒλΉ„μž',
196
  duration: parseInt($$('input[name="duration"]').find(r => r.checked).value, 10),
 
202
  };
203
 
204
  setRunning(true);
 
205
  $('#emptyState').style.display = 'none';
206
  animatePipeline();
207
 
 
214
  const msg = err && err.message ? err.message : String(err);
215
  $('#errBox').hidden = false;
216
  $('#errBox').textContent = `❌ ${msg}`;
217
+ if ($('#summaryBar').hidden) $('#emptyState').style.display = '';
 
218
  console.error(err);
219
  } finally {
220
  setRunning(false);
 
260
  $('#sumCount').textContent = data.summary.count;
261
  $('#sumAvg').textContent = (data.summary.avg_final || 0).toFixed(3);
262
  $('#sumTop').textContent = (data.summary.top_final || 0).toFixed(3);
263
+ $('#sumBackend').textContent =
264
+ data.summary.backend +
265
+ (data.summary.model ? ' Β· ' + data.summary.model.split('/').pop() : '');
266
 
267
  CHARTS.forEach(c => { try { c.destroy(); } catch(_){} });
268
  CHARTS = [];
 
339
  <div class="scores-section">
340
  <div class="radar-wrap"><canvas></canvas></div>
341
  <div class="scores-table">
342
+ ${scoreItem('Novelty', score.novelty)}
343
+ ${scoreItem('Utility', score.utility)}
344
+ ${scoreItem('Affect', score.affect)}
345
+ ${scoreItem('Humor/Pathos', score.humor_or_pathos)}
346
+ ${scoreItem('Surprise', score.surprise)}
347
+ ${scoreItem('Risk', score.risk, { risk: true })}
348
+ ${scoreItem('Gating', (score.gating_passed || 0) + '/5', { raw: true })}
349
+ ${scoreItem('Final', score.final, { bold: true })}
350
  </div>
351
  </div>
352
  `;
 
375
  scales: {
376
  r: {
377
  suggestedMin: 0, suggestedMax: 1,
378
+ ticks: { display: false, stepSize: 0.25 },
379
+ grid: { color: '#e6e9f0' },
380
+ angleLines: { color: '#e6e9f0' },
381
+ pointLabels: { font: { size: 10, weight: '600' }, color: '#475569' },
382
  },
383
  },
384
  },
 
433
  function scoreItem(label, val, opts = {}) {
434
  const v = opts.raw ? val : (typeof val === 'number' ? val.toFixed(3) : val);
435
  const style = [];
436
+ if (opts.risk && Number(val) > 0) style.push('color: var(--danger);');
437
+ if (opts.bold) style.push('color: var(--primary); font-size: 15px;');
438
  return `
439
  <div class="score-item">
440
  <span class="score-item-label">${label}</span>
static/index.html CHANGED
@@ -37,15 +37,27 @@
37
  <aside class="sidebar">
38
  <div class="card">
39
  <div class="card-header">
40
- <h2><span class="step">1</span>Product Genome</h2>
41
  </div>
42
  <div class="field">
43
- <label for="productSel">Product</label>
44
- <div class="product-row">
45
- <select id="productSel"></select>
46
- <button type="button" id="newProductBtn" class="btn-mini" title="μƒˆ μ œν’ˆ μΆ”κ°€">οΌ‹</button>
 
 
 
 
 
 
 
 
 
 
 
 
 
47
  </div>
48
- <div id="productMeta" class="meta"></div>
49
  </div>
50
  </div>
51
 
@@ -187,70 +199,6 @@
187
  </div>
188
  </footer>
189
 
190
- <!-- Custom product modal -->
191
- <div id="productModal" class="modal" hidden>
192
- <div class="modal-backdrop" data-close></div>
193
- <div class="modal-panel">
194
- <header class="modal-head">
195
- <h3>🧬 μƒˆ μ œν’ˆ μΆ”κ°€ <small>β€” LLM이 잠재λ ₯Β·SCAMPER λ³€μ£ΌκΉŒμ§€ ν™•μž₯ν•©λ‹ˆλ‹€</small></h3>
196
- <button type="button" class="modal-x" data-close aria-label="λ‹«κΈ°">βœ•</button>
197
- </header>
198
-
199
- <div class="modal-body">
200
- <div class="form-row">
201
- <div class="field">
202
- <label for="npBrand">λΈŒλžœλ“œ (가상 λΈŒλžœλ“œλ§Œ, μ‹€μ‘΄ λΈŒλžœλ“œ κΈˆμ§€)</label>
203
- <input type="text" id="npBrand" placeholder="예: NOVA-Bean, AETHER-Phone, HAN-Cha" maxlength="40" />
204
- </div>
205
- <div class="field">
206
- <label for="npTier">κ°€κ²©λŒ€</label>
207
- <select id="npTier">
208
- <option value="budget">budget</option>
209
- <option value="mid">mid</option>
210
- <option value="premium" selected>premium</option>
211
- <option value="luxury">luxury</option>
212
- </select>
213
- </div>
214
- </div>
215
- <div class="field">
216
- <label for="npCategory">μΉ΄ν…Œκ³ λ¦¬ (ν•œκ΅­μ–΄)</label>
217
- <input type="text" id="npCategory" placeholder="예: 원두 컀피, νƒœλΈ”λ¦Ώ, λŸ¬λ‹ν™”, λ§₯μ£Ό, ν™”μž₯ν’ˆ" maxlength="60" />
218
- </div>
219
-
220
- <div class="field">
221
- <label>
222
- λΆ€ν’ˆ/ꡬ성 μš”μ†Œ <span id="npPartCount">(2/8)</span>
223
- <small>Β· 2~8개 Β· 이름 + ν•œ 쀄 μ„€λͺ…</small>
224
- </label>
225
- <div id="npParts" class="parts-list"></div>
226
- <button type="button" id="npAddPart" class="btn-ghost">οΌ‹ λΆ€ν’ˆ μΆ”κ°€</button>
227
- </div>
228
-
229
- <details class="preset-details">
230
- <summary>πŸ’‘ λΉ λ₯Έ 프리셋 β€” ν•œ λ²ˆμ— μ±„μš°κΈ°</summary>
231
- <div class="preset-grid">
232
- <button type="button" class="preset-chip" data-preset="coffee">β˜• 원두 컀피 NOVA-Bean</button>
233
- <button type="button" class="preset-chip" data-preset="phone">πŸ“± 슀마트폰 AETHER-Phone</button>
234
- <button type="button" class="preset-chip" data-preset="sneaker">πŸ‘Ÿ λŸ¬λ‹ν™” PULSE-Run</button>
235
- <button type="button" class="preset-chip" data-preset="beer">🍺 수제λ§₯μ£Ό HOP-Haru</button>
236
- <button type="button" class="preset-chip" data-preset="cosmetic">πŸ’„ ν™”μž₯ν’ˆ GLOW-Han</button>
237
- <button type="button" class="preset-chip" data-preset="car">πŸš— μ „κΈ°μ°¨ SPIRA-EV</button>
238
- </div>
239
- </details>
240
-
241
- <div id="npErr" class="err" hidden></div>
242
- </div>
243
-
244
- <footer class="modal-foot">
245
- <button type="button" class="btn-ghost" data-close>μ·¨μ†Œ</button>
246
- <button type="button" id="npSubmit" class="btn-primary">
247
- <span class="btn-spinner" hidden></span>
248
- <span class="btn-label">β†’ LLM으둜 ν™•μž₯</span>
249
- </button>
250
- </footer>
251
- </div>
252
- </div>
253
-
254
  <script src="/static/app.js"></script>
255
  </body>
256
  </html>
 
37
  <aside class="sidebar">
38
  <div class="card">
39
  <div class="card-header">
40
+ <h2><span class="step">1</span>Product / Service <small>Β· 자유 ν”„λ‘¬ν”„νŠΈ</small></h2>
41
  </div>
42
  <div class="field">
43
+ <label for="productPrompt">μ œν’ˆ λ˜λŠ” μ„œλΉ„μŠ€λ₯Ό 자유둭게 μž…λ ₯ν•˜μ„Έμš”</label>
44
+ <textarea id="productPrompt" rows="3" placeholder="예: 수제λ§₯μ£Ό HOP-Haru Β· 병, 크라운 μΊ‘, ν—€λ“œ κ±°ν’ˆ, 라벨, 홉&#10;예: μ „κΈ°μ°¨ SPIRA-EV Β· λ°°ν„°λ¦¬νŒ©, λ“€μ–Ό λͺ¨ν„°, 17인치 슀크린, μš”ν¬ ν•Έλ“€&#10;예: O2O 세탁 μ„œλΉ„μŠ€ MOON-Fold Β· μˆ˜κ±°κΈ°μ‚¬, μ•± μ•Œλ¦Ό, 비닐 컀버, ν–₯κΈ° 캑슐&#10;예: 온라인 μ½”λ”© κ°•μ˜ CODE-Muze Β· μ˜μƒ, 과제, μ½”λ“œλ¦¬λ·°, μŠ¬λž™ λ°©, 수료증"></textarea>
45
+ <div class="product-presets">
46
+ <button type="button" class="preset-chip-inline" data-preset="watch">⌚ μ‹œκ³„</button>
47
+ <button type="button" class="preset-chip-inline" data-preset="coffee">β˜• 컀피</button>
48
+ <button type="button" class="preset-chip-inline" data-preset="phone">πŸ“± 폰</button>
49
+ <button type="button" class="preset-chip-inline" data-preset="sneaker">πŸ‘Ÿ λŸ¬λ‹ν™”</button>
50
+ <button type="button" class="preset-chip-inline" data-preset="beer">🍺 λ§₯μ£Ό</button>
51
+ <button type="button" class="preset-chip-inline" data-preset="cosmetic">πŸ’„ ν™”μž₯ν’ˆ</button>
52
+ <button type="button" class="preset-chip-inline" data-preset="car">πŸš— μ „κΈ°μ°¨</button>
53
+ <button type="button" class="preset-chip-inline" data-preset="laundry">🧺 μ„Ένƒμ„œλΉ„μŠ€</button>
54
+ <button type="button" class="preset-chip-inline" data-preset="edu">πŸŽ“ μ˜¨λΌμΈκ°•μ˜</button>
55
+ <button type="button" class="preset-chip-inline" data-preset="banking">🏦 은행앱</button>
56
+ </div>
57
+ <div id="productStatus" class="product-status">
58
+ <span class="status-dot status-idle"></span>
59
+ <span class="status-text">Generateλ₯Ό λˆ„λ₯΄λ©΄ Kimi-K2P6κ°€ genome을 ν™•μž₯ν•©λ‹ˆλ‹€.</span>
60
  </div>
 
61
  </div>
62
  </div>
63
 
 
199
  </div>
200
  </footer>
201
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
202
  <script src="/static/app.js"></script>
203
  </body>
204
  </html>
static/styles.css CHANGED
@@ -538,25 +538,61 @@ input[type="range"] { padding: 0; width: 100%; accent-color: var(--primary); }
538
  font-variant-numeric: tabular-nums;
539
  }
540
 
541
- /* ── PRODUCT ROW + MINI BTN ─────────────────────────────────── */
542
- .product-row {
543
- display: grid; grid-template-columns: 1fr 40px; gap: 8px;
 
 
 
 
 
 
 
544
  }
545
- .btn-mini {
546
- width: 40px; height: 38px;
 
 
 
 
547
  border: 1px solid var(--border);
548
- border-radius: var(--radius-sm);
549
- background: white; cursor: pointer;
550
- font-size: 18px; font-weight: 700;
551
- color: var(--primary);
 
552
  transition: all 0.15s;
553
- display: grid; place-items: center;
554
  font-family: inherit;
555
  }
556
- .btn-mini:hover {
557
  border-color: var(--primary);
558
  background: var(--primary-light);
 
 
559
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
560
 
561
  /* ── MODAL ──────────────────────────────────────────────────── */
562
  .modal {
 
538
  font-variant-numeric: tabular-nums;
539
  }
540
 
541
+ /* ── PRODUCT PROMPT ─────────────────────────────────────────── */
542
+ #productPrompt {
543
+ font-family: inherit;
544
+ min-height: 86px;
545
+ line-height: 1.55;
546
+ }
547
+ #productPrompt::placeholder {
548
+ color: var(--text-subtle);
549
+ font-size: 12.5px;
550
+ line-height: 1.6;
551
  }
552
+ .product-presets {
553
+ display: flex; flex-wrap: wrap; gap: 6px;
554
+ margin-top: 10px;
555
+ }
556
+ .preset-chip-inline {
557
+ padding: 6px 10px;
558
  border: 1px solid var(--border);
559
+ border-radius: 999px;
560
+ background: white;
561
+ color: var(--text);
562
+ font-size: 12px; font-weight: 600;
563
+ cursor: pointer;
564
  transition: all 0.15s;
 
565
  font-family: inherit;
566
  }
567
+ .preset-chip-inline:hover {
568
  border-color: var(--primary);
569
  background: var(--primary-light);
570
+ color: var(--primary);
571
+ transform: translateY(-1px);
572
  }
573
+ .product-status {
574
+ display: flex; align-items: center; gap: 8px;
575
+ margin-top: 12px;
576
+ padding: 9px 12px;
577
+ background: var(--surface);
578
+ border: 1px solid var(--border);
579
+ border-radius: var(--radius-sm);
580
+ font-size: 12px;
581
+ }
582
+ .status-dot {
583
+ width: 8px; height: 8px; border-radius: 50%;
584
+ flex-shrink: 0;
585
+ }
586
+ .status-dot.status-idle { background: var(--text-subtle); }
587
+ .status-dot.status-loading { background: var(--warn); animation: pulse-dot 1.2s ease-in-out infinite; }
588
+ .status-dot.status-ready { background: var(--success); }
589
+ .status-dot.status-error { background: var(--danger); }
590
+ @keyframes pulse-dot {
591
+ 0%, 100% { box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.45); }
592
+ 50% { box-shadow: 0 0 0 6px rgba(245, 158, 11, 0); }
593
+ }
594
+ .status-text { color: var(--text); line-height: 1.45; }
595
+ .status-text strong { color: var(--primary); }
596
 
597
  /* ── MODAL ──────────────────────────────────────────────────── */
598
  .modal {