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.
- package/README.md +2 -2
- package/bin/autoloop.sh +382 -0
- package/bin/ctl +327 -5
- package/bin/native-app.py +5 -2
- package/bin/watchdog.sh +357 -0
- package/cognitive/__init__.py +14 -0
- package/cognitive/__pycache__/__init__.cpython-314.pyc +0 -0
- package/cognitive/__pycache__/dispatcher.cpython-314.pyc +0 -0
- package/cognitive/__pycache__/evaluator.cpython-314.pyc +0 -0
- package/cognitive/__pycache__/goal_engine.cpython-314.pyc +0 -0
- package/cognitive/__pycache__/learning.cpython-314.pyc +0 -0
- package/cognitive/__pycache__/orchestrator.cpython-314.pyc +0 -0
- package/cognitive/__pycache__/planner.cpython-314.pyc +0 -0
- package/cognitive/dispatcher.py +192 -0
- package/cognitive/evaluator.py +289 -0
- package/cognitive/goal_engine.py +232 -0
- package/cognitive/learning.py +189 -0
- package/cognitive/orchestrator.py +303 -0
- package/cognitive/planner.py +207 -0
- package/cognitive/prompts/analyst.md +31 -0
- package/cognitive/prompts/coder.md +22 -0
- package/cognitive/prompts/reviewer.md +33 -0
- package/cognitive/prompts/tester.md +21 -0
- package/cognitive/prompts/writer.md +25 -0
- package/config.sh +6 -1
- package/dag/__init__.py +5 -0
- package/dag/__pycache__/__init__.cpython-314.pyc +0 -0
- package/dag/__pycache__/graph.cpython-314.pyc +0 -0
- package/dag/graph.py +222 -0
- package/lib/jobs.sh +12 -1
- package/package.json +5 -1
- package/postinstall.sh +1 -1
- package/service/controller.sh +43 -11
- package/web/audit.py +122 -0
- package/web/checkpoint.py +80 -0
- package/web/config.py +2 -5
- package/web/handler.py +464 -26
- package/web/handler_fs.py +15 -14
- package/web/handler_goals.py +203 -0
- package/web/handler_jobs.py +165 -42
- package/web/handler_memory.py +203 -0
- package/web/jobs.py +576 -12
- package/web/personas.py +419 -0
- package/web/pipeline.py +682 -50
- package/web/presets.py +506 -0
- package/web/projects.py +58 -4
- package/web/static/api.js +90 -3
- package/web/static/app.js +8 -0
- package/web/static/base.css +51 -12
- package/web/static/context.js +14 -4
- package/web/static/form.css +3 -2
- package/web/static/goals.css +363 -0
- package/web/static/goals.js +300 -0
- package/web/static/i18n.js +288 -0
- package/web/static/index.html +142 -6
- package/web/static/jobs.css +951 -4
- package/web/static/jobs.js +890 -54
- package/web/static/memoryview.js +117 -0
- package/web/static/personas.js +228 -0
- package/web/static/pipeline.css +308 -1
- package/web/static/pipelines.js +249 -14
- package/web/static/presets.js +244 -0
- package/web/static/send.js +26 -4
- package/web/static/settings-style.css +34 -3
- package/web/static/settings.js +37 -1
- package/web/static/stream.js +242 -19
- package/web/static/utils.js +54 -2
- package/web/webhook.py +210 -0
|
@@ -1,5 +1,35 @@
|
|
|
1
1
|
/* ── Settings & Overlays ── */
|
|
2
2
|
|
|
3
|
+
/* ── Theme Toggle FAB ── */
|
|
4
|
+
.theme-fab {
|
|
5
|
+
position: fixed;
|
|
6
|
+
bottom: 80px;
|
|
7
|
+
right: 24px;
|
|
8
|
+
z-index: 300;
|
|
9
|
+
width: 36px;
|
|
10
|
+
height: 36px;
|
|
11
|
+
border-radius: 50%;
|
|
12
|
+
background: var(--surface);
|
|
13
|
+
border: 1px solid var(--border);
|
|
14
|
+
color: var(--text-secondary);
|
|
15
|
+
display: flex;
|
|
16
|
+
align-items: center;
|
|
17
|
+
justify-content: center;
|
|
18
|
+
cursor: pointer;
|
|
19
|
+
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
|
|
20
|
+
transition: all var(--transition);
|
|
21
|
+
}
|
|
22
|
+
.theme-fab:hover {
|
|
23
|
+
background: var(--surface-hover);
|
|
24
|
+
color: var(--accent);
|
|
25
|
+
border-color: var(--accent);
|
|
26
|
+
}
|
|
27
|
+
/* dark mode: show sun icon, hide moon */
|
|
28
|
+
.theme-icon-light { display: none; }
|
|
29
|
+
.theme-icon-dark { display: block; }
|
|
30
|
+
[data-theme="light"] .theme-icon-light { display: block; }
|
|
31
|
+
[data-theme="light"] .theme-icon-dark { display: none; }
|
|
32
|
+
|
|
3
33
|
/* ── Settings Panel ── */
|
|
4
34
|
.settings-fab {
|
|
5
35
|
position: fixed;
|
|
@@ -210,11 +240,11 @@
|
|
|
210
240
|
|
|
211
241
|
.job-preview {
|
|
212
242
|
padding: 6px 14px;
|
|
213
|
-
background: var(--bg);
|
|
243
|
+
background: var(--stream-bg, #080a0e);
|
|
214
244
|
font-family: var(--font-mono);
|
|
215
245
|
font-size: 0.78rem;
|
|
216
246
|
line-height: 1.7;
|
|
217
|
-
color: var(--text
|
|
247
|
+
color: var(--text);
|
|
218
248
|
max-height: calc(0.78rem * 1.7 * 2 + 6px + 6px);
|
|
219
249
|
overflow: hidden;
|
|
220
250
|
box-sizing: content-box;
|
|
@@ -226,6 +256,7 @@
|
|
|
226
256
|
.preview-line {
|
|
227
257
|
padding: 2px 0;
|
|
228
258
|
word-break: break-word;
|
|
259
|
+
color: var(--text);
|
|
229
260
|
}
|
|
230
261
|
.preview-line + .preview-line {
|
|
231
262
|
margin-top: 1px;
|
|
@@ -255,6 +286,6 @@
|
|
|
255
286
|
font-weight: 700;
|
|
256
287
|
}
|
|
257
288
|
.preview-text {
|
|
258
|
-
color: var(--text
|
|
289
|
+
color: var(--text);
|
|
259
290
|
}
|
|
260
291
|
|
package/web/static/settings.js
CHANGED
|
@@ -27,11 +27,26 @@ function _populateSettingsUI() {
|
|
|
27
27
|
const d = _settingsData;
|
|
28
28
|
const sel = document.getElementById('cfgLocale');
|
|
29
29
|
if (sel) sel.value = d.locale || _currentLocale;
|
|
30
|
+
|
|
31
|
+
const whUrl = document.getElementById('cfgWebhookUrl');
|
|
32
|
+
if (whUrl) whUrl.value = d.webhook_url || '';
|
|
33
|
+
const whSecret = document.getElementById('cfgWebhookSecret');
|
|
34
|
+
if (whSecret) whSecret.value = d.webhook_secret || '';
|
|
35
|
+
const whEvents = document.getElementById('cfgWebhookEvents');
|
|
36
|
+
if (whEvents) whEvents.value = d.webhook_events || 'done,failed';
|
|
30
37
|
}
|
|
31
38
|
|
|
32
39
|
async function saveSettings() {
|
|
33
40
|
const locale = document.getElementById('cfgLocale').value;
|
|
34
|
-
const
|
|
41
|
+
const webhookUrl = (document.getElementById('cfgWebhookUrl')?.value || '').trim();
|
|
42
|
+
const webhookSecret = (document.getElementById('cfgWebhookSecret')?.value || '').trim();
|
|
43
|
+
const webhookEvents = document.getElementById('cfgWebhookEvents')?.value || 'done,failed';
|
|
44
|
+
const payload = {
|
|
45
|
+
locale,
|
|
46
|
+
webhook_url: webhookUrl,
|
|
47
|
+
webhook_secret: webhookSecret,
|
|
48
|
+
webhook_events: webhookEvents,
|
|
49
|
+
};
|
|
35
50
|
try {
|
|
36
51
|
await apiFetch('/api/config', {
|
|
37
52
|
method: 'POST',
|
|
@@ -43,3 +58,24 @@ async function saveSettings() {
|
|
|
43
58
|
showToast(t('msg_settings_save_failed') + ': ' + e.message, 'error');
|
|
44
59
|
}
|
|
45
60
|
}
|
|
61
|
+
|
|
62
|
+
async function testWebhook() {
|
|
63
|
+
const btn = document.getElementById('btnWebhookTest');
|
|
64
|
+
if (!btn) return;
|
|
65
|
+
const orig = btn.innerHTML;
|
|
66
|
+
btn.disabled = true;
|
|
67
|
+
btn.innerHTML = '<span class="spinner" style="width:12px;height:12px;"></span>';
|
|
68
|
+
try {
|
|
69
|
+
const result = await apiFetch('/api/webhooks/test', { method: 'POST' });
|
|
70
|
+
if (result.delivered) {
|
|
71
|
+
showToast(`웹훅 전송 성공 (HTTP ${result.status_code})`);
|
|
72
|
+
} else {
|
|
73
|
+
showToast(`웹훅 전송 실패: ${result.error}`, 'error');
|
|
74
|
+
}
|
|
75
|
+
} catch (e) {
|
|
76
|
+
showToast(`웹훅 테스트 실패: ${e.message}`, 'error');
|
|
77
|
+
} finally {
|
|
78
|
+
btn.disabled = false;
|
|
79
|
+
btn.innerHTML = orig;
|
|
80
|
+
}
|
|
81
|
+
}
|
package/web/static/stream.js
CHANGED
|
@@ -31,15 +31,30 @@ function updateJobPreview(jobId) {
|
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
function initStream(jobId) {
|
|
35
|
-
if (streamState[jobId] && streamState[jobId].timer) return;
|
|
34
|
+
function initStream(jobId, jobData) {
|
|
36
35
|
if (streamState[jobId] && streamState[jobId]._bulkLoading) return;
|
|
37
36
|
|
|
38
37
|
if (!streamState[jobId]) {
|
|
39
|
-
streamState[jobId] = { offset: 0, timer: null, done: false, jobData: null, events: [], renderedCount: 0 };
|
|
38
|
+
streamState[jobId] = { offset: 0, timer: null, done: false, jobData: jobData || null, events: [], renderedCount: 0, _initTime: Date.now(), _lastEventTime: Date.now() };
|
|
39
|
+
} else if (jobData) {
|
|
40
|
+
streamState[jobId].jobData = jobData;
|
|
40
41
|
}
|
|
41
42
|
|
|
42
43
|
const state = streamState[jobId];
|
|
44
|
+
|
|
45
|
+
// expand 패널이 새로 생겼는데 아직 placeholder 상태면 캐시된 이벤트를 즉시 렌더링
|
|
46
|
+
const container = document.getElementById(`streamContent-${jobId}`);
|
|
47
|
+
if (container && state.events.length > 0) {
|
|
48
|
+
const isEmpty = container.querySelector('.stream-empty') || container.children.length === 0;
|
|
49
|
+
if (isEmpty) {
|
|
50
|
+
state.renderedCount = 0;
|
|
51
|
+
renderStreamEvents(jobId);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// SSE 또는 폴링이 이미 진행 중이면 중복 시작하지 않음
|
|
56
|
+
if (state.timer || state._eventSource) return;
|
|
57
|
+
|
|
43
58
|
if (state.done && state.events.length > 0) {
|
|
44
59
|
renderStreamEvents(jobId);
|
|
45
60
|
return;
|
|
@@ -51,8 +66,67 @@ function initStream(jobId) {
|
|
|
51
66
|
return;
|
|
52
67
|
}
|
|
53
68
|
|
|
54
|
-
|
|
55
|
-
|
|
69
|
+
// SSE 실시간 스트림 시작 (실패 시 자동 폴링 폴백)
|
|
70
|
+
startSSEStream(jobId);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function startSSEStream(jobId) {
|
|
74
|
+
const state = streamState[jobId];
|
|
75
|
+
if (!state) return;
|
|
76
|
+
|
|
77
|
+
let url = `${API}/api/jobs/${encodeURIComponent(jobId)}/stream`;
|
|
78
|
+
if (AUTH_TOKEN) url += `?token=${encodeURIComponent(AUTH_TOKEN)}`;
|
|
79
|
+
|
|
80
|
+
const es = new EventSource(url);
|
|
81
|
+
state._eventSource = es;
|
|
82
|
+
|
|
83
|
+
es.onmessage = function(e) {
|
|
84
|
+
try {
|
|
85
|
+
const evt = JSON.parse(e.data);
|
|
86
|
+
state.events.push(evt);
|
|
87
|
+
state._lastEventTime = Date.now();
|
|
88
|
+
renderStreamEvents(jobId);
|
|
89
|
+
updateJobPreview(jobId);
|
|
90
|
+
} catch { /* parse error */ }
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
es.addEventListener('done', function(e) {
|
|
94
|
+
es.close();
|
|
95
|
+
state._eventSource = null;
|
|
96
|
+
state.done = true;
|
|
97
|
+
|
|
98
|
+
let finalStatus = 'done';
|
|
99
|
+
try {
|
|
100
|
+
const data = JSON.parse(e.data);
|
|
101
|
+
finalStatus = data.status || 'done';
|
|
102
|
+
} catch {}
|
|
103
|
+
|
|
104
|
+
const lastResult = state.events.filter(ev => ev.type === 'result').pop();
|
|
105
|
+
if (lastResult && lastResult.is_error) finalStatus = 'failed';
|
|
106
|
+
if (state.jobData) state.jobData.status = finalStatus;
|
|
107
|
+
|
|
108
|
+
renderStreamDone(jobId);
|
|
109
|
+
updateJobRowStatus(jobId, finalStatus);
|
|
110
|
+
notifyJobDone(jobId, finalStatus, state.jobData ? state.jobData.prompt : '');
|
|
111
|
+
|
|
112
|
+
const pvRow = document.querySelector(`tr[data-job-id="${CSS.escape(jobId + '__preview')}"]`);
|
|
113
|
+
if (pvRow) pvRow.remove();
|
|
114
|
+
|
|
115
|
+
fetchJobs();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
es.onerror = function() {
|
|
119
|
+
if (state.done) return;
|
|
120
|
+
|
|
121
|
+
// SSE 실패 → 폴링 폴백
|
|
122
|
+
es.close();
|
|
123
|
+
state._eventSource = null;
|
|
124
|
+
|
|
125
|
+
if (!state.timer) {
|
|
126
|
+
pollStream(jobId);
|
|
127
|
+
state.timer = setInterval(() => pollStream(jobId), 500);
|
|
128
|
+
}
|
|
129
|
+
};
|
|
56
130
|
}
|
|
57
131
|
|
|
58
132
|
async function loadStreamBulk(jobId) {
|
|
@@ -69,13 +143,28 @@ async function loadStreamBulk(jobId) {
|
|
|
69
143
|
}
|
|
70
144
|
if (data.done || !data.events || data.events.length === 0) {
|
|
71
145
|
state.done = true;
|
|
146
|
+
const lastResult = state.events.filter(e => e.type === 'result').pop();
|
|
147
|
+
const finalStatus = (lastResult && lastResult.is_error) ? 'failed'
|
|
148
|
+
: (state.jobData ? state.jobData.status : 'done');
|
|
149
|
+
if (state.jobData) state.jobData.status = finalStatus;
|
|
72
150
|
renderStreamDone(jobId);
|
|
73
|
-
updateJobRowStatus(jobId,
|
|
151
|
+
updateJobRowStatus(jobId, finalStatus);
|
|
74
152
|
const pvRow = document.querySelector(`tr[data-job-id="${CSS.escape(jobId + '__preview')}"]`);
|
|
75
153
|
if (pvRow) pvRow.remove();
|
|
76
154
|
}
|
|
77
|
-
} catch {
|
|
78
|
-
|
|
155
|
+
} catch (err) {
|
|
156
|
+
const container = document.getElementById(`streamContent-${jobId}`);
|
|
157
|
+
if (container) {
|
|
158
|
+
const retryId = `retryBulk-${jobId}`;
|
|
159
|
+
container.innerHTML = `<div class="stream-error-state">
|
|
160
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
|
|
161
|
+
<span>${escapeHtml(t('stream_load_failed'))}</span>
|
|
162
|
+
<button class="btn btn-sm" id="${retryId}" onclick="event.stopPropagation(); loadStreamBulk('${escapeHtml(jobId)}')">
|
|
163
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>
|
|
164
|
+
${escapeHtml(t('stream_retry'))}
|
|
165
|
+
</button>
|
|
166
|
+
</div>`;
|
|
167
|
+
}
|
|
79
168
|
} finally {
|
|
80
169
|
state._bulkLoading = false;
|
|
81
170
|
}
|
|
@@ -84,6 +173,10 @@ async function loadStreamBulk(jobId) {
|
|
|
84
173
|
function stopStream(jobId) {
|
|
85
174
|
const state = streamState[jobId];
|
|
86
175
|
if (!state) return;
|
|
176
|
+
if (state._eventSource) {
|
|
177
|
+
state._eventSource.close();
|
|
178
|
+
state._eventSource = null;
|
|
179
|
+
}
|
|
87
180
|
if (state.timer) {
|
|
88
181
|
clearInterval(state.timer);
|
|
89
182
|
state.timer = null;
|
|
@@ -103,9 +196,17 @@ async function pollStream(jobId) {
|
|
|
103
196
|
const newOffset = data.offset !== undefined ? data.offset : state.offset + events.length;
|
|
104
197
|
const done = !!data.done;
|
|
105
198
|
|
|
199
|
+
// 성공 시 실패 카운터 초기화 + 백오프 복원
|
|
200
|
+
if (state._pollFails > 0) {
|
|
201
|
+
state._pollFails = 0;
|
|
202
|
+
_setPollInterval(jobId, 500);
|
|
203
|
+
_clearPollWarning(jobId);
|
|
204
|
+
}
|
|
205
|
+
|
|
106
206
|
if (events.length > 0) {
|
|
107
207
|
state.events = state.events.concat(events);
|
|
108
208
|
state.offset = newOffset;
|
|
209
|
+
state._lastEventTime = Date.now();
|
|
109
210
|
renderStreamEvents(jobId);
|
|
110
211
|
updateJobPreview(jobId);
|
|
111
212
|
}
|
|
@@ -113,14 +214,95 @@ async function pollStream(jobId) {
|
|
|
113
214
|
if (done) {
|
|
114
215
|
state.done = true;
|
|
115
216
|
stopStream(jobId);
|
|
217
|
+
|
|
218
|
+
// result 이벤트의 is_error 로 실제 최종 상태 결정
|
|
219
|
+
const lastResult = state.events.filter(e => e.type === 'result').pop();
|
|
220
|
+
const finalStatus = lastResult && lastResult.is_error ? 'failed' : 'done';
|
|
221
|
+
if (state.jobData) state.jobData.status = finalStatus;
|
|
222
|
+
|
|
116
223
|
renderStreamDone(jobId);
|
|
117
|
-
updateJobRowStatus(jobId,
|
|
224
|
+
updateJobRowStatus(jobId, finalStatus);
|
|
225
|
+
notifyJobDone(jobId, finalStatus, state.jobData ? state.jobData.prompt : '');
|
|
118
226
|
const pvRow = document.querySelector(`tr[data-job-id="${CSS.escape(jobId + '__preview')}"]`);
|
|
119
227
|
if (pvRow) pvRow.remove();
|
|
228
|
+
|
|
229
|
+
// 즉시 전체 행 동기화 (액션 버튼, 필터 등)
|
|
230
|
+
fetchJobs();
|
|
120
231
|
}
|
|
121
232
|
} catch {
|
|
122
|
-
//
|
|
233
|
+
// 네트워크 에러 — 지수 백오프 + 실패 피드백
|
|
234
|
+
state._pollFails = (state._pollFails || 0) + 1;
|
|
235
|
+
const fails = state._pollFails;
|
|
236
|
+
|
|
237
|
+
if (fails >= 20) {
|
|
238
|
+
// 20회 연속 실패 (~2분) → 폴링 중단, 재시도 버튼 표시
|
|
239
|
+
stopStream(jobId);
|
|
240
|
+
_showPollRetry(jobId);
|
|
241
|
+
} else if (fails >= 5) {
|
|
242
|
+
// 5회+ 실패 → 경고 표시 + 백오프 (1s → 2s → 4s → 최대 10s)
|
|
243
|
+
const interval = Math.min(500 * Math.pow(2, fails - 4), 10000);
|
|
244
|
+
_setPollInterval(jobId, interval);
|
|
245
|
+
_showPollWarning(jobId);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** 폴링 간격을 동적으로 변경한다. */
|
|
251
|
+
function _setPollInterval(jobId, ms) {
|
|
252
|
+
const state = streamState[jobId];
|
|
253
|
+
if (!state || !state.timer) return;
|
|
254
|
+
clearInterval(state.timer);
|
|
255
|
+
state.timer = setInterval(() => pollStream(jobId), ms);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** 연결 불안정 경고를 스트림 패널에 표시한다. */
|
|
259
|
+
function _showPollWarning(jobId) {
|
|
260
|
+
const panel = document.getElementById(`streamPanel-${jobId}`);
|
|
261
|
+
if (!panel || panel.querySelector('.stream-poll-warning')) return;
|
|
262
|
+
const warn = document.createElement('div');
|
|
263
|
+
warn.className = 'stream-poll-warning';
|
|
264
|
+
warn.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg> ${escapeHtml(t('conn_lost'))}`;
|
|
265
|
+
panel.prepend(warn);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function _clearPollWarning(jobId) {
|
|
269
|
+
const panel = document.getElementById(`streamPanel-${jobId}`);
|
|
270
|
+
if (!panel) return;
|
|
271
|
+
const warn = panel.querySelector('.stream-poll-warning');
|
|
272
|
+
if (warn) warn.remove();
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** 폴링이 포기된 후 수동 재시도 버튼을 표시한다. */
|
|
276
|
+
function _showPollRetry(jobId) {
|
|
277
|
+
_clearPollWarning(jobId);
|
|
278
|
+
const container = document.getElementById(`streamContent-${jobId}`);
|
|
279
|
+
if (!container) return;
|
|
280
|
+
// 이미 표시된 경우 중복 방지
|
|
281
|
+
if (container.querySelector('.stream-poll-retry')) return;
|
|
282
|
+
const retryDiv = document.createElement('div');
|
|
283
|
+
retryDiv.className = 'stream-error-state stream-poll-retry';
|
|
284
|
+
retryDiv.innerHTML = `
|
|
285
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
|
|
286
|
+
<span>${escapeHtml(t('conn_lost'))}</span>
|
|
287
|
+
<button class="btn btn-sm" onclick="event.stopPropagation(); retryPollStream('${escapeHtml(jobId)}')">
|
|
288
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>
|
|
289
|
+
${escapeHtml(t('stream_reconnect'))}
|
|
290
|
+
</button>`;
|
|
291
|
+
container.appendChild(retryDiv);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function retryPollStream(jobId) {
|
|
295
|
+
const state = streamState[jobId];
|
|
296
|
+
if (!state) return;
|
|
297
|
+
state._pollFails = 0;
|
|
298
|
+
// 재시도 UI 제거
|
|
299
|
+
const container = document.getElementById(`streamContent-${jobId}`);
|
|
300
|
+
if (container) {
|
|
301
|
+
const retry = container.querySelector('.stream-poll-retry');
|
|
302
|
+
if (retry) retry.remove();
|
|
123
303
|
}
|
|
304
|
+
// SSE 우선 시도, 실패 시 자동 폴링 전환
|
|
305
|
+
startSSEStream(jobId);
|
|
124
306
|
}
|
|
125
307
|
|
|
126
308
|
function renderStreamEvents(jobId) {
|
|
@@ -154,8 +336,14 @@ function renderStreamEvents(jobId) {
|
|
|
154
336
|
break;
|
|
155
337
|
case 'result':
|
|
156
338
|
div.classList.add('stream-event-result');
|
|
157
|
-
|
|
158
|
-
|
|
339
|
+
if (evt.is_error && evt.user_error) {
|
|
340
|
+
div.classList.add('stream-event-result-error');
|
|
341
|
+
div.innerHTML = `<span class="stream-result-icon">✗</span>
|
|
342
|
+
<span class="stream-result-text">${escapeHtml(evt.user_error.summary)}</span>`;
|
|
343
|
+
} else {
|
|
344
|
+
div.innerHTML = `<span class="stream-result-icon">✓</span>
|
|
345
|
+
<span class="stream-result-text">${escapeHtml(typeof evt.result === 'string' ? evt.result : JSON.stringify(evt.result || ''))}</span>`;
|
|
346
|
+
}
|
|
159
347
|
if (evt.session_id) {
|
|
160
348
|
const panel = document.getElementById(`streamPanel-${jobId}`);
|
|
161
349
|
if (panel) panel.dataset.sessionId = evt.session_id;
|
|
@@ -167,7 +355,7 @@ function renderStreamEvents(jobId) {
|
|
|
167
355
|
cells[4].className = 'job-session clickable';
|
|
168
356
|
cells[4].title = evt.session_id;
|
|
169
357
|
const evtCwd = panel ? (panel.dataset.cwd || '') : '';
|
|
170
|
-
cells[4].setAttribute('onclick', `event.stopPropagation(); resumeFromJob('${
|
|
358
|
+
cells[4].setAttribute('onclick', `event.stopPropagation(); resumeFromJob('${escapeJsStr(evt.session_id)}', '', '${escapeJsStr(evtCwd)}')`);
|
|
171
359
|
}
|
|
172
360
|
}
|
|
173
361
|
}
|
|
@@ -202,12 +390,47 @@ function renderStreamDone(jobId) {
|
|
|
202
390
|
const status = state && state.jobData ? state.jobData.status : 'done';
|
|
203
391
|
const isFailed = status === 'failed';
|
|
204
392
|
|
|
393
|
+
// 이벤트가 0개면 "불러오는 중" placeholder를 "출력 없음"으로 교체
|
|
394
|
+
if (state && state.events.length === 0) {
|
|
395
|
+
const container = document.getElementById(`streamContent-${jobId}`);
|
|
396
|
+
if (container && (container.querySelector('.stream-empty') || container.children.length === 0)) {
|
|
397
|
+
container.innerHTML = `<div class="stream-no-output">${t('stream_no_output')}</div>`;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// 소요시간 추출
|
|
402
|
+
const lastResult = state ? state.events.filter(e => e.type === 'result').pop() : null;
|
|
403
|
+
let bannerDetails = '';
|
|
404
|
+
if (lastResult) {
|
|
405
|
+
const info = formatDuration(lastResult.duration_ms);
|
|
406
|
+
if (info) bannerDetails = ` — ${info}`;
|
|
407
|
+
}
|
|
408
|
+
|
|
205
409
|
let banner = panel.querySelector('.stream-done-banner');
|
|
206
410
|
if (!banner) {
|
|
207
411
|
banner = document.createElement('div');
|
|
208
412
|
banner.className = `stream-done-banner${isFailed ? ' failed' : ''}`;
|
|
209
|
-
banner.textContent = isFailed ?
|
|
413
|
+
banner.textContent = (isFailed ? `✗ ${t('stream_job_failed')}` : `✓ ${t('stream_job_done')}`) + bannerDetails;
|
|
210
414
|
panel.appendChild(banner);
|
|
415
|
+
} else if (bannerDetails && !banner.textContent.includes('—')) {
|
|
416
|
+
banner.textContent = (isFailed ? `✗ ${t('stream_job_failed')}` : `✓ ${t('stream_job_done')}`) + bannerDetails;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// 실패 시 사용자 친화적 에러 카드 표시
|
|
420
|
+
if (isFailed && lastResult && !panel.querySelector('.user-error-card')) {
|
|
421
|
+
const ue = lastResult.user_error;
|
|
422
|
+
if (ue) {
|
|
423
|
+
const card = document.createElement('div');
|
|
424
|
+
card.className = 'user-error-card';
|
|
425
|
+
const stepsHtml = (ue.next_steps || []).map(s => `<li>${escapeHtml(s)}</li>`).join('');
|
|
426
|
+
const rawText = typeof lastResult.result === 'string' ? lastResult.result : '';
|
|
427
|
+
const detailsHtml = rawText ? `<details class="user-error-details"><summary>${escapeHtml(t('err_show_log'))}</summary><pre class="user-error-raw">${escapeHtml(rawText.slice(0, 2000))}</pre></details>` : '';
|
|
428
|
+
card.innerHTML = `<div class="user-error-summary">${escapeHtml(ue.summary)}</div>
|
|
429
|
+
<div class="user-error-cause">${escapeHtml(ue.cause)}</div>
|
|
430
|
+
${stepsHtml ? `<ul class="user-error-steps">${stepsHtml}</ul>` : ''}
|
|
431
|
+
${detailsHtml}`;
|
|
432
|
+
banner.insertAdjacentElement('afterend', card);
|
|
433
|
+
}
|
|
211
434
|
}
|
|
212
435
|
|
|
213
436
|
let actions = panel.querySelector('.stream-actions');
|
|
@@ -217,11 +440,11 @@ function renderStreamDone(jobId) {
|
|
|
217
440
|
actions.innerHTML = `
|
|
218
441
|
<button class="btn btn-sm" onclick="event.stopPropagation(); copyStreamResult('${escapeHtml(jobId)}')">
|
|
219
442
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
|
220
|
-
|
|
443
|
+
${escapeHtml(t('stream_copy_all'))}
|
|
221
444
|
</button>
|
|
222
445
|
<button class="btn btn-sm btn-danger" onclick="event.stopPropagation(); deleteJob('${escapeHtml(jobId)}')">
|
|
223
446
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
|
|
224
|
-
|
|
447
|
+
${escapeHtml(t('stream_delete_job'))}
|
|
225
448
|
</button>`;
|
|
226
449
|
panel.appendChild(actions);
|
|
227
450
|
}
|
|
@@ -231,14 +454,14 @@ function renderStreamDone(jobId) {
|
|
|
231
454
|
const followup = document.createElement('div');
|
|
232
455
|
followup.className = 'stream-followup';
|
|
233
456
|
followup.innerHTML = `
|
|
234
|
-
<span class="stream-followup-label"
|
|
457
|
+
<span class="stream-followup-label">${escapeHtml(t('stream_followup_label'))}</span>
|
|
235
458
|
<div class="followup-input-wrap">
|
|
236
459
|
<input type="text" class="followup-input" id="followupInput-${escapeHtml(jobId)}"
|
|
237
|
-
placeholder="
|
|
460
|
+
placeholder="${escapeHtml(t('stream_followup_placeholder'))}"
|
|
238
461
|
onkeydown="if(event.key==='Enter'){event.stopPropagation();sendFollowUp('${escapeHtml(jobId)}')}"
|
|
239
462
|
onclick="event.stopPropagation()">
|
|
240
463
|
<button class="btn btn-primary btn-sm" onclick="event.stopPropagation(); sendFollowUp('${escapeHtml(jobId)}')" style="white-space:nowrap;">
|
|
241
|
-
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
|
|
464
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg> ${escapeHtml(t('send'))}
|
|
242
465
|
</button>
|
|
243
466
|
</div>
|
|
244
467
|
<div class="followup-previews" id="followupPreviews-${escapeHtml(jobId)}"></div>`;
|
package/web/static/utils.js
CHANGED
|
@@ -5,13 +5,19 @@
|
|
|
5
5
|
function showToast(message, type = 'success') {
|
|
6
6
|
const container = document.getElementById('toastContainer');
|
|
7
7
|
const toast = document.createElement('div');
|
|
8
|
+
const duration = type === 'error' ? 6000 : 3000;
|
|
8
9
|
toast.className = `toast ${type}`;
|
|
10
|
+
toast.style.setProperty('--toast-duration', `${duration}ms`);
|
|
9
11
|
const icon = type === 'success'
|
|
10
12
|
? '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"/></svg>'
|
|
11
13
|
: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>';
|
|
12
|
-
toast.innerHTML = `${icon}
|
|
14
|
+
toast.innerHTML = `${icon} <span class="toast-msg">${escapeHtml(message)}</span><span class="toast-close">×</span>`;
|
|
15
|
+
toast.addEventListener('click', () => {
|
|
16
|
+
toast.style.animation = 'toastOut 0.2s ease forwards';
|
|
17
|
+
setTimeout(() => { if (toast.parentNode) toast.remove(); }, 200);
|
|
18
|
+
});
|
|
13
19
|
container.appendChild(toast);
|
|
14
|
-
setTimeout(() => { if (toast.parentNode) toast.remove(); },
|
|
20
|
+
setTimeout(() => { if (toast.parentNode) toast.remove(); }, duration);
|
|
15
21
|
}
|
|
16
22
|
|
|
17
23
|
function escapeHtml(str) {
|
|
@@ -21,6 +27,12 @@ function escapeHtml(str) {
|
|
|
21
27
|
return d.innerHTML;
|
|
22
28
|
}
|
|
23
29
|
|
|
30
|
+
/** JS 문자열 이스케이프 — onclick 핸들러 내 싱글쿼트 문자열에서 사용 */
|
|
31
|
+
function escapeJsStr(str) {
|
|
32
|
+
if (!str) return '';
|
|
33
|
+
return str.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
|
34
|
+
}
|
|
35
|
+
|
|
24
36
|
function truncate(str, len = 60) {
|
|
25
37
|
if (!str) return '-';
|
|
26
38
|
return str.length > len ? str.slice(0, len) + '...' : str;
|
|
@@ -77,3 +89,43 @@ function getFileExt(filename) {
|
|
|
77
89
|
const dot = filename.lastIndexOf('.');
|
|
78
90
|
return dot >= 0 ? filename.slice(dot + 1).toUpperCase() : '?';
|
|
79
91
|
}
|
|
92
|
+
|
|
93
|
+
/* ── Desktop Notification ── */
|
|
94
|
+
function requestNotificationPermission() {
|
|
95
|
+
if ('Notification' in window && Notification.permission === 'default') {
|
|
96
|
+
Notification.requestPermission();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function notifyJobDone(jobId, status, prompt) {
|
|
101
|
+
if (!('Notification' in window) || Notification.permission !== 'granted') return;
|
|
102
|
+
if (document.hasFocus()) return;
|
|
103
|
+
const title = status === 'done' ? `Job #${jobId} 완료` : `Job #${jobId} 실패`;
|
|
104
|
+
const body = truncate(prompt || '', 80);
|
|
105
|
+
const icon = status === 'done'
|
|
106
|
+
? 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y="80" font-size="80">%E2%9C%85</text></svg>'
|
|
107
|
+
: 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y="80" font-size="80">%E2%9D%8C</text></svg>';
|
|
108
|
+
try {
|
|
109
|
+
const n = new Notification(title, { body, icon, tag: `job-${jobId}` });
|
|
110
|
+
n.onclick = () => { window.focus(); toggleJobExpand(String(jobId)); n.close(); };
|
|
111
|
+
setTimeout(() => n.close(), 8000);
|
|
112
|
+
} catch { /* silent */ }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/* ── Duration formatting ── */
|
|
116
|
+
function formatDuration(durationMs) {
|
|
117
|
+
if (durationMs == null) return '';
|
|
118
|
+
const sec = durationMs / 1000;
|
|
119
|
+
return sec < 60 ? `${sec.toFixed(1)}s` : `${Math.floor(sec / 60)}m ${Math.round(sec % 60)}s`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/* ── Theme ── */
|
|
123
|
+
function applyTheme(theme) {
|
|
124
|
+
document.documentElement.setAttribute('data-theme', theme);
|
|
125
|
+
localStorage.setItem('theme', theme);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function toggleTheme() {
|
|
129
|
+
const current = document.documentElement.getAttribute('data-theme') || 'dark';
|
|
130
|
+
applyTheme(current === 'dark' ? 'light' : 'dark');
|
|
131
|
+
}
|