claude-controller 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/README.md +2 -2
  2. package/bin/autoloop.sh +382 -0
  3. package/bin/ctl +327 -5
  4. package/bin/native-app.py +5 -2
  5. package/bin/watchdog.sh +357 -0
  6. package/cognitive/__init__.py +14 -0
  7. package/cognitive/__pycache__/__init__.cpython-314.pyc +0 -0
  8. package/cognitive/__pycache__/dispatcher.cpython-314.pyc +0 -0
  9. package/cognitive/__pycache__/evaluator.cpython-314.pyc +0 -0
  10. package/cognitive/__pycache__/goal_engine.cpython-314.pyc +0 -0
  11. package/cognitive/__pycache__/learning.cpython-314.pyc +0 -0
  12. package/cognitive/__pycache__/orchestrator.cpython-314.pyc +0 -0
  13. package/cognitive/__pycache__/planner.cpython-314.pyc +0 -0
  14. package/cognitive/dispatcher.py +192 -0
  15. package/cognitive/evaluator.py +289 -0
  16. package/cognitive/goal_engine.py +232 -0
  17. package/cognitive/learning.py +189 -0
  18. package/cognitive/orchestrator.py +303 -0
  19. package/cognitive/planner.py +207 -0
  20. package/cognitive/prompts/analyst.md +31 -0
  21. package/cognitive/prompts/coder.md +22 -0
  22. package/cognitive/prompts/reviewer.md +33 -0
  23. package/cognitive/prompts/tester.md +21 -0
  24. package/cognitive/prompts/writer.md +25 -0
  25. package/config.sh +6 -1
  26. package/dag/__init__.py +5 -0
  27. package/dag/__pycache__/__init__.cpython-314.pyc +0 -0
  28. package/dag/__pycache__/graph.cpython-314.pyc +0 -0
  29. package/dag/graph.py +222 -0
  30. package/lib/jobs.sh +12 -1
  31. package/package.json +5 -1
  32. package/postinstall.sh +1 -1
  33. package/service/controller.sh +43 -11
  34. package/web/audit.py +122 -0
  35. package/web/checkpoint.py +80 -0
  36. package/web/config.py +2 -5
  37. package/web/handler.py +464 -26
  38. package/web/handler_fs.py +15 -14
  39. package/web/handler_goals.py +203 -0
  40. package/web/handler_jobs.py +165 -42
  41. package/web/handler_memory.py +203 -0
  42. package/web/jobs.py +576 -12
  43. package/web/personas.py +419 -0
  44. package/web/pipeline.py +682 -50
  45. package/web/presets.py +506 -0
  46. package/web/projects.py +58 -4
  47. package/web/static/api.js +90 -3
  48. package/web/static/app.js +8 -0
  49. package/web/static/base.css +51 -12
  50. package/web/static/context.js +14 -4
  51. package/web/static/form.css +3 -2
  52. package/web/static/goals.css +363 -0
  53. package/web/static/goals.js +300 -0
  54. package/web/static/i18n.js +288 -0
  55. package/web/static/index.html +142 -6
  56. package/web/static/jobs.css +951 -4
  57. package/web/static/jobs.js +890 -54
  58. package/web/static/memoryview.js +117 -0
  59. package/web/static/personas.js +228 -0
  60. package/web/static/pipeline.css +308 -1
  61. package/web/static/pipelines.js +249 -14
  62. package/web/static/presets.js +244 -0
  63. package/web/static/send.js +26 -4
  64. package/web/static/settings-style.css +34 -3
  65. package/web/static/settings.js +37 -1
  66. package/web/static/stream.js +242 -19
  67. package/web/static/utils.js +54 -2
  68. package/web/webhook.py +210 -0
@@ -6,6 +6,8 @@
6
6
  let _pipelines = [];
7
7
  let _pipelinePollTimer = null;
8
8
  let _pipelineCountdownTimer = null;
9
+ const _POLL_FAST = 2000; // job 실행 중
10
+ const _POLL_NORMAL = 5000; // 대기 중
9
11
 
