claude-controller 0.1.2 → 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 +1189 -0
- package/bin/native-app.py +6 -3
- 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 +11 -5
- 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 +634 -473
- package/web/handler_fs.py +153 -0
- package/web/handler_goals.py +203 -0
- package/web/handler_jobs.py +372 -0
- package/web/handler_memory.py +203 -0
- package/web/handler_sessions.py +132 -0
- package/web/jobs.py +585 -13
- package/web/personas.py +419 -0
- package/web/pipeline.py +981 -0
- package/web/presets.py +506 -0
- package/web/projects.py +246 -0
- package/web/static/api.js +141 -0
- package/web/static/app.js +25 -1937
- package/web/static/attachments.js +144 -0
- package/web/static/base.css +497 -0
- package/web/static/context.js +204 -0
- package/web/static/dirs.js +246 -0
- package/web/static/form.css +763 -0
- package/web/static/goals.css +363 -0
- package/web/static/goals.js +300 -0
- package/web/static/i18n.js +625 -0
- package/web/static/index.html +215 -13
- package/web/static/{styles.css → jobs.css} +746 -1141
- package/web/static/jobs.js +1270 -0
- package/web/static/memoryview.js +117 -0
- package/web/static/personas.js +228 -0
- package/web/static/pipeline.css +338 -0
- package/web/static/pipelines.js +487 -0
- package/web/static/presets.js +244 -0
- package/web/static/send.js +135 -0
- package/web/static/settings-style.css +291 -0
- package/web/static/settings.js +81 -0
- package/web/static/stream.js +534 -0
- package/web/static/utils.js +131 -0
- package/web/webhook.py +210 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/* ═══════════════════════════════════════════════
|
|
2
|
+
Settings — 설정 패널
|
|
3
|
+
═══════════════════════════════════════════════ */
|
|
4
|
+
|
|
5
|
+
let _settingsData = {};
|
|
6
|
+
|
|
7
|
+
function openSettings() {
|
|
8
|
+
loadSettings().then(() => {
|
|
9
|
+
document.getElementById('settingsOverlay').classList.add('open');
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function closeSettings() {
|
|
14
|
+
document.getElementById('settingsOverlay').classList.remove('open');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function loadSettings() {
|
|
18
|
+
try {
|
|
19
|
+
_settingsData = await apiFetch('/api/config');
|
|
20
|
+
} catch {
|
|
21
|
+
_settingsData = {};
|
|
22
|
+
}
|
|
23
|
+
_populateSettingsUI();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function _populateSettingsUI() {
|
|
27
|
+
const d = _settingsData;
|
|
28
|
+
const sel = document.getElementById('cfgLocale');
|
|
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';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function saveSettings() {
|
|
40
|
+
const locale = document.getElementById('cfgLocale').value;
|
|
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
|
+
};
|
|
50
|
+
try {
|
|
51
|
+
await apiFetch('/api/config', {
|
|
52
|
+
method: 'POST',
|
|
53
|
+
body: JSON.stringify(payload),
|
|
54
|
+
});
|
|
55
|
+
setLocale(locale);
|
|
56
|
+
showToast(t('msg_settings_saved'));
|
|
57
|
+
} catch (e) {
|
|
58
|
+
showToast(t('msg_settings_save_failed') + ': ' + e.message, 'error');
|
|
59
|
+
}
|
|
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
|
+
}
|
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
/* ═══════════════════════════════════════════════
|
|
2
|
+
Stream — 스트림 폴링, 렌더링, 복사
|
|
3
|
+
═══════════════════════════════════════════════ */
|
|
4
|
+
|
|
5
|
+
const streamState = {};
|
|
6
|
+
|
|
7
|
+
function updateJobPreview(jobId) {
|
|
8
|
+
const el = document.getElementById(`jobPreview-${jobId}`);
|
|
9
|
+
if (!el) return;
|
|
10
|
+
|
|
11
|
+
const state = streamState[jobId];
|
|
12
|
+
if (!state || state.events.length === 0) return;
|
|
13
|
+
|
|
14
|
+
const recent = state.events.slice(-2);
|
|
15
|
+
const lines = recent.map(evt => {
|
|
16
|
+
if (evt.type === 'tool_use') {
|
|
17
|
+
const input = escapeHtml((typeof evt.input === 'string' ? evt.input : JSON.stringify(evt.input || '')).slice(0, 150));
|
|
18
|
+
return `<div class="preview-line"><span class="preview-tool">${escapeHtml(evt.tool || 'Tool')}</span>${input}</div>`;
|
|
19
|
+
}
|
|
20
|
+
if (evt.type === 'result') {
|
|
21
|
+
const text = (typeof evt.result === 'string' ? evt.result : '').slice(0, 150);
|
|
22
|
+
return `<div class="preview-line preview-result">${escapeHtml(text)}</div>`;
|
|
23
|
+
}
|
|
24
|
+
const text = (evt.text || '').split('\n').pop().slice(0, 150);
|
|
25
|
+
if (!text) return '';
|
|
26
|
+
return `<div class="preview-line">${escapeHtml(text)}</div>`;
|
|
27
|
+
}).filter(Boolean);
|
|
28
|
+
|
|
29
|
+
if (lines.length > 0) {
|
|
30
|
+
el.innerHTML = `<div class="preview-lines">${lines.join('')}</div>`;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function initStream(jobId, jobData) {
|
|
35
|
+
if (streamState[jobId] && streamState[jobId]._bulkLoading) return;
|
|
36
|
+
|
|
37
|
+
if (!streamState[jobId]) {
|
|
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;
|
|
41
|
+
}
|
|
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
|
+
|
|
58
|
+
if (state.done && state.events.length > 0) {
|
|
59
|
+
renderStreamEvents(jobId);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const isDone = state.jobData && (state.jobData.status === 'done' || state.jobData.status === 'failed');
|
|
64
|
+
if (isDone) {
|
|
65
|
+
loadStreamBulk(jobId);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
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
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function loadStreamBulk(jobId) {
|
|
133
|
+
const state = streamState[jobId];
|
|
134
|
+
if (!state || state._bulkLoading) return;
|
|
135
|
+
state._bulkLoading = true;
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
const data = await apiFetch(`/api/jobs/${encodeURIComponent(jobId)}/stream?offset=${state.offset}`);
|
|
139
|
+
if (data.events && data.events.length > 0) {
|
|
140
|
+
state.events = state.events.concat(data.events);
|
|
141
|
+
state.offset = data.offset;
|
|
142
|
+
renderStreamEvents(jobId);
|
|
143
|
+
}
|
|
144
|
+
if (data.done || !data.events || data.events.length === 0) {
|
|
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;
|
|
150
|
+
renderStreamDone(jobId);
|
|
151
|
+
updateJobRowStatus(jobId, finalStatus);
|
|
152
|
+
const pvRow = document.querySelector(`tr[data-job-id="${CSS.escape(jobId + '__preview')}"]`);
|
|
153
|
+
if (pvRow) pvRow.remove();
|
|
154
|
+
}
|
|
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
|
+
}
|
|
168
|
+
} finally {
|
|
169
|
+
state._bulkLoading = false;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function stopStream(jobId) {
|
|
174
|
+
const state = streamState[jobId];
|
|
175
|
+
if (!state) return;
|
|
176
|
+
if (state._eventSource) {
|
|
177
|
+
state._eventSource.close();
|
|
178
|
+
state._eventSource = null;
|
|
179
|
+
}
|
|
180
|
+
if (state.timer) {
|
|
181
|
+
clearInterval(state.timer);
|
|
182
|
+
state.timer = null;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function pollStream(jobId) {
|
|
187
|
+
const state = streamState[jobId];
|
|
188
|
+
if (!state || state.done) {
|
|
189
|
+
stopStream(jobId);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
try {
|
|
194
|
+
const data = await apiFetch(`/api/jobs/${encodeURIComponent(jobId)}/stream?offset=${state.offset}`);
|
|
195
|
+
const events = data.events || [];
|
|
196
|
+
const newOffset = data.offset !== undefined ? data.offset : state.offset + events.length;
|
|
197
|
+
const done = !!data.done;
|
|
198
|
+
|
|
199
|
+
// 성공 시 실패 카운터 초기화 + 백오프 복원
|
|
200
|
+
if (state._pollFails > 0) {
|
|
201
|
+
state._pollFails = 0;
|
|
202
|
+
_setPollInterval(jobId, 500);
|
|
203
|
+
_clearPollWarning(jobId);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (events.length > 0) {
|
|
207
|
+
state.events = state.events.concat(events);
|
|
208
|
+
state.offset = newOffset;
|
|
209
|
+
state._lastEventTime = Date.now();
|
|
210
|
+
renderStreamEvents(jobId);
|
|
211
|
+
updateJobPreview(jobId);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (done) {
|
|
215
|
+
state.done = true;
|
|
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
|
+
|
|
223
|
+
renderStreamDone(jobId);
|
|
224
|
+
updateJobRowStatus(jobId, finalStatus);
|
|
225
|
+
notifyJobDone(jobId, finalStatus, state.jobData ? state.jobData.prompt : '');
|
|
226
|
+
const pvRow = document.querySelector(`tr[data-job-id="${CSS.escape(jobId + '__preview')}"]`);
|
|
227
|
+
if (pvRow) pvRow.remove();
|
|
228
|
+
|
|
229
|
+
// 즉시 전체 행 동기화 (액션 버튼, 필터 등)
|
|
230
|
+
fetchJobs();
|
|
231
|
+
}
|
|
232
|
+
} catch {
|
|
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();
|
|
303
|
+
}
|
|
304
|
+
// SSE 우선 시도, 실패 시 자동 폴링 전환
|
|
305
|
+
startSSEStream(jobId);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function renderStreamEvents(jobId) {
|
|
309
|
+
const container = document.getElementById(`streamContent-${jobId}`);
|
|
310
|
+
if (!container) return;
|
|
311
|
+
|
|
312
|
+
const state = streamState[jobId];
|
|
313
|
+
if (!state || state.events.length === 0) return;
|
|
314
|
+
|
|
315
|
+
if (!state.renderedCount) state.renderedCount = 0;
|
|
316
|
+
if (state.renderedCount >= state.events.length) return;
|
|
317
|
+
|
|
318
|
+
const wasAtBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 40;
|
|
319
|
+
|
|
320
|
+
if (state.renderedCount === 0) {
|
|
321
|
+
container.innerHTML = '';
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const fragment = document.createDocumentFragment();
|
|
325
|
+
for (let i = state.renderedCount; i < state.events.length; i++) {
|
|
326
|
+
const evt = state.events[i];
|
|
327
|
+
const type = (evt.type || 'text').toLowerCase();
|
|
328
|
+
const div = document.createElement('div');
|
|
329
|
+
div.className = 'stream-event';
|
|
330
|
+
|
|
331
|
+
switch (type) {
|
|
332
|
+
case 'tool_use':
|
|
333
|
+
div.classList.add('stream-event-tool');
|
|
334
|
+
div.innerHTML = `<span class="stream-tool-badge">${escapeHtml(evt.tool || 'Tool')}</span>
|
|
335
|
+
<span class="stream-tool-input">${escapeHtml(typeof evt.input === 'string' ? evt.input : JSON.stringify(evt.input || ''))}</span>`;
|
|
336
|
+
break;
|
|
337
|
+
case 'result':
|
|
338
|
+
div.classList.add('stream-event-result');
|
|
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
|
+
}
|
|
347
|
+
if (evt.session_id) {
|
|
348
|
+
const panel = document.getElementById(`streamPanel-${jobId}`);
|
|
349
|
+
if (panel) panel.dataset.sessionId = evt.session_id;
|
|
350
|
+
const jobRow = document.querySelector(`tr[data-job-id="${CSS.escape(jobId)}"]`);
|
|
351
|
+
if (jobRow) {
|
|
352
|
+
const cells = jobRow.querySelectorAll('td');
|
|
353
|
+
if (cells.length >= 5 && cells[4].textContent !== evt.session_id.slice(0, 8)) {
|
|
354
|
+
cells[4].textContent = evt.session_id.slice(0, 8);
|
|
355
|
+
cells[4].className = 'job-session clickable';
|
|
356
|
+
cells[4].title = evt.session_id;
|
|
357
|
+
const evtCwd = panel ? (panel.dataset.cwd || '') : '';
|
|
358
|
+
cells[4].setAttribute('onclick', `event.stopPropagation(); resumeFromJob('${escapeJsStr(evt.session_id)}', '', '${escapeJsStr(evtCwd)}')`);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
break;
|
|
363
|
+
case 'error':
|
|
364
|
+
div.classList.add('stream-event-error');
|
|
365
|
+
div.innerHTML = `<span class="stream-error-icon">✗</span>
|
|
366
|
+
<span class="stream-error-text">${escapeHtml(evt.text || evt.error || evt.message || 'Unknown error')}</span>`;
|
|
367
|
+
break;
|
|
368
|
+
case 'text':
|
|
369
|
+
default:
|
|
370
|
+
div.classList.add('stream-event-text');
|
|
371
|
+
div.textContent = evt.text || '';
|
|
372
|
+
break;
|
|
373
|
+
}
|
|
374
|
+
fragment.appendChild(div);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
container.appendChild(fragment);
|
|
378
|
+
state.renderedCount = state.events.length;
|
|
379
|
+
|
|
380
|
+
if (wasAtBottom) {
|
|
381
|
+
container.scrollTop = container.scrollHeight;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function renderStreamDone(jobId) {
|
|
386
|
+
const panel = document.getElementById(`streamPanel-${jobId}`);
|
|
387
|
+
if (!panel) return;
|
|
388
|
+
|
|
389
|
+
const state = streamState[jobId];
|
|
390
|
+
const status = state && state.jobData ? state.jobData.status : 'done';
|
|
391
|
+
const isFailed = status === 'failed';
|
|
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
|
+
|
|
409
|
+
let banner = panel.querySelector('.stream-done-banner');
|
|
410
|
+
if (!banner) {
|
|
411
|
+
banner = document.createElement('div');
|
|
412
|
+
banner.className = `stream-done-banner${isFailed ? ' failed' : ''}`;
|
|
413
|
+
banner.textContent = (isFailed ? `✗ ${t('stream_job_failed')}` : `✓ ${t('stream_job_done')}`) + bannerDetails;
|
|
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
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
let actions = panel.querySelector('.stream-actions');
|
|
437
|
+
if (!actions) {
|
|
438
|
+
actions = document.createElement('div');
|
|
439
|
+
actions.className = 'stream-actions';
|
|
440
|
+
actions.innerHTML = `
|
|
441
|
+
<button class="btn btn-sm" onclick="event.stopPropagation(); copyStreamResult('${escapeHtml(jobId)}')">
|
|
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>
|
|
443
|
+
${escapeHtml(t('stream_copy_all'))}
|
|
444
|
+
</button>
|
|
445
|
+
<button class="btn btn-sm btn-danger" onclick="event.stopPropagation(); deleteJob('${escapeHtml(jobId)}')">
|
|
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>
|
|
447
|
+
${escapeHtml(t('stream_delete_job'))}
|
|
448
|
+
</button>`;
|
|
449
|
+
panel.appendChild(actions);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const sessionId = panel.dataset.sessionId;
|
|
453
|
+
if (sessionId && !panel.querySelector('.stream-followup')) {
|
|
454
|
+
const followup = document.createElement('div');
|
|
455
|
+
followup.className = 'stream-followup';
|
|
456
|
+
followup.innerHTML = `
|
|
457
|
+
<span class="stream-followup-label">${escapeHtml(t('stream_followup_label'))}</span>
|
|
458
|
+
<div class="followup-input-wrap">
|
|
459
|
+
<input type="text" class="followup-input" id="followupInput-${escapeHtml(jobId)}"
|
|
460
|
+
placeholder="${escapeHtml(t('stream_followup_placeholder'))}"
|
|
461
|
+
onkeydown="if(event.key==='Enter'){event.stopPropagation();sendFollowUp('${escapeHtml(jobId)}')}"
|
|
462
|
+
onclick="event.stopPropagation()">
|
|
463
|
+
<button class="btn btn-primary btn-sm" onclick="event.stopPropagation(); sendFollowUp('${escapeHtml(jobId)}')" style="white-space:nowrap;">
|
|
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'))}
|
|
465
|
+
</button>
|
|
466
|
+
</div>
|
|
467
|
+
<div class="followup-previews" id="followupPreviews-${escapeHtml(jobId)}"></div>`;
|
|
468
|
+
panel.appendChild(followup);
|
|
469
|
+
|
|
470
|
+
const fInput = document.getElementById(`followupInput-${jobId}`);
|
|
471
|
+
if (fInput) {
|
|
472
|
+
fInput.addEventListener('paste', function(e) {
|
|
473
|
+
const files = e.clipboardData?.files;
|
|
474
|
+
if (files && files.length > 0) {
|
|
475
|
+
e.preventDefault();
|
|
476
|
+
handleFollowUpFiles(jobId, files);
|
|
477
|
+
}
|
|
478
|
+
});
|
|
479
|
+
fInput.addEventListener('drop', function(e) {
|
|
480
|
+
if (e.dataTransfer.files.length > 0) {
|
|
481
|
+
e.preventDefault();
|
|
482
|
+
handleFollowUpFiles(jobId, e.dataTransfer.files);
|
|
483
|
+
}
|
|
484
|
+
});
|
|
485
|
+
fInput.addEventListener('dragover', function(e) { e.preventDefault(); });
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function copyStreamResult(jobId) {
|
|
491
|
+
const state = streamState[jobId];
|
|
492
|
+
if (!state || state.events.length === 0) {
|
|
493
|
+
showToast(t('msg_copy_no_result'), 'error');
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const textParts = [];
|
|
498
|
+
for (const evt of state.events) {
|
|
499
|
+
const type = (evt.type || 'text').toLowerCase();
|
|
500
|
+
switch (type) {
|
|
501
|
+
case 'text':
|
|
502
|
+
if (evt.text) textParts.push(evt.text);
|
|
503
|
+
break;
|
|
504
|
+
case 'result': {
|
|
505
|
+
const r = typeof evt.result === 'string' ? evt.result : JSON.stringify(evt.result || '');
|
|
506
|
+
if (r) textParts.push(`[Result] ${r}`);
|
|
507
|
+
break;
|
|
508
|
+
}
|
|
509
|
+
case 'tool_use': {
|
|
510
|
+
const toolName = evt.tool || 'Tool';
|
|
511
|
+
const toolInput = typeof evt.input === 'string' ? evt.input : JSON.stringify(evt.input || '');
|
|
512
|
+
textParts.push(`[${toolName}] ${toolInput}`);
|
|
513
|
+
break;
|
|
514
|
+
}
|
|
515
|
+
case 'error': {
|
|
516
|
+
const errMsg = evt.text || evt.error || evt.message || 'Unknown error';
|
|
517
|
+
textParts.push(`[Error] ${errMsg}`);
|
|
518
|
+
break;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const text = textParts.join('\n').trim();
|
|
524
|
+
if (!text) {
|
|
525
|
+
showToast(t('msg_copy_no_text'), 'error');
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
navigator.clipboard.writeText(text).then(() => {
|
|
530
|
+
showToast(t('msg_copy_done'));
|
|
531
|
+
}).catch(() => {
|
|
532
|
+
showToast(t('msg_copy_failed'), 'error');
|
|
533
|
+
});
|
|
534
|
+
}
|