10
12
  async function fetchPipelines() {
11
13
  try {
@@ -13,17 +15,27 @@ async function fetchPipelines() {
13
15
  renderPipelines();
14
16
 
15
17
  const hasActive = _pipelines.some(p => p.status === 'active');
16
- if (hasActive && !_pipelinePollTimer) {
17
- _pipelinePollTimer = setInterval(_pipelinePollTick, 5000);
18
- } else if (!hasActive && _pipelinePollTimer) {
18
+ const hasRunning = _pipelines.some(p => p.status === 'active' && p.job_id);
19
+ const desiredInterval = hasRunning ? _POLL_FAST : _POLL_NORMAL;
20
+
21
+ if (hasActive) {
22
+ // 간격이 바뀌었으면 타이머 재설정
23
+ if (_pipelinePollTimer && _pipelinePollTimer._interval !== desiredInterval) {
24
+ clearInterval(_pipelinePollTimer);
25
+ _pipelinePollTimer = null;
26
+ }
27
+ if (!_pipelinePollTimer) {
28
+ _pipelinePollTimer = setInterval(_pipelinePollTick, desiredInterval);
29
+ _pipelinePollTimer._interval = desiredInterval;
30
+ }
31
+ } else if (_pipelinePollTimer) {
19
32
  clearInterval(_pipelinePollTimer);
20
33
  _pipelinePollTimer = null;
21
34
  }
22
35
 
23
- const hasTimer = _pipelines.some(p => p.status === 'active');
24
- if (hasTimer && !_pipelineCountdownTimer) {
36
+ if (hasActive && !_pipelineCountdownTimer) {
25
37
  _pipelineCountdownTimer = setInterval(_updateCountdowns, 1000);
26
- } else if (!hasTimer && _pipelineCountdownTimer) {
38
+ } else if (!hasActive && _pipelineCountdownTimer) {
27
39
  clearInterval(_pipelineCountdownTimer);
28
40
  _pipelineCountdownTimer = null;
29
41
  }
@@ -31,12 +43,16 @@ async function fetchPipelines() {
31
43
  }
32
44
 
33
45
  async function _pipelinePollTick() {
34
- const hasRunning = _pipelines.some(p => p.status === 'active' && p.job_id);
35
- if (hasRunning) {
46
+ const hasActive = _pipelines.some(p => p.status === 'active');
47
+ if (hasActive) {
36
48
  try {
37
- await apiFetch('/api/pipelines/tick-all', {
49
+ const results = await apiFetch('/api/pipelines/tick-all', {
38
50
  method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}',
39
51
  });
52
+ // 완료/에러/디스패치 감지 시 jobs 목록도 즉시 갱신
53
+ if (Array.isArray(results) && results.some(r => r.result && (r.result.action === 'completed' || r.result.action === 'error' || r.result.action === 'dispatched'))) {
54
+ fetchJobs();
55
+ }
40
56
  } catch { /* silent */ }
41
57
  }
42
58
  fetchPipelines();
@@ -56,14 +72,23 @@ function _updateCountdowns() {
56
72
 
57
73
  if (!p.next_run) {
58
74
  el.textContent = _formatTimer(0);
75
+ // next_run 없고 job도 없으면 즉시 dispatch (프리셋 생성 직후 등)
76
+ if (!p._dispatching && !p.job_id) {
77
+ p._dispatching = true;
78
+ apiFetch(`/api/pipelines/${encodeURIComponent(p.id)}/run`, {
79
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}',
80
+ }).then(() => { fetchPipelines(); fetchJobs(); setTimeout(fetchJobs, 1000); })
81
+ .catch(() => {})
82
+ .finally(() => { p._dispatching = false; });
83
+ }
59
84
  continue;
60
85
  }
61
86
 
62
87
  const remaining = Math.max(0, Math.floor((new Date(p.next_run).getTime() - Date.now()) / 1000));
63
88
  el.textContent = _formatTimer(remaining);
64
89
 
65
- // 타이머 0 → 즉시 dispatch + 로컬에서 next_run 갱신
66
- if (remaining <= 0 && !p._dispatching) {
90
+ // 타이머 0 → 이미 job_id가 있으면 skip (이중 발사 방지)
91
+ if (remaining <= 0 && !p._dispatching && !p.job_id) {
67
92
  p._dispatching = true;
68
93
  // 로컬에서 즉시 next_run 갱신 → 타이머 끊김 방지
69
94
  if (p.interval_sec) {
@@ -104,18 +129,40 @@ function renderPipelines() {
104
129
  }
105
130
 
106
131
  const cmdPreview = (p.command || '').substring(0, 80) + ((p.command || '').length > 80 ? '...' : '');
107
- const intervalLabel = p.interval ? `<span style="font-size:0.65rem;padding:1px 5px;background:rgba(59,130,246,0.1);color:var(--primary);border-radius:3px;">${escapeHtml(p.interval)} 반복</span>` : '';
132
+ const projectName = p.project_path ? p.project_path.split('/').filter(Boolean).pop() : '';
133
+ const projectPath = p.project_path || '';
134
+
135
+ // 적응형 인터벌 표시
136
+ const baseInterval = p.interval_sec;
137
+ const effectiveInterval = p.effective_interval_sec;
138
+ const isAdapted = baseInterval && effectiveInterval && baseInterval !== effectiveInterval;
139
+ let intervalLabel = '';
140
+ if (p.interval) {
141
+ if (isAdapted) {
142
+ const effMin = Math.round(effectiveInterval / 60);
143
+ const pct = Math.round((effectiveInterval - baseInterval) / baseInterval * 100);
144
+ intervalLabel = `<span style="font-size:0.65rem;padding:1px 5px;background:rgba(168,85,247,0.1);color:#a855f7;border-radius:3px;" title="기본: ${escapeHtml(p.interval)}, 적응: ${effMin}분 (${pct > 0 ? '+' : ''}${pct}%)">${effMin}분 적응</span>`;
145
+ } else {
146
+ intervalLabel = `<span style="font-size:0.65rem;padding:1px 5px;background:rgba(59,130,246,0.1);color:var(--primary);border-radius:3px;">${escapeHtml(p.interval)} 반복</span>`;
147
+ }
148
+ }
149
+
150
+ // 체이닝 표시
151
+ const chainLabel = p.on_complete ? `<span style="font-size:0.65rem;padding:1px 5px;background:rgba(34,197,94,0.1);color:#22c55e;border-radius:3px;" title="완료 시 트리거">→ chain</span>` : '';
152
+
153
+ const runningBadge = isRunning ? `<span class="pipe-running-badge"><span class="pipe-running-dot"></span>실행 중</span>` : '';
108
154
  const runCount = p.run_count ? `<span style="font-size:0.65rem;color:var(--text-muted);">${p.run_count}회</span>` : '';
109
155
 
110
156
  const toggleBtn = isOn
111
157
  ? `<button class="btn btn-sm" onclick="stopPipeline('${p.id}')">OFF</button>`
112
158
  : `<button class="btn btn-sm btn-primary" onclick="runPipeline('${p.id}')">ON</button>`;
113
159
 
114
- return `<div class="pipeline-card" data-pipe-id="${p.id}">
160
+ return `<div class="pipeline-card${isRunning ? ' is-running' : ''}" data-pipe-id="${p.id}">
115
161
  <div class="pipeline-card-header">
116
162
  <div class="pipeline-card-title">${escapeHtml(p.name || p.id)}</div>
117
- <div style="display:flex;gap:4px;align-items:center;">${intervalLabel} ${timerHtml}</div>
163
+ <div style="display:flex;gap:4px;align-items:center;">${runningBadge} ${intervalLabel} ${chainLabel} ${timerHtml}</div>
118
164
  </div>
165
+ ${projectName ? `<div style="font-size:0.65rem;color:var(--text-muted);margin:2px 0 0 0;display:flex;align-items:center;gap:3px;" title="${escapeHtml(projectPath)}"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>${escapeHtml(projectName)}</div>` : ''}
119
166
  <div class="pipeline-card-goal" style="font-size:0.75rem;color:var(--text-secondary);margin:4px 0;font-family:var(--font-mono,monospace);">${escapeHtml(cmdPreview)}</div>
120
167
  ${p.last_error ? `<div style="font-size:0.7rem;color:var(--danger);margin:4px 0;padding:4px 8px;background:rgba(239,68,68,0.1);border-radius:4px;">${escapeHtml(p.last_error)}</div>` : ''}
121
168
  <div style="display:flex;gap:8px;font-size:0.7rem;color:var(--text-muted);align-items:center;">
@@ -125,6 +172,7 @@ function renderPipelines() {
125
172
  <div class="pipeline-card-actions" style="margin-top:8px;display:flex;gap:6px;">
126
173
  ${toggleBtn}
127
174
  <button class="btn btn-sm" onclick="editPipeline('${p.id}')">수정</button>
175
+ ${p.run_count ? `<button class="btn btn-sm" onclick="showPipelineHistory('${p.id}')">이력</button>` : ''}
128
176
  <button class="btn btn-sm btn-danger" onclick="deletePipeline('${p.id}')">삭제</button>
129
177
  </div>
130
178
  </div>`;
@@ -250,3 +298,190 @@ async function deletePipeline(pipeId) {
250
298
  showToast(err.message || '삭제 실패', 'error');
251
299
  }
252
300
  }
301
+
302
+ /* ── 진화 요약 패널 ── */
303
+ async function fetchEvolutionSummary() {
304
+ const el = document.getElementById('evolutionSummary');
305
+ if (!el) return;
306
+ try {
307
+ const data = await apiFetch('/api/pipelines/evolution');
308
+ const cls = data.classifications || {};
309
+ const total = (cls.has_change || 0) + (cls.no_change || 0) + (cls.unknown || 0);
310
+ const adaptations = (data.interval_adaptations || []);
311
+
312
+ let adaptHtml = '';
313
+ if (adaptations.length > 0) {
314
+ adaptHtml = adaptations.map(a =>
315
+ `<span style="font-size:0.65rem;color:#a855f7;">${escapeHtml(a.name)}: ${a.change_pct > 0 ? '+' : ''}${a.change_pct}%</span>`
316
+ ).join(' ');
317
+ }
318
+
319
+ el.innerHTML = `<div style="display:flex;gap:12px;align-items:center;flex-wrap:wrap;padding:8px 12px;background:var(--bg-secondary);border-radius:8px;font-size:0.7rem;color:var(--text-muted);">
320
+ <span>총 ${data.total_runs}회 실행</span>
321
+ <span>효율 ${data.efficiency_pct}%</span>
322
+ ${total > 0 ? `<span style="color:var(--text-secondary);">변경:${cls.has_change||0} / 무변경:${cls.no_change||0}</span>` : ''}
323
+ ${adaptHtml}
324
+ </div>`;
325
+ } catch { el.innerHTML = ''; }
326
+ }
327
+
328
+ /* ── 새 자동화 생성 모달 ── */
329
+ function openCreatePipeline() {
330
+ const cwd = document.getElementById('cwdInput')?.value?.trim() || '';
331
+ const formId = 'pipeCreate_' + Date.now();
332
+ const overlay = document.createElement('div');
333
+ overlay.className = 'settings-overlay';
334
+ overlay.style.cssText = 'display:flex;align-items:center;justify-content:center;';
335
+ overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); };
336
+ overlay.innerHTML = `<div class="settings-panel" style="max-width:520px;margin:0;">
337
+ <div class="settings-header">
338
+ <div class="settings-title" style="display:flex;align-items:center;gap:8px;">
339
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
340
+ <span>새 자동화</span>
341
+ </div>
342
+ <button class="settings-close" onclick="this.closest('.settings-overlay').remove()">
343
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
344
+ </button>
345
+ </div>
346
+ <div class="settings-body">
347
+ <div style="margin-bottom:12px;">
348
+ <label style="font-size:0.72rem;font-weight:600;color:var(--text-secondary);display:block;margin-bottom:4px;">이름</label>
349
+ <input id="${formId}_name" type="text" placeholder="예: code-quality"
350
+ style="width:100%;padding:6px 10px;border:1px solid var(--border);border-radius:6px;background:var(--bg-secondary);color:var(--text-primary);font-size:0.8rem;box-sizing:border-box;">
351
+ </div>
352
+ <div style="margin-bottom:12px;">
353
+ <label style="font-size:0.72rem;font-weight:600;color:var(--text-secondary);display:block;margin-bottom:4px;">프로젝트 경로</label>
354
+ <input id="${formId}_path" type="text" value="${escapeHtml(cwd)}"
355
+ style="width:100%;padding:6px 10px;border:1px solid var(--border);border-radius:6px;background:var(--bg-secondary);color:var(--text-primary);font-size:0.8rem;font-family:var(--font-mono,monospace);box-sizing:border-box;">
356
+ </div>
357
+ <div style="margin-bottom:12px;">
358
+ <label style="font-size:0.72rem;font-weight:600;color:var(--text-secondary);display:block;margin-bottom:4px;">명령어 (프롬프트)</label>
359
+ <textarea id="${formId}_cmd" rows="4" placeholder="Claude에게 시킬 작업을 입력하세요"
360
+ style="width:100%;padding:6px 10px;border:1px solid var(--border);border-radius:6px;background:var(--bg-secondary);color:var(--text-primary);font-size:0.8rem;font-family:var(--font-mono,monospace);resize:vertical;box-sizing:border-box;"></textarea>
361
+ </div>
362
+ <div style="display:flex;gap:12px;margin-bottom:12px;">
363
+ <div style="flex:1;">
364
+ <label style="font-size:0.72rem;font-weight:600;color:var(--text-secondary);display:block;margin-bottom:4px;">반복 간격 <span style="font-weight:400;color:var(--text-muted);">(비우면 1회)</span></label>
365
+ <input id="${formId}_interval" type="text" placeholder="예: 5m, 1h"
366
+ style="width:100%;padding:6px 10px;border:1px solid var(--border);border-radius:6px;background:var(--bg-secondary);color:var(--text-primary);font-size:0.8rem;box-sizing:border-box;">
367
+ </div>
368
+ <div style="flex:1;">
369
+ <label style="font-size:0.72rem;font-weight:600;color:var(--text-secondary);display:block;margin-bottom:4px;">체이닝 <span style="font-weight:400;color:var(--text-muted);">(완료 시 트리거)</span></label>
370
+ <select id="${formId}_chain"
371
+ style="width:100%;padding:6px 10px;border:1px solid var(--border);border-radius:6px;background:var(--bg-secondary);color:var(--text-primary);font-size:0.8rem;box-sizing:border-box;">
372
+ <option value="">없음</option>
373
+ ${_pipelines.map(p => `<option value="${escapeHtml(p.id)}">${escapeHtml(p.name || p.id)}</option>`).join('')}
374
+ </select>
375
+ </div>
376
+ </div>
377
+ </div>
378
+ <div class="settings-footer" style="display:flex;gap:6px;">
379
+ <button class="btn btn-sm btn-primary" onclick="_submitCreatePipeline('${formId}',this)">생성 및 실행</button>
380
+ <button class="btn btn-sm" onclick="this.closest('.settings-overlay').remove()">취소</button>
381
+ </div>
382
+ </div>`;
383
+ document.body.appendChild(overlay);
384
+ // 포커스
385
+ setTimeout(() => document.getElementById(formId + '_name')?.focus(), 100);
386
+ }
387
+
388
+ async function _submitCreatePipeline(formId, btn) {
389
+ const name = document.getElementById(formId + '_name').value.trim();
390
+ const project_path = document.getElementById(formId + '_path').value.trim();
391
+ const command = document.getElementById(formId + '_cmd').value.trim();
392
+ const interval = document.getElementById(formId + '_interval').value.trim();
393
+ const on_complete = document.getElementById(formId + '_chain').value;
394
+
395
+ if (!project_path) { showToast('프로젝트 경로를 입력하세요', 'error'); return; }
396
+ if (!command) { showToast('명령어를 입력하세요', 'error'); return; }
397
+
398
+ btn.disabled = true;
399
+ btn.textContent = '생성 중...';
400
+ try {
401
+ await apiFetch('/api/pipelines', {
402
+ method: 'POST',
403
+ headers: { 'Content-Type': 'application/json' },
404
+ body: JSON.stringify({ name, project_path, command, interval, on_complete }),
405
+ });
406
+ showToast('자동화 생성 완료');
407
+ btn.closest('.settings-overlay').remove();
408
+ fetchPipelines();
409
+ fetchJobs();
410
+ } catch (err) {
411
+ showToast(err.message || '생성 실패', 'error');
412
+ btn.disabled = false;
413
+ btn.textContent = '생성 및 실행';
414
+ }
415
+ }
416
+
417
+ /* ── 실행 이력 모달 ── */
418
+ async function showPipelineHistory(pipeId) {
419
+ try {
420
+ const data = await apiFetch(`/api/pipelines/${encodeURIComponent(pipeId)}/history`);
421
+ const overlay = document.createElement('div');
422
+ overlay.className = 'settings-overlay';
423
+ overlay.style.cssText = 'display:flex;align-items:center;justify-content:center;';
424
+ overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); };
425
+
426
+ const entries = data.entries || [];
427
+ let tableHtml = '';
428
+ if (entries.length === 0) {
429
+ tableHtml = '<div style="padding:20px;text-align:center;color:var(--text-muted);font-size:0.8rem;">실행 이력이 없습니다.</div>';
430
+ } else {
431
+ const rows = entries.map((h, i) => {
432
+ const cls = h.classification || 'unknown';
433
+ const clsBadge = cls === 'has_change'
434
+ ? '<span class="pipe-hist-badge pipe-hist-change">변경</span>'
435
+ : cls === 'no_change'
436
+ ? '<span class="pipe-hist-badge pipe-hist-nochange">무변경</span>'
437
+ : '<span class="pipe-hist-badge pipe-hist-unknown">?</span>';
438
+ const dur = h.duration_ms ? `${(h.duration_ms / 1000).toFixed(1)}s` : '-';
439
+ const time = h.completed_at || '';
440
+ const resultPreview = escapeHtml((h.result || '').substring(0, 120));
441
+ return `<tr>
442
+ <td style="white-space:nowrap;">${time}</td>
443
+ <td>${clsBadge}</td>
444
+ <td>${dur}</td>
445
+ <td class="pipe-hist-result" title="${escapeHtml(h.result || '')}">${resultPreview}${(h.result || '').length > 120 ? '...' : ''}</td>
446
+ </tr>`;
447
+ }).join('');
448
+ tableHtml = `<div class="pipe-hist-table-wrap"><table class="pipe-hist-table">
449
+ <thead><tr><th>시간</th><th>분류</th><th>소요</th><th>결과 요약</th></tr></thead>
450
+ <tbody>${rows}</tbody>
451
+ </table></div>`;
452
+ }
453
+
454
+ const summaryHtml = `<div style="display:flex;gap:12px;font-size:0.72rem;color:var(--text-muted);margin-bottom:12px;">
455
+ <span>총 ${data.run_count}회 실행</span>
456
+ </div>`;
457
+
458
+ overlay.innerHTML = `<div class="settings-panel" style="max-width:700px;margin:0;">
459
+ <div class="settings-header">
460
+ <div class="settings-title" style="display:flex;align-items:center;gap:8px;">
461
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
462
+ <span>${escapeHtml(data.name || pipeId)} — 실행 이력</span>
463
+ </div>
464
+ <button class="settings-close" onclick="this.closest('.settings-overlay').remove()">
465
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
466
+ </button>
467
+ </div>
468
+ <div class="settings-body" style="padding:12px 16px;">
469
+ ${summaryHtml}
470
+ ${tableHtml}
471
+ </div>
472
+ <div class="settings-footer">
473
+ <button class="btn btn-sm" onclick="this.closest('.settings-overlay').remove()">닫기</button>
474
+ </div>
475
+ </div>`;
476
+ document.body.appendChild(overlay);
477
+ } catch (err) {
478
+ showToast(err.message || '이력 조회 실패', 'error');
479
+ }
480
+ }
481
+
482
+ // fetchPipelines 후 진화 요약도 갱신
483
+ const _origFetchPipelines = fetchPipelines;
484
+ fetchPipelines = async function() {
485
+ await _origFetchPipelines();
486
+ fetchEvolutionSummary();
487
+ };
@@ -0,0 +1,244 @@
1
+ /* ===================================================
2
+ Presets -- 자동화 프리셋 브라우징/적용 UI
3
+ =================================================== */
4
+
5
+ let _presets = [];
6
+ let _presetsPanelOpen = false;
7
+
8
+ const _PRESET_ICONS = {
9
+ rocket: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"/><path d="M12 15l-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"/><path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0"/><path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"/></svg>',
10
+ search: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>',
11
+ book: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>',
12
+ shield: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>',
13
+ layers: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg>',
14
+ bookmark: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>',
15
+ };
16
+
17
+ async function fetchPresets() {
18
+ try {
19
+ _presets = await apiFetch('/api/presets');
20
+ renderPresetCards();
21
+ } catch { /* silent */ }
22
+ }
23
+
24
+ function renderPresetCards() {
25
+ const container = document.getElementById('presetGrid');
26
+ if (!container) return;
27
+
28
+ if (_presets.length === 0) {
29
+ container.innerHTML = '<div class="empty-state" style="padding:20px;text-align:center;color:var(--text-muted);font-size:0.8rem;">프리셋이 없습니다.</div>';
30
+ return;
31
+ }
32
+
33
+ container.innerHTML = _presets.map(p => {
34
+ const icon = _PRESET_ICONS[p.icon] || _PRESET_ICONS.layers;
35
+ const pipeNames = (p.pipeline_names || []).map(n => escapeHtml(n)).join(' &rarr; ');
36
+ const builtinBadge = p.builtin
37
+ ? '<span class="preset-badge preset-badge-builtin">내장</span>'
38
+ : '<span class="preset-badge preset-badge-custom">커스텀</span>';
39
+
40
+ return `<div class="preset-card" data-preset-id="${escapeHtml(p.id)}">
41
+ <div class="preset-card-icon">${icon}</div>
42
+ <div class="preset-card-body">
43
+ <div class="preset-card-header">
44
+ <span class="preset-card-name">${escapeHtml(p.name)}</span>
45
+ ${builtinBadge}
46
+ </div>
47
+ <div class="preset-card-desc">${escapeHtml(p.description)}</div>
48
+ <div class="preset-card-pipes">${pipeNames} <span class="preset-pipe-count">(${p.pipeline_count}개)</span></div>
49
+ </div>
50
+ <div class="preset-card-actions">
51
+ <button class="btn btn-sm btn-primary" onclick="openApplyPreset('${escapeHtml(p.id)}')">적용</button>
52
+ ${!p.builtin ? `<button class="btn btn-sm btn-danger" onclick="deletePreset('${escapeHtml(p.id)}')">삭제</button>` : ''}
53
+ </div>
54
+ </div>`;
55
+ }).join('');
56
+ }
57
+
58
+ function openApplyPreset(presetId) {
59
+ const preset = _presets.find(p => p.id === presetId);
60
+ if (!preset) return;
61
+
62
+ const overlayId = 'presetApply_' + Date.now();
63
+ const overlay = document.createElement('div');
64
+ overlay.className = 'settings-overlay';
65
+ overlay.style.cssText = 'display:flex;align-items:center;justify-content:center;';
66
+ overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); };
67
+
68
+ const icon = _PRESET_ICONS[preset.icon] || _PRESET_ICONS.layers;
69
+
70
+ overlay.innerHTML = `<div class="settings-panel" style="max-width:480px;margin:0;">
71
+ <div class="settings-header">
72
+ <div class="settings-title" style="display:flex;align-items:center;gap:8px;">
73
+ ${icon}
74
+ <span>프리셋 적용: ${escapeHtml(preset.name)}</span>
75
+ </div>
76
+ <button class="settings-close" onclick="this.closest('.settings-overlay').remove()">
77
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
78
+ </button>
79
+ </div>
80
+ <div class="settings-body">
81
+ <div style="margin-bottom:12px;font-size:0.78rem;color:var(--text-secondary);">
82
+ ${escapeHtml(preset.description)}
83
+ </div>
84
+ <div style="margin-bottom:12px;font-size:0.72rem;color:var(--text-muted);display:flex;gap:6px;flex-wrap:wrap;">
85
+ ${(preset.pipeline_names || []).map(n => `<span class="preset-pipe-tag">${escapeHtml(n)}</span>`).join('')}
86
+ </div>
87
+ <div style="margin-bottom:12px;">
88
+ <label style="font-size:0.72rem;font-weight:600;color:var(--text-secondary);display:block;margin-bottom:4px;">프로젝트 경로</label>
89
+ <div style="display:flex;gap:6px;">
90
+ <input id="${overlayId}_path" type="text" placeholder="/path/to/project"
91
+ value="${escapeHtml(document.getElementById('cwdInput')?.value || '')}"
92
+ style="flex:1;padding:6px 10px;border:1px solid var(--border);border-radius:6px;background:var(--bg-secondary);color:var(--text-primary);font-size:0.8rem;font-family:var(--font-mono,monospace);">
93
+ <button class="btn btn-sm" onclick="_browseForPreset('${overlayId}_path')" title="디렉토리 탐색">
94
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
95
+ </button>
96
+ </div>
97
+ </div>
98
+ </div>
99
+ <div class="settings-footer" style="display:flex;gap:6px;">
100
+ <button class="btn btn-sm btn-primary" onclick="_doApplyPreset('${escapeHtml(presetId)}','${overlayId}',this)">적용하기</button>
101
+ <button class="btn btn-sm" onclick="this.closest('.settings-overlay').remove()">취소</button>
102
+ </div>
103
+ </div>`;
104
+ document.body.appendChild(overlay);
105
+ }
106
+
107
+ function _browseForPreset(inputId) {
108
+ // 기존 디렉토리 브라우저를 재활용: 선택 후 해당 input에 값 설정
109
+ const input = document.getElementById(inputId);
110
+ if (!input) return;
111
+ // 임시로 cwdInput에 연결하여 기존 dir browser 활용
112
+ const origCwd = document.getElementById('cwdInput')?.value || '';
113
+ toggleDirBrowser();
114
+ // dirBrowser 선택 시 cwdInput에 값이 설정되므로 polling으로 감지
115
+ const poll = setInterval(() => {
116
+ const newVal = document.getElementById('cwdInput')?.value || '';
117
+ if (newVal && newVal !== origCwd) {
118
+ input.value = newVal;
119
+ clearInterval(poll);
120
+ }
121
+ }, 300);
122
+ setTimeout(() => clearInterval(poll), 30000);
123
+ }
124
+
125
+ async function _doApplyPreset(presetId, overlayId, btn) {
126
+ const path = document.getElementById(overlayId + '_path').value.trim();
127
+ if (!path) { showToast('프로젝트 경로를 입력하세요', 'error'); return; }
128
+
129
+ btn.disabled = true;
130
+ btn.textContent = '적용 중...';
131
+ try {
132
+ const result = await apiFetch('/api/presets/apply', {
133
+ method: 'POST',
134
+ headers: { 'Content-Type': 'application/json' },
135
+ body: JSON.stringify({ preset_id: presetId, project_path: path }),
136
+ });
137
+ showToast(`프리셋 "${result.preset_name}" 적용 완료 (${result.pipelines_created}개 파이프라인 생성)`);
138
+ btn.closest('.settings-overlay').remove();
139
+ // 생성된 파이프라인 중 첫 번째를 즉시 실행 (나머지는 체이닝 또는 타이머로 자동 시작)
140
+ if (result.pipelines && result.pipelines.length > 0) {
141
+ runPipeline(result.pipelines[0].id);
142
+ }
143
+ fetchPipelines();
144
+ } catch (err) {
145
+ showToast(`적용 실패: ${err.message}`, 'error');
146
+ btn.disabled = false;
147
+ btn.textContent = '적용하기';
148
+ }
149
+ }
150
+
151
+ async function deletePreset(presetId) {
152
+ if (!confirm('이 프리셋을 삭제하시겠습니까?')) return;
153
+ try {
154
+ await apiFetch(`/api/presets/${encodeURIComponent(presetId)}`, { method: 'DELETE' });
155
+ showToast('프리셋 삭제됨');
156
+ fetchPresets();
157
+ } catch (err) {
158
+ showToast(err.message || '삭제 실패', 'error');
159
+ }
160
+ }
161
+
162
+ /* -- 현재 파이프라인을 프리셋으로 저장 -- */
163
+
164
+ function openSavePresetDialog() {
165
+ if (_pipelines.length === 0) {
166
+ showToast('저장할 파이프라인이 없습니다', 'error');
167
+ return;
168
+ }
169
+ const dlgId = 'presetSave_' + Date.now();
170
+ const overlay = document.createElement('div');
171
+ overlay.className = 'settings-overlay';
172
+ overlay.style.cssText = 'display:flex;align-items:center;justify-content:center;';
173
+ overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); };
174
+
175
+ const pipeChecks = _pipelines.map(p =>
176
+ `<label style="display:flex;gap:6px;align-items:center;font-size:0.78rem;color:var(--text-primary);">
177
+ <input type="checkbox" value="${escapeHtml(p.id)}" checked style="accent-color:var(--primary);">
178
+ ${escapeHtml(p.name || p.id)}
179
+ <span style="font-size:0.65rem;color:var(--text-muted);">${escapeHtml(p.interval || '1회')}</span>
180
+ </label>`
181
+ ).join('');
182
+
183
+ overlay.innerHTML = `<div class="settings-panel" style="max-width:440px;margin:0;">
184
+ <div class="settings-header">
185
+ <div class="settings-title" style="display:flex;align-items:center;gap:8px;">
186
+ ${_PRESET_ICONS.bookmark}
187
+ <span>프리셋으로 저장</span>
188
+ </div>
189
+ <button class="settings-close" onclick="this.closest('.settings-overlay').remove()">
190
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
191
+ </button>
192
+ </div>
193
+ <div class="settings-body">
194
+ <div style="margin-bottom:12px;">
195
+ <label style="font-size:0.72rem;font-weight:600;color:var(--text-secondary);display:block;margin-bottom:4px;">프리셋 이름</label>
196
+ <input id="${dlgId}_name" type="text" placeholder="나의 자동화 프리셋"
197
+ style="width:100%;padding:6px 10px;border:1px solid var(--border);border-radius:6px;background:var(--bg-secondary);color:var(--text-primary);font-size:0.8rem;box-sizing:border-box;">
198
+ </div>
199
+ <div style="margin-bottom:12px;">
200
+ <label style="font-size:0.72rem;font-weight:600;color:var(--text-secondary);display:block;margin-bottom:4px;">설명</label>
201
+ <input id="${dlgId}_desc" type="text" placeholder="이 프리셋의 용도를 설명하세요"
202
+ style="width:100%;padding:6px 10px;border:1px solid var(--border);border-radius:6px;background:var(--bg-secondary);color:var(--text-primary);font-size:0.8rem;box-sizing:border-box;">
203
+ </div>
204
+ <div style="margin-bottom:8px;">
205
+ <label style="font-size:0.72rem;font-weight:600;color:var(--text-secondary);display:block;margin-bottom:6px;">포함할 파이프라인</label>
206
+ <div id="${dlgId}_pipes" style="display:flex;flex-direction:column;gap:6px;max-height:200px;overflow-y:auto;">
207
+ ${pipeChecks}
208
+ </div>
209
+ </div>
210
+ </div>
211
+ <div class="settings-footer" style="display:flex;gap:6px;">
212
+ <button class="btn btn-sm btn-primary" onclick="_doSavePreset('${dlgId}',this)">저장</button>
213
+ <button class="btn btn-sm" onclick="this.closest('.settings-overlay').remove()">취소</button>
214
+ </div>
215
+ </div>`;
216
+ document.body.appendChild(overlay);
217
+ }
218
+
219
+ async function _doSavePreset(dlgId, btn) {
220
+ const name = document.getElementById(dlgId + '_name').value.trim();
221
+ if (!name) { showToast('프리셋 이름을 입력하세요', 'error'); return; }
222
+
223
+ const desc = document.getElementById(dlgId + '_desc').value.trim();
224
+ const checks = document.querySelectorAll(`#${dlgId}_pipes input[type="checkbox"]:checked`);
225
+ const pipeIds = Array.from(checks).map(c => c.value);
226
+ if (pipeIds.length === 0) { showToast('최소 1개 파이프라인을 선택하세요', 'error'); return; }
227
+
228
+ btn.disabled = true;
229
+ btn.textContent = '저장 중...';
230
+ try {
231
+ const result = await apiFetch('/api/presets/save', {
232
+ method: 'POST',
233
+ headers: { 'Content-Type': 'application/json' },
234
+ body: JSON.stringify({ name, description: desc, pipeline_ids: pipeIds }),
235
+ });
236
+ showToast(`프리셋 "${result.name}" 저장됨 (${result.pipeline_count}개 파이프라인)`);
237
+ btn.closest('.settings-overlay').remove();
238
+ fetchPresets();
239
+ } catch (err) {
240
+ showToast(`저장 실패: ${err.message}`, 'error');
241
+ btn.disabled = false;
242
+ btn.textContent = '저장';
243
+ }
244
+ }
@@ -4,6 +4,7 @@
4
4
 
5
5
  let _sendLock = false;
6
6
  let _automationMode = false;
7
+ let _depsMode = false;
7
8
 
8
9
  function toggleAutomation() {
9
10
  _automationMode = !_automationMode;
@@ -22,12 +23,24 @@ function toggleAutomation() {
22
23
  }
23
24
  }
24
25
 
26
+ function toggleDeps() {
27
+ // DAG UI는 숨김 처리됨 — AI가 API depends_on으로 직접 사용
28
+ _depsMode = !_depsMode;
29
+ const row = document.getElementById('depsRow');
30
+ if (row) row.style.display = _depsMode ? 'flex' : 'none';
31
+ if (!_depsMode) {
32
+ const inp = document.getElementById('depsInput');
33
+ if (inp) inp.value = '';
34
+ }
35
+ }
36
+
25
37
  function clearPromptForm() {
26
38
  document.getElementById('promptInput').value = '';
27
39
  clearAttachments();
28
40
  updatePromptMirror();
29
41
  clearDirSelection();
30
42
  if (_automationMode) toggleAutomation();
43
+ if (_depsMode) toggleDeps();
31
44
  }
32
45
 
33
46
  async function sendTask(e) {
@@ -54,8 +67,7 @@ async function sendTask(e) {
54
67
  body: JSON.stringify({ project_path: cwd, command: prompt, interval }),
55
68
  });
56
69
  showToast(`자동화 등록: ${pipe.name || pipe.id}`);
57
- document.getElementById('promptInput').value = '';
58
- toggleAutomation();
70
+ clearPromptForm();
59
71
  fetchPipelines();
60
72
  runPipeline(pipe.id);
61
73
  } catch (err) {
@@ -94,13 +106,23 @@ async function sendTask(e) {
94
106
 
95
107
  if (filePaths.length > 0) body.images = filePaths;
96
108
 
97
- await apiFetch('/api/send', { method: 'POST', body: JSON.stringify(body) });
109
+ // 의존성 모드: depends_on 추가
110
+ if (_depsMode) {
111
+ const depsVal = document.getElementById('depsInput').value.trim();
112
+ if (depsVal) {
113
+ body.depends_on = depsVal.split(/[,\s]+/).filter(Boolean);
114
+ }
115
+ }
116
+
117
+ const result = await apiFetch('/api/send', { method: 'POST', body: JSON.stringify(body) });
98
118
  const modeMsg = _contextMode === 'resume' ? ' (resume)' : _contextMode === 'fork' ? ' (fork)' : '';
99
- showToast(t('msg_task_sent') + modeMsg);
119
+ const depMsg = result && result.status === 'pending' ? ' (대기 중 — 선행 작업 완료 후 실행)' : '';
120
+ showToast(t('msg_task_sent') + modeMsg + depMsg);
100
121
  if (cwd) addRecentDir(cwd);
101
122
  document.getElementById('promptInput').value = '';
102
123
  clearAttachments();
103
124
  clearContext();
125
+ if (_depsMode) toggleDeps();
104
126
  fetchJobs();
105
127
  } catch (err) {
106
128
  showToast(`${t('msg_send_failed')}: ${err.message}`, 'error');