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
package/web/static/app.js
CHANGED
|
@@ -1,1572 +1,28 @@
|
|
|
1
1
|
/* ═══════════════════════════════════════════════
|
|
2
|
-
Controller Service Dashboard —
|
|
2
|
+
Controller Service Dashboard — Entry Point
|
|
3
|
+
|
|
4
|
+
모듈 로드 순서 (index.html에서):
|
|
5
|
+
1. i18n.js — 국제화
|
|
6
|
+
2. utils.js — 유틸리티
|
|
7
|
+
3. api.js — API 호출
|
|
8
|
+
4. context.js — 세션 컨텍스트
|
|
9
|
+
5. attachments.js — 파일 첨부
|
|
10
|
+
6. dirs.js — 디렉토리 브라우저
|
|
11
|
+
7. send.js — 작업 전송
|
|
12
|
+
8. stream.js — 스트림 폴링/렌더링
|
|
13
|
+
9. jobs.js — 작업 목록
|
|
14
|
+
10. pipelines.js — 자동화 파이프라인
|
|
15
|
+
11. settings.js — 설정
|
|
16
|
+
12. app.js — 초기화 (이 파일)
|
|
3
17
|
═══════════════════════════════════════════════ */
|
|
4
18
|
|
|
5
|
-
// ── 자동 연결 설정 ──
|
|
6
|
-
const LOCAL_BACKEND = 'http://localhost:8420';
|
|
7
|
-
let API = ''; // same-origin이면 '', 원격이면 LOCAL_BACKEND
|
|
8
|
-
let AUTH_TOKEN = ''; // 토큰 (선택적)
|
|
9
|
-
let _backendConnected = false;
|
|
10
|
-
let expandedJobId = null;
|
|
11
|
-
let jobPollTimer = null;
|
|
12
|
-
let serviceRunning = null;
|
|
13
|
-
|
|
14
|
-
// ── Context Management ──
|
|
15
|
-
let _contextMode = 'new'; // 'new' | 'resume' | 'fork'
|
|
16
|
-
let _contextSessionId = null;
|
|
17
|
-
let _contextSessionPrompt = null;
|
|
18
|
-
|
|
19
|
-
// ── Stream State ──
|
|
20
|
-
// Per-job stream state: { offset, timer, done, jobData }
|
|
21
|
-
const streamState = {};
|
|
22
|
-
|
|
23
|
-
// ── Toast Notifications ──
|
|
24
|
-
function showToast(message, type = 'success') {
|
|
25
|
-
const container = document.getElementById('toastContainer');
|
|
26
|
-
const toast = document.createElement('div');
|
|
27
|
-
toast.className = `toast ${type}`;
|
|
28
|
-
const icon = type === 'success'
|
|
29
|
-
? '<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>'
|
|
30
|
-
: '<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>';
|
|
31
|
-
toast.innerHTML = `${icon} ${escapeHtml(message)}`;
|
|
32
|
-
container.appendChild(toast);
|
|
33
|
-
setTimeout(() => { if (toast.parentNode) toast.remove(); }, 3000);
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
// ── Utility ──
|
|
37
|
-
function escapeHtml(str) {
|
|
38
|
-
if (!str) return '';
|
|
39
|
-
const d = document.createElement('div');
|
|
40
|
-
d.textContent = str;
|
|
41
|
-
return d.innerHTML;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function truncate(str, len = 60) {
|
|
45
|
-
if (!str) return '-';
|
|
46
|
-
return str.length > len ? str.slice(0, len) + '...' : str;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function renderPromptHtml(prompt) {
|
|
50
|
-
if (!prompt) return '-';
|
|
51
|
-
const text = truncate(prompt, 200);
|
|
52
|
-
const escaped = escapeHtml(text);
|
|
53
|
-
return escaped.replace(/@(\/[^\s,]+|image\d+)/g, (match, ref) => {
|
|
54
|
-
const isImage = ref.startsWith('image');
|
|
55
|
-
const label = isImage ? ref : ref.split('/').pop();
|
|
56
|
-
const icon = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><polyline points="13 2 13 9 20 9"/></svg>';
|
|
57
|
-
return `<span class="prompt-img-chip" title="${escapeHtml('@' + ref)}">${icon}${escapeHtml(label)}</span>`;
|
|
58
|
-
});
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function formatTime(ts) {
|
|
62
|
-
if (!ts) return '-';
|
|
63
|
-
try {
|
|
64
|
-
const d = new Date(ts);
|
|
65
|
-
if (isNaN(d.getTime())) return ts;
|
|
66
|
-
const pad = n => String(n).padStart(2, '0');
|
|
67
|
-
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
|
68
|
-
} catch { return ts; }
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
function formatCwd(cwd) {
|
|
72
|
-
if (!cwd) return '-';
|
|
73
|
-
const parts = cwd.replace(/\/$/, '').split('/');
|
|
74
|
-
return parts[parts.length - 1] || cwd;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
function formatElapsed(startTs) {
|
|
78
|
-
if (!startTs) return '--:--';
|
|
79
|
-
const start = new Date(startTs).getTime();
|
|
80
|
-
if (isNaN(start)) return '--:--';
|
|
81
|
-
const elapsed = Math.max(0, Math.floor((Date.now() - start) / 1000));
|
|
82
|
-
const h = Math.floor(elapsed / 3600);
|
|
83
|
-
const m = Math.floor((elapsed % 3600) / 60);
|
|
84
|
-
const s = elapsed % 60;
|
|
85
|
-
const pad = n => String(n).padStart(2, '0');
|
|
86
|
-
if (h > 0) return `${h}:${pad(m)}:${pad(s)}`;
|
|
87
|
-
return `${pad(m)}:${pad(s)}`;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
// ── Context Management ──
|
|
91
|
-
function setContextMode(mode) {
|
|
92
|
-
_contextMode = mode;
|
|
93
|
-
_contextSessionId = null;
|
|
94
|
-
_contextSessionPrompt = null;
|
|
95
|
-
_updateContextUI();
|
|
96
|
-
_closeSessionPicker();
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
function clearContext() { setContextMode('new'); }
|
|
100
|
-
|
|
101
|
-
function _updateContextUI() {
|
|
102
|
-
document.getElementById('ctxNew').classList.toggle('active', _contextMode === 'new');
|
|
103
|
-
document.getElementById('ctxResume').classList.toggle('active', _contextMode === 'resume');
|
|
104
|
-
document.getElementById('ctxFork').classList.toggle('active', _contextMode === 'fork');
|
|
105
|
-
const label = document.getElementById('ctxSessionLabel');
|
|
106
|
-
if (_contextSessionId) {
|
|
107
|
-
const tag = _contextMode === 'resume' ? 'resume' : 'fork';
|
|
108
|
-
const shortId = _contextSessionId.slice(0, 8);
|
|
109
|
-
const p = _contextSessionPrompt ? ' \u00b7 ' + _contextSessionPrompt : '';
|
|
110
|
-
label.textContent = tag + ' \u00b7 ' + shortId + p;
|
|
111
|
-
label.classList.add('visible');
|
|
112
|
-
} else {
|
|
113
|
-
label.classList.remove('visible');
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
// 세션 데이터 캐시 (검색 필터링용)
|
|
118
|
-
let _sessionCache = [];
|
|
119
|
-
let _sessionAllCache = []; // 프로젝트 필터 해제 시 전체 캐시
|
|
120
|
-
let _sessionProjectFilter = true; // true: 선택된 프로젝트만, false: 전체
|
|
121
|
-
|
|
122
|
-
function _formatCwdShort(cwd) {
|
|
123
|
-
if (!cwd) return '';
|
|
124
|
-
const parts = cwd.replace(/\/$/, '').split('/');
|
|
125
|
-
return parts[parts.length - 1] || cwd;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
function _renderSessionItem(s) {
|
|
129
|
-
const statusClass = s.status || 'unknown';
|
|
130
|
-
const jobLabel = s.job_id ? '#' + s.job_id : '';
|
|
131
|
-
const cwdShort = _formatCwdShort(s.cwd);
|
|
132
|
-
const costLabel = s.cost_usd != null ? '$' + Number(s.cost_usd).toFixed(4) : '';
|
|
133
|
-
const timeLabel = s.timestamp ? s.timestamp.replace(/^\d{4}-/, '') : '';
|
|
134
|
-
return '<div class="session-item" data-sid="' + escapeHtml(s.session_id)
|
|
135
|
-
+ '" data-prompt="' + escapeHtml(s.prompt || '')
|
|
136
|
-
+ '" data-cwd="' + escapeHtml(s.cwd || '') + '">'
|
|
137
|
-
+ '<div class="session-item-row">'
|
|
138
|
-
+ '<span class="session-item-status ' + escapeHtml(statusClass) + '"></span>'
|
|
139
|
-
+ '<span class="session-item-id">' + escapeHtml(s.session_id.slice(0, 8)) + '</span>'
|
|
140
|
-
+ (s.slug ? '<span class="session-item-slug">' + escapeHtml(s.slug) + '</span>' : '')
|
|
141
|
-
+ (jobLabel ? '<span class="session-item-job">' + escapeHtml(jobLabel) + '</span>' : '')
|
|
142
|
-
+ '<span class="session-item-prompt">' + escapeHtml(s.prompt || '(프롬프트 없음)') + '</span>'
|
|
143
|
-
+ '</div>'
|
|
144
|
-
+ '<div class="session-item-meta">'
|
|
145
|
-
+ '<span class="session-item-time">' + escapeHtml(timeLabel) + '</span>'
|
|
146
|
-
+ (cwdShort ? '<span class="session-item-cwd">' + escapeHtml(cwdShort) + '</span>' : '')
|
|
147
|
-
+ (costLabel ? '<span class="session-item-cost">' + escapeHtml(costLabel) + '</span>' : '')
|
|
148
|
-
+ '</div>'
|
|
149
|
-
+ '</div>';
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
function _renderSessionList(sessions, grouped) {
|
|
153
|
-
const list = document.getElementById('sessionPickerList');
|
|
154
|
-
if (!sessions || sessions.length === 0) {
|
|
155
|
-
list.innerHTML = '<div class="session-empty"><div class="session-empty-icon">💭</div>저장된 세션이 없습니다.</div>';
|
|
156
|
-
return;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
// 프로젝트 필터가 꺼져 있고 그룹핑 요청인 경우 → CWD 기준 그룹핑
|
|
160
|
-
if (grouped) {
|
|
161
|
-
const groups = {};
|
|
162
|
-
const noProject = [];
|
|
163
|
-
sessions.forEach(function(s) {
|
|
164
|
-
if (s.cwd) {
|
|
165
|
-
const key = s.cwd;
|
|
166
|
-
if (!groups[key]) groups[key] = [];
|
|
167
|
-
groups[key].push(s);
|
|
168
|
-
} else {
|
|
169
|
-
noProject.push(s);
|
|
170
|
-
}
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
// 그룹을 최신 세션 기준으로 정렬
|
|
174
|
-
const sortedKeys = Object.keys(groups).sort(function(a, b) {
|
|
175
|
-
const aTs = groups[a][0] ? groups[a][0].timestamp || '' : '';
|
|
176
|
-
const bTs = groups[b][0] ? groups[b][0].timestamp || '' : '';
|
|
177
|
-
return bTs.localeCompare(aTs);
|
|
178
|
-
});
|
|
179
|
-
|
|
180
|
-
let html = '';
|
|
181
|
-
sortedKeys.forEach(function(cwd) {
|
|
182
|
-
const items = groups[cwd];
|
|
183
|
-
const name = _formatCwdShort(cwd);
|
|
184
|
-
html += '<div class="session-group-header">'
|
|
185
|
-
+ '<svg 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>'
|
|
186
|
-
+ escapeHtml(name)
|
|
187
|
-
+ '<span class="session-group-count">' + items.length + '</span>'
|
|
188
|
-
+ '</div>';
|
|
189
|
-
html += items.map(_renderSessionItem).join('');
|
|
190
|
-
});
|
|
191
|
-
|
|
192
|
-
if (noProject.length > 0) {
|
|
193
|
-
html += '<div class="session-group-header">'
|
|
194
|
-
+ '<svg 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>'
|
|
195
|
-
+ t('no_project')
|
|
196
|
-
+ '<span class="session-group-count">' + noProject.length + '</span>'
|
|
197
|
-
+ '</div>';
|
|
198
|
-
html += noProject.map(_renderSessionItem).join('');
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
list.innerHTML = html;
|
|
202
|
-
return;
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
list.innerHTML = sessions.map(_renderSessionItem).join('');
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
function _filterSessions(query) {
|
|
209
|
-
const useGrouped = !_sessionProjectFilter; // 전체 보기일 때만 그룹핑
|
|
210
|
-
if (!query || !query.trim()) {
|
|
211
|
-
_renderSessionList(_sessionCache, useGrouped);
|
|
212
|
-
return;
|
|
213
|
-
}
|
|
214
|
-
const q = query.toLowerCase();
|
|
215
|
-
const filtered = _sessionCache.filter(function(s) {
|
|
216
|
-
return (s.prompt && s.prompt.toLowerCase().includes(q))
|
|
217
|
-
|| (s.session_id && s.session_id.toLowerCase().includes(q))
|
|
218
|
-
|| (s.job_id && String(s.job_id).includes(q))
|
|
219
|
-
|| (s.cwd && s.cwd.toLowerCase().includes(q));
|
|
220
|
-
});
|
|
221
|
-
_renderSessionList(filtered, useGrouped);
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
async function openSessionPicker(mode) {
|
|
225
|
-
const picker = document.getElementById('sessionPicker');
|
|
226
|
-
const wasOpen = picker.classList.contains('open');
|
|
227
|
-
if (wasOpen && _contextMode === mode && !_contextSessionId) {
|
|
228
|
-
_closeSessionPicker();
|
|
229
|
-
setContextMode('new');
|
|
230
|
-
return;
|
|
231
|
-
}
|
|
232
|
-
_contextMode = mode;
|
|
233
|
-
_contextSessionId = null;
|
|
234
|
-
_contextSessionPrompt = null;
|
|
235
|
-
_updateContextUI();
|
|
236
|
-
document.getElementById('sessionPickerTitle').textContent =
|
|
237
|
-
t('select_session');
|
|
238
|
-
const list = document.getElementById('sessionPickerList');
|
|
239
|
-
list.innerHTML = '<div class="session-empty"><span class="spinner" style="display:block;margin:0 auto 8px;"></span></div>';
|
|
240
|
-
// 검색 초기화
|
|
241
|
-
const searchInput = document.getElementById('sessionSearchInput');
|
|
242
|
-
if (searchInput) searchInput.value = '';
|
|
243
|
-
picker.classList.add('open');
|
|
244
|
-
|
|
245
|
-
// 선택된 cwd 확인 → 프로젝트 필터 적용
|
|
246
|
-
const selectedCwd = document.getElementById('cwdInput').value || '';
|
|
247
|
-
const filterBar = document.getElementById('sessionFilterBar');
|
|
248
|
-
const filterBtn = document.getElementById('sessionFilterBtn');
|
|
249
|
-
const filterProject = document.getElementById('sessionFilterProject');
|
|
250
|
-
|
|
251
|
-
if (selectedCwd) {
|
|
252
|
-
filterBar.style.display = 'flex';
|
|
253
|
-
const projectName = _formatCwdShort(selectedCwd);
|
|
254
|
-
filterProject.textContent = selectedCwd;
|
|
255
|
-
_sessionProjectFilter = true;
|
|
256
|
-
filterBtn.classList.add('active');
|
|
257
|
-
} else {
|
|
258
|
-
filterBar.style.display = 'none';
|
|
259
|
-
_sessionProjectFilter = false;
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
try {
|
|
263
|
-
// 프로젝트 필터가 활성화되면 cwd 기준으로 세션 로드
|
|
264
|
-
if (selectedCwd && _sessionProjectFilter) {
|
|
265
|
-
const [filtered, all] = await Promise.all([
|
|
266
|
-
apiFetch('/api/sessions?cwd=' + encodeURIComponent(selectedCwd)),
|
|
267
|
-
apiFetch('/api/sessions'),
|
|
268
|
-
]);
|
|
269
|
-
_sessionCache = Array.isArray(filtered) ? filtered : [];
|
|
270
|
-
_sessionAllCache = Array.isArray(all) ? all : [];
|
|
271
|
-
} else {
|
|
272
|
-
const sessions = await apiFetch('/api/sessions');
|
|
273
|
-
_sessionCache = Array.isArray(sessions) ? sessions : [];
|
|
274
|
-
_sessionAllCache = _sessionCache;
|
|
275
|
-
}
|
|
276
|
-
const useGrouped = !_sessionProjectFilter;
|
|
277
|
-
_renderSessionList(_sessionCache, useGrouped);
|
|
278
|
-
} catch (err) {
|
|
279
|
-
const msg = err.message || '알 수 없는 오류';
|
|
280
|
-
let displayMsg = msg;
|
|
281
|
-
try {
|
|
282
|
-
const parsed = JSON.parse(msg);
|
|
283
|
-
if (parsed.error) displayMsg = parsed.error;
|
|
284
|
-
} catch(_) {}
|
|
285
|
-
list.innerHTML = '<div class="session-empty"><div class="session-empty-icon">⚠️</div>세션 로드 실패<br><span style="font-size:0.7rem;color:var(--text-muted)">' + escapeHtml(displayMsg) + '</span></div>';
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
function _toggleProjectFilter() {
|
|
290
|
-
_sessionProjectFilter = !_sessionProjectFilter;
|
|
291
|
-
const filterBtn = document.getElementById('sessionFilterBtn');
|
|
292
|
-
filterBtn.classList.toggle('active', _sessionProjectFilter);
|
|
293
|
-
// 캐시 전환
|
|
294
|
-
if (_sessionProjectFilter) {
|
|
295
|
-
// 필터된 결과로 전환
|
|
296
|
-
const selectedCwd = document.getElementById('cwdInput').value || '';
|
|
297
|
-
if (selectedCwd) {
|
|
298
|
-
_sessionCache = _sessionAllCache.filter(function(s) {
|
|
299
|
-
return s.cwd && s.cwd.replace(/\/$/, '') === selectedCwd.replace(/\/$/, '');
|
|
300
|
-
});
|
|
301
|
-
}
|
|
302
|
-
} else {
|
|
303
|
-
_sessionCache = _sessionAllCache;
|
|
304
|
-
}
|
|
305
|
-
// 검색어가 있으면 재필터
|
|
306
|
-
const searchInput = document.getElementById('sessionSearchInput');
|
|
307
|
-
_filterSessions(searchInput ? searchInput.value : '');
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
function _selectSession(sid, prompt, cwd) {
|
|
311
|
-
_contextSessionId = sid;
|
|
312
|
-
_contextSessionPrompt = prompt;
|
|
313
|
-
_updateContextUI();
|
|
314
|
-
_closeSessionPicker();
|
|
315
|
-
// 세션의 cwd로 디렉터리 + 최근 프로젝트 칩 동기화
|
|
316
|
-
if (cwd) {
|
|
317
|
-
addRecentDir(cwd);
|
|
318
|
-
selectRecentDir(cwd);
|
|
319
|
-
}
|
|
320
|
-
showToast(t('msg_session_select') + ': ' + sid.slice(0, 8) + '...');
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
function _closeSessionPicker() {
|
|
324
|
-
document.getElementById('sessionPicker').classList.remove('open');
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
document.addEventListener('click', function(e) {
|
|
328
|
-
var item = e.target.closest('.session-item');
|
|
329
|
-
if (item && item.dataset.sid) {
|
|
330
|
-
_selectSession(item.dataset.sid, item.dataset.prompt || '', item.dataset.cwd || '');
|
|
331
|
-
return;
|
|
332
|
-
}
|
|
333
|
-
var picker = document.getElementById('sessionPicker');
|
|
334
|
-
if (picker && picker.classList.contains('open')
|
|
335
|
-
&& !e.target.closest('.ctx-toolbar') && !e.target.closest('.session-picker')) {
|
|
336
|
-
_closeSessionPicker();
|
|
337
|
-
}
|
|
338
|
-
});
|
|
339
|
-
|
|
340
|
-
// ── API Helpers ──
|
|
341
|
-
async function apiFetch(path, options = {}) {
|
|
342
|
-
const headers = { 'Content-Type': 'application/json', ...options.headers };
|
|
343
|
-
if (AUTH_TOKEN) {
|
|
344
|
-
headers['Authorization'] = `Bearer ${AUTH_TOKEN}`;
|
|
345
|
-
}
|
|
346
|
-
try {
|
|
347
|
-
const resp = await fetch(`${API}${path}`, { ...options, headers });
|
|
348
|
-
if (!resp.ok) {
|
|
349
|
-
const text = await resp.text().catch(() => '');
|
|
350
|
-
throw new Error(text || `HTTP ${resp.status}`);
|
|
351
|
-
}
|
|
352
|
-
const ct = resp.headers.get('content-type') || '';
|
|
353
|
-
if (ct.includes('application/json')) return resp.json();
|
|
354
|
-
return resp.text();
|
|
355
|
-
} catch (err) {
|
|
356
|
-
throw err;
|
|
357
|
-
}
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
// ── Service Status ──
|
|
361
|
-
async function checkStatus() {
|
|
362
|
-
try {
|
|
363
|
-
const data = await apiFetch('/api/status');
|
|
364
|
-
const running = data.running !== undefined ? data.running : (data.status === 'running');
|
|
365
|
-
serviceRunning = running;
|
|
366
|
-
} catch {
|
|
367
|
-
serviceRunning = null;
|
|
368
|
-
}
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
// ── Service Actions ──
|
|
372
|
-
async function serviceAction(action) {
|
|
373
|
-
const btn = document.getElementById(`btn${action.charAt(0).toUpperCase() + action.slice(1)}`);
|
|
374
|
-
if (btn) btn.disabled = true;
|
|
375
|
-
try {
|
|
376
|
-
if (action === 'restart') {
|
|
377
|
-
await apiFetch('/api/service/stop', { method: 'POST' });
|
|
378
|
-
await new Promise(r => setTimeout(r, 500));
|
|
379
|
-
await apiFetch('/api/service/start', { method: 'POST' });
|
|
380
|
-
} else {
|
|
381
|
-
await apiFetch(`/api/service/${action}`, { method: 'POST' });
|
|
382
|
-
}
|
|
383
|
-
showToast(t(action === 'start' ? 'msg_service_start' : action === 'stop' ? 'msg_service_stop' : 'msg_service_restart'));
|
|
384
|
-
setTimeout(checkStatus, 1000);
|
|
385
|
-
} catch (err) {
|
|
386
|
-
showToast(`${t('msg_service_failed')}: ${err.message}`, 'error');
|
|
387
|
-
} finally {
|
|
388
|
-
if (btn) btn.disabled = false;
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
// ── Attachments State ──
|
|
393
|
-
const attachments = [];
|
|
394
|
-
|
|
395
|
-
function updateAttachBadge() {
|
|
396
|
-
// @imageN 이 textarea에 직접 삽입되므로 배지는 사용하지 않음
|
|
397
|
-
document.getElementById('imgCountBadge').textContent = '';
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
function insertAtCursor(textarea, text) {
|
|
401
|
-
const start = textarea.selectionStart;
|
|
402
|
-
const end = textarea.selectionEnd;
|
|
403
|
-
const before = textarea.value.substring(0, start);
|
|
404
|
-
const after = textarea.value.substring(end);
|
|
405
|
-
// 앞에 공백이 없으면 추가
|
|
406
|
-
const space = (before.length > 0 && !before.endsWith(' ') && !before.endsWith('\n')) ? ' ' : '';
|
|
407
|
-
textarea.value = before + space + text + ' ' + after;
|
|
408
|
-
const newPos = start + space.length + text.length + 1;
|
|
409
|
-
textarea.selectionStart = textarea.selectionEnd = newPos;
|
|
410
|
-
textarea.focus();
|
|
411
|
-
updatePromptMirror();
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
function updatePromptMirror() {
|
|
415
|
-
const ta = document.getElementById('promptInput');
|
|
416
|
-
const mirror = document.getElementById('promptMirror');
|
|
417
|
-
if (!ta || !mirror) return;
|
|
418
|
-
const val = ta.value;
|
|
419
|
-
if (!val) {
|
|
420
|
-
mirror.innerHTML = '';
|
|
421
|
-
syncAttachmentsFromText('');
|
|
422
|
-
return;
|
|
423
|
-
}
|
|
424
|
-
const escaped = escapeHtml(val);
|
|
425
|
-
// Color-only highlight: keep exact text, just wrap @refs in a colored span
|
|
426
|
-
mirror.innerHTML = escaped.replace(/@(\/[^\s,]+|image\d+)/g, (match) => {
|
|
427
|
-
return `<span class="prompt-at-ref">${escapeHtml(match)}</span>`;
|
|
428
|
-
}) + '\n';
|
|
429
|
-
mirror.scrollTop = ta.scrollTop;
|
|
430
|
-
syncAttachmentsFromText(val);
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
// @imageN 참조가 textarea에서 사라지면 해당 첨부를 자동 제거
|
|
434
|
-
function syncAttachmentsFromText(text) {
|
|
435
|
-
const container = document.getElementById('attachmentPreviews');
|
|
436
|
-
if (!container) return;
|
|
437
|
-
let changed = false;
|
|
438
|
-
attachments.forEach((att, idx) => {
|
|
439
|
-
if (!att) return;
|
|
440
|
-
const ref = `@image${idx}`;
|
|
441
|
-
if (!text.includes(ref)) {
|
|
442
|
-
attachments[idx] = null;
|
|
443
|
-
const thumb = container.querySelector(`[data-idx="${idx}"]`);
|
|
444
|
-
if (thumb) thumb.remove();
|
|
445
|
-
changed = true;
|
|
446
|
-
}
|
|
447
|
-
});
|
|
448
|
-
if (changed) updateAttachBadge();
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
function formatFileSize(bytes) {
|
|
452
|
-
if (bytes < 1024) return bytes + ' B';
|
|
453
|
-
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
|
454
|
-
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
function getFileExt(filename) {
|
|
458
|
-
const dot = filename.lastIndexOf('.');
|
|
459
|
-
return dot >= 0 ? filename.slice(dot + 1).toUpperCase() : '?';
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
async function uploadFile(file) {
|
|
463
|
-
return new Promise((resolve, reject) => {
|
|
464
|
-
const reader = new FileReader();
|
|
465
|
-
reader.onload = async () => {
|
|
466
|
-
try {
|
|
467
|
-
const data = await apiFetch('/api/upload', {
|
|
468
|
-
method: 'POST',
|
|
469
|
-
body: JSON.stringify({ filename: file.name, data: reader.result }),
|
|
470
|
-
});
|
|
471
|
-
resolve(data);
|
|
472
|
-
} catch (err) {
|
|
473
|
-
reject(err);
|
|
474
|
-
}
|
|
475
|
-
};
|
|
476
|
-
reader.onerror = () => reject(new Error(t('msg_file_read_failed')));
|
|
477
|
-
reader.readAsDataURL(file);
|
|
478
|
-
});
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
function removeAttachment(idx) {
|
|
482
|
-
attachments[idx] = null;
|
|
483
|
-
const container = document.getElementById('attachmentPreviews');
|
|
484
|
-
const thumb = container.querySelector(`[data-idx="${idx}"]`);
|
|
485
|
-
if (thumb) thumb.remove();
|
|
486
|
-
// textarea에서 @imageN 참조도 제거
|
|
487
|
-
const ta = document.getElementById('promptInput');
|
|
488
|
-
ta.value = ta.value.replace(new RegExp(`\\s*@image${idx}\\b`, 'g'), '');
|
|
489
|
-
updateAttachBadge();
|
|
490
|
-
updatePromptMirror();
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
function clearAttachments() {
|
|
494
|
-
attachments.length = 0;
|
|
495
|
-
document.getElementById('attachmentPreviews').innerHTML = '';
|
|
496
|
-
updateAttachBadge();
|
|
497
|
-
updatePromptMirror();
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
function clearPromptForm() {
|
|
501
|
-
document.getElementById('promptInput').value = '';
|
|
502
|
-
clearAttachments();
|
|
503
|
-
updatePromptMirror();
|
|
504
|
-
if (typeof clearDirSelection === 'function') clearDirSelection();
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
function openFilePicker() {
|
|
508
|
-
document.getElementById('filePickerInput').click();
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
async function handleFiles(files) {
|
|
512
|
-
const container = document.getElementById('attachmentPreviews');
|
|
513
|
-
for (const file of files) {
|
|
514
|
-
const isImage = file.type.startsWith('image/');
|
|
515
|
-
const localUrl = isImage ? URL.createObjectURL(file) : null;
|
|
516
|
-
|
|
517
|
-
const tempIdx = attachments.length;
|
|
518
|
-
attachments.push({ localUrl, serverPath: null, filename: file.name, isImage, size: file.size });
|
|
519
|
-
|
|
520
|
-
const thumb = document.createElement('div');
|
|
521
|
-
thumb.dataset.idx = tempIdx;
|
|
522
|
-
|
|
523
|
-
if (isImage) {
|
|
524
|
-
thumb.className = 'img-thumb uploading';
|
|
525
|
-
thumb.innerHTML = `<img src="${localUrl}" alt="${escapeHtml(file.name)}">
|
|
526
|
-
<button class="img-remove" onclick="removeAttachment(${tempIdx})" title="제거">×</button>`;
|
|
527
|
-
} else {
|
|
528
|
-
thumb.className = 'file-thumb uploading';
|
|
529
|
-
thumb.innerHTML = `
|
|
530
|
-
<div class="file-icon">${escapeHtml(getFileExt(file.name))}</div>
|
|
531
|
-
<div class="file-info">
|
|
532
|
-
<div class="file-name" title="${escapeHtml(file.name)}">${escapeHtml(file.name)}</div>
|
|
533
|
-
<div class="file-size">${formatFileSize(file.size)}</div>
|
|
534
|
-
</div>
|
|
535
|
-
<button class="file-remove" onclick="removeAttachment(${tempIdx})" title="제거">×</button>`;
|
|
536
|
-
}
|
|
537
|
-
container.appendChild(thumb);
|
|
538
|
-
updateAttachBadge();
|
|
539
|
-
|
|
540
|
-
try {
|
|
541
|
-
const data = await uploadFile(file);
|
|
542
|
-
// 업로드 중 사용자가 첨부를 제거했거나 폼이 초기화된 경우 무시
|
|
543
|
-
if (!attachments[tempIdx]) continue;
|
|
544
|
-
attachments[tempIdx].serverPath = data.path;
|
|
545
|
-
attachments[tempIdx].filename = data.filename || file.name;
|
|
546
|
-
thumb.classList.remove('uploading');
|
|
547
|
-
// textarea에 @imageN 삽입
|
|
548
|
-
const ta = document.getElementById('promptInput');
|
|
549
|
-
insertAtCursor(ta, `@image${tempIdx}`);
|
|
550
|
-
} catch (err) {
|
|
551
|
-
showToast(`${t('msg_upload_failed')}: ${escapeHtml(file.name)} — ${err.message}`, 'error');
|
|
552
|
-
if (attachments[tempIdx]) attachments[tempIdx] = null;
|
|
553
|
-
if (thumb.parentNode) thumb.remove();
|
|
554
|
-
updateAttachBadge();
|
|
555
|
-
}
|
|
556
|
-
}
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
// ── Send Lock (중복 전송 방지) ──
|
|
560
|
-
let _sendLock = false;
|
|
561
|
-
|
|
562
|
-
// ── Send Task ──
|
|
563
|
-
async function sendTask(e) {
|
|
564
|
-
e.preventDefault();
|
|
565
|
-
if (_sendLock) return false;
|
|
566
|
-
|
|
567
|
-
const prompt = document.getElementById('promptInput').value.trim();
|
|
568
|
-
if (!prompt) {
|
|
569
|
-
showToast(t('msg_prompt_required'), 'error');
|
|
570
|
-
return false;
|
|
571
|
-
}
|
|
572
|
-
|
|
573
|
-
_sendLock = true;
|
|
574
|
-
const cwd = document.getElementById('cwdInput').value.trim();
|
|
575
|
-
const btn = document.getElementById('btnSend');
|
|
576
|
-
btn.disabled = true;
|
|
577
|
-
btn.innerHTML = '<span class="spinner"></span> 전송 중...';
|
|
578
|
-
|
|
579
|
-
try {
|
|
580
|
-
// @imageN → @/actual/server/path 치환
|
|
581
|
-
let finalPrompt = prompt;
|
|
582
|
-
const filePaths = [];
|
|
583
|
-
attachments.forEach((att, idx) => {
|
|
584
|
-
if (att && att.serverPath) {
|
|
585
|
-
finalPrompt = finalPrompt.replace(new RegExp(`@image${idx}\\b`, 'g'), `@${att.serverPath}`);
|
|
586
|
-
filePaths.push(att.serverPath);
|
|
587
|
-
}
|
|
588
|
-
});
|
|
589
|
-
|
|
590
|
-
const body = { prompt: finalPrompt };
|
|
591
|
-
if (cwd) body.cwd = cwd;
|
|
592
|
-
|
|
593
|
-
// Context mode: resume or fork adds session info
|
|
594
|
-
if (_contextSessionId && (_contextMode === 'resume' || _contextMode === 'fork')) {
|
|
595
|
-
body.session = _contextMode + ':' + _contextSessionId;
|
|
596
|
-
}
|
|
597
|
-
|
|
598
|
-
if (filePaths.length > 0) body.images = filePaths;
|
|
599
|
-
|
|
600
|
-
await apiFetch('/api/send', { method: 'POST', body: JSON.stringify(body) });
|
|
601
|
-
const modeMsg = _contextMode === 'resume' ? ' (resume)' : _contextMode === 'fork' ? ' (fork)' : '';
|
|
602
|
-
showToast(t('msg_task_sent') + modeMsg);
|
|
603
|
-
if (cwd) addRecentDir(cwd);
|
|
604
|
-
document.getElementById('promptInput').value = '';
|
|
605
|
-
clearAttachments();
|
|
606
|
-
clearContext();
|
|
607
|
-
fetchJobs();
|
|
608
|
-
} catch (err) {
|
|
609
|
-
showToast(`${t('msg_send_failed')}: ${err.message}`, 'error');
|
|
610
|
-
} finally {
|
|
611
|
-
_sendLock = false;
|
|
612
|
-
btn.disabled = false;
|
|
613
|
-
btn.innerHTML = '<svg width="14" height="14" 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> <span data-i18n="send">' + t('send') + '</span>';
|
|
614
|
-
}
|
|
615
|
-
return false;
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
// ── Job List ──
|
|
619
|
-
function statusBadgeHtml(status) {
|
|
620
|
-
const s = (status || 'unknown').toLowerCase();
|
|
621
|
-
const labels = { running: t('status_running'), done: t('status_done'), failed: t('status_failed'), pending: t('status_pending') };
|
|
622
|
-
const cls = { running: 'badge-running', done: 'badge-done', failed: 'badge-failed', pending: 'badge-pending' };
|
|
623
|
-
return `<span class="badge ${cls[s] || 'badge-pending'}">${labels[s] || s}</span>`;
|
|
624
|
-
}
|
|
625
|
-
|
|
626
|
-
function jobActionsHtml(id, status, sessionId, cwd) {
|
|
627
|
-
const isRunning = status === 'running';
|
|
628
|
-
const escapedCwd = escapeHtml(cwd || '');
|
|
629
|
-
let btns = '';
|
|
630
|
-
if (!isRunning) {
|
|
631
|
-
btns += `<button class="btn-retry-job" onclick="event.stopPropagation(); retryJob('${escapeHtml(id)}')" title="같은 프롬프트로 다시 실행"><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></button>`;
|
|
632
|
-
}
|
|
633
|
-
if (sessionId) {
|
|
634
|
-
btns += `<button class="btn-continue-job" onclick="event.stopPropagation(); openFollowUp('${escapeHtml(id)}')" title="세션 이어서 명령 (resume)"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="13 17 18 12 13 7"/><polyline points="6 17 11 12 6 7"/></svg></button>`;
|
|
635
|
-
btns += `<button class="btn-fork-job" onclick="event.stopPropagation(); quickForkSession('${escapeHtml(sessionId)}', '${escapedCwd}')" title="이 세션에서 분기 (fork)" style="color:var(--yellow);"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><circle cx="18" cy="6" r="3"/><path d="M6 9v3c0 3.3 2.7 6 6 6h3"/></svg></button>`;
|
|
636
|
-
}
|
|
637
|
-
if (!isRunning) {
|
|
638
|
-
btns += `<button class="btn-delete-job" onclick="event.stopPropagation(); deleteJob('${escapeHtml(id)}')" title="작업 제거"><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></button>`;
|
|
639
|
-
}
|
|
640
|
-
if (!btns) return '';
|
|
641
|
-
return `<div style="display:flex; align-items:center; gap:4px;">${btns}</div>`;
|
|
642
|
-
}
|
|
643
|
-
|
|
644
|
-
// 작업 목록에서 직접 fork 세션 선택
|
|
645
|
-
function quickForkSession(sessionId, cwd) {
|
|
646
|
-
_contextMode = 'fork';
|
|
647
|
-
_contextSessionId = sessionId;
|
|
648
|
-
_contextSessionPrompt = null;
|
|
649
|
-
_updateContextUI();
|
|
650
|
-
// 세션의 cwd로 디렉터리 + 최근 프로젝트 칩 동기화
|
|
651
|
-
if (cwd) {
|
|
652
|
-
addRecentDir(cwd);
|
|
653
|
-
selectRecentDir(cwd);
|
|
654
|
-
}
|
|
655
|
-
showToast(t('msg_fork_mode') + ' (' + sessionId.slice(0, 8) + '...). ' + t('msg_fork_input'));
|
|
656
|
-
document.getElementById('promptInput').focus();
|
|
657
|
-
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
658
|
-
}
|
|
659
|
-
|
|
660
|
-
// 작업 목록의 세션 ID 클릭 → resume 모드로 전환
|
|
661
|
-
function resumeFromJob(sessionId, promptHint, cwd) {
|
|
662
|
-
_contextMode = 'resume';
|
|
663
|
-
_contextSessionId = sessionId;
|
|
664
|
-
_contextSessionPrompt = promptHint || null;
|
|
665
|
-
_updateContextUI();
|
|
666
|
-
// 세션의 cwd로 디렉터리 + 최근 프로젝트 칩 동기화
|
|
667
|
-
if (cwd) {
|
|
668
|
-
addRecentDir(cwd);
|
|
669
|
-
selectRecentDir(cwd);
|
|
670
|
-
}
|
|
671
|
-
showToast('Resume 모드: ' + sessionId.slice(0, 8) + '... 세션에 이어서 전송합니다.');
|
|
672
|
-
document.getElementById('promptInput').focus();
|
|
673
|
-
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
674
|
-
}
|
|
675
|
-
|
|
676
|
-
function openFollowUp(jobId) {
|
|
677
|
-
if (expandedJobId !== jobId) {
|
|
678
|
-
toggleJobExpand(jobId);
|
|
679
|
-
setTimeout(() => focusFollowUpInput(jobId), 200);
|
|
680
|
-
} else {
|
|
681
|
-
focusFollowUpInput(jobId);
|
|
682
|
-
}
|
|
683
|
-
}
|
|
684
|
-
|
|
685
|
-
function focusFollowUpInput(jobId) {
|
|
686
|
-
const input = document.getElementById(`followupInput-${jobId}`);
|
|
687
|
-
if (input) {
|
|
688
|
-
input.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
689
|
-
input.focus();
|
|
690
|
-
}
|
|
691
|
-
}
|
|
692
|
-
|
|
693
|
-
// followup 첨부 상태 (jobId -> [{serverPath, filename}])
|
|
694
|
-
const followUpAttachments = {};
|
|
695
|
-
|
|
696
|
-
async function handleFollowUpFiles(jobId, files) {
|
|
697
|
-
if (!followUpAttachments[jobId]) followUpAttachments[jobId] = [];
|
|
698
|
-
const container = document.getElementById(`followupPreviews-${jobId}`);
|
|
699
|
-
for (const file of files) {
|
|
700
|
-
try {
|
|
701
|
-
const data = await uploadFile(file);
|
|
702
|
-
followUpAttachments[jobId].push({ serverPath: data.path, filename: data.filename || file.name });
|
|
703
|
-
if (container) {
|
|
704
|
-
const chip = document.createElement('span');
|
|
705
|
-
chip.className = 'followup-file-chip';
|
|
706
|
-
chip.textContent = data.filename || file.name;
|
|
707
|
-
chip.title = data.path;
|
|
708
|
-
container.appendChild(chip);
|
|
709
|
-
}
|
|
710
|
-
// input에 @path 삽입
|
|
711
|
-
const input = document.getElementById(`followupInput-${jobId}`);
|
|
712
|
-
if (input) {
|
|
713
|
-
const space = input.value.length > 0 && !input.value.endsWith(' ') ? ' ' : '';
|
|
714
|
-
input.value += space + '@' + data.path + ' ';
|
|
715
|
-
input.focus();
|
|
716
|
-
}
|
|
717
|
-
} catch (err) {
|
|
718
|
-
showToast(`${t('msg_upload_failed')}: ${file.name}`, 'error');
|
|
719
|
-
}
|
|
720
|
-
}
|
|
721
|
-
}
|
|
722
|
-
|
|
723
|
-
async function sendFollowUp(jobId) {
|
|
724
|
-
if (_sendLock) return;
|
|
725
|
-
|
|
726
|
-
const input = document.getElementById(`followupInput-${jobId}`);
|
|
727
|
-
if (!input) return;
|
|
728
|
-
const prompt = input.value.trim();
|
|
729
|
-
if (!prompt) {
|
|
730
|
-
showToast(t('msg_continue_input'), 'error');
|
|
731
|
-
return;
|
|
732
|
-
}
|
|
733
|
-
|
|
734
|
-
const panel = document.getElementById(`streamPanel-${jobId}`);
|
|
735
|
-
const sessionId = panel ? panel.dataset.sessionId : '';
|
|
736
|
-
const cwd = panel ? panel.dataset.cwd : '';
|
|
737
|
-
|
|
738
|
-
if (!sessionId) {
|
|
739
|
-
showToast(t('msg_no_session_id'), 'error');
|
|
740
|
-
return;
|
|
741
|
-
}
|
|
742
|
-
|
|
743
|
-
_sendLock = true;
|
|
744
|
-
const btn = input.parentElement.querySelector('.btn-primary');
|
|
745
|
-
const origHtml = btn.innerHTML;
|
|
746
|
-
btn.disabled = true;
|
|
747
|
-
btn.innerHTML = '<span class="spinner" style="width:12px;height:12px;"></span>';
|
|
748
|
-
|
|
749
|
-
try {
|
|
750
|
-
// followup 첨부 이미지 경로를 images 배열로 전송
|
|
751
|
-
const images = (followUpAttachments[jobId] || []).map(a => a.serverPath);
|
|
752
|
-
const body = { prompt, session: `resume:${sessionId}` };
|
|
753
|
-
if (cwd) body.cwd = cwd;
|
|
754
|
-
if (images.length > 0) body.images = images;
|
|
755
|
-
|
|
756
|
-
await apiFetch('/api/send', { method: 'POST', body: JSON.stringify(body) });
|
|
757
|
-
showToast(t('msg_continue_sent'));
|
|
758
|
-
input.value = '';
|
|
759
|
-
// 첨부 초기화
|
|
760
|
-
delete followUpAttachments[jobId];
|
|
761
|
-
const container = document.getElementById(`followupPreviews-${jobId}`);
|
|
762
|
-
if (container) container.innerHTML = '';
|
|
763
|
-
fetchJobs();
|
|
764
|
-
} catch (err) {
|
|
765
|
-
showToast(`${t('msg_send_failed')}: ${err.message}`, 'error');
|
|
766
|
-
} finally {
|
|
767
|
-
_sendLock = false;
|
|
768
|
-
btn.disabled = false;
|
|
769
|
-
btn.innerHTML = origHtml;
|
|
770
|
-
}
|
|
771
|
-
}
|
|
772
|
-
|
|
773
|
-
async function retryJob(jobId) {
|
|
774
|
-
if (_sendLock) return;
|
|
775
|
-
_sendLock = true;
|
|
776
|
-
|
|
777
|
-
try {
|
|
778
|
-
const data = await apiFetch('/api/jobs');
|
|
779
|
-
const jobs = Array.isArray(data) ? data : (data.jobs || []);
|
|
780
|
-
const job = jobs.find(j => String(j.id || j.job_id) === String(jobId));
|
|
781
|
-
if (!job || !job.prompt) {
|
|
782
|
-
showToast(t('msg_no_original_prompt'), 'error');
|
|
783
|
-
return;
|
|
784
|
-
}
|
|
785
|
-
|
|
786
|
-
const body = { prompt: job.prompt };
|
|
787
|
-
if (job.cwd) body.cwd = job.cwd;
|
|
788
|
-
|
|
789
|
-
await apiFetch('/api/send', { method: 'POST', body: JSON.stringify(body) });
|
|
790
|
-
showToast(t('msg_rerun_done'));
|
|
791
|
-
fetchJobs();
|
|
792
|
-
} catch (err) {
|
|
793
|
-
showToast(`${t('msg_rerun_failed')}: ${err.message}`, 'error');
|
|
794
|
-
} finally {
|
|
795
|
-
_sendLock = false;
|
|
796
|
-
}
|
|
797
|
-
}
|
|
798
|
-
|
|
799
|
-
async function fetchJobs() {
|
|
800
|
-
try {
|
|
801
|
-
const data = await apiFetch('/api/jobs');
|
|
802
|
-
const jobs = Array.isArray(data) ? data : (data.jobs || []);
|
|
803
|
-
renderJobs(jobs);
|
|
804
|
-
} catch {
|
|
805
|
-
// silent fail for polling
|
|
806
|
-
}
|
|
807
|
-
}
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
function renderJobs(jobs) {
|
|
811
|
-
const tbody = document.getElementById('jobTableBody');
|
|
812
|
-
const countEl = document.getElementById('jobCount');
|
|
813
|
-
countEl.textContent = jobs.length > 0 ? `(${jobs.length}건)` : '';
|
|
814
|
-
|
|
815
|
-
if (jobs.length === 0) {
|
|
816
|
-
tbody.innerHTML = `<tr data-job-id="__empty__"><td colspan="7" class="empty-state">
|
|
817
|
-
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="width:40px;height:40px;margin-bottom:12px;opacity:0.3;"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="21" x2="9" y2="9"/></svg>
|
|
818
|
-
<div>${t('no_jobs')}</div>
|
|
819
|
-
</td></tr>`;
|
|
820
|
-
return;
|
|
821
|
-
}
|
|
822
|
-
|
|
823
|
-
jobs.sort((a, b) => {
|
|
824
|
-
const aRunning = a.status === 'running' ? 0 : 1;
|
|
825
|
-
const bRunning = b.status === 'running' ? 0 : 1;
|
|
826
|
-
if (aRunning !== bRunning) return aRunning - bRunning;
|
|
827
|
-
return (parseInt(b.id || b.job_id || 0)) - (parseInt(a.id || a.job_id || 0));
|
|
828
|
-
});
|
|
829
|
-
|
|
830
|
-
for (const job of jobs) {
|
|
831
|
-
const id = job.id || job.job_id || '-';
|
|
832
|
-
if (streamState[id]) {
|
|
833
|
-
streamState[id].jobData = job;
|
|
834
|
-
}
|
|
835
|
-
}
|
|
836
|
-
|
|
837
|
-
const existingRows = {};
|
|
838
|
-
for (const row of tbody.querySelectorAll('tr[data-job-id]')) {
|
|
839
|
-
existingRows[row.dataset.jobId] = row;
|
|
840
|
-
}
|
|
841
|
-
|
|
842
|
-
const newIds = [];
|
|
843
|
-
for (const job of jobs) {
|
|
844
|
-
const id = job.id || job.job_id || '-';
|
|
845
|
-
newIds.push(id);
|
|
846
|
-
if (expandedJobId === id) newIds.push(id + '__expand');
|
|
847
|
-
}
|
|
848
|
-
|
|
849
|
-
const emptyRow = tbody.querySelector('tr[data-job-id="__empty__"]');
|
|
850
|
-
if (emptyRow) emptyRow.remove();
|
|
851
|
-
|
|
852
|
-
for (const job of jobs) {
|
|
853
|
-
const id = job.id || job.job_id || '-';
|
|
854
|
-
const isExpanded = expandedJobId === id;
|
|
855
|
-
const existing = existingRows[id];
|
|
856
|
-
|
|
857
|
-
if (existing && !existing.classList.contains('expand-row')) {
|
|
858
|
-
const cells = existing.querySelectorAll('td');
|
|
859
|
-
if (cells.length >= 7) {
|
|
860
|
-
const newStatus = statusBadgeHtml(job.status);
|
|
861
|
-
if (cells[1].innerHTML !== newStatus) cells[1].innerHTML = newStatus;
|
|
862
|
-
const newCwd = escapeHtml(formatCwd(job.cwd));
|
|
863
|
-
if (cells[3].innerHTML !== newCwd) {
|
|
864
|
-
cells[3].innerHTML = newCwd;
|
|
865
|
-
cells[3].title = job.cwd || '';
|
|
866
|
-
}
|
|
867
|
-
const newSession = job.session_id ? job.session_id.slice(0, 8) : (job.status === 'running' ? '—' : '-');
|
|
868
|
-
if (cells[4].textContent !== newSession) {
|
|
869
|
-
cells[4].textContent = newSession;
|
|
870
|
-
if (job.session_id) {
|
|
871
|
-
cells[4].className = 'job-session clickable';
|
|
872
|
-
cells[4].title = job.session_id;
|
|
873
|
-
cells[4].setAttribute('onclick', `event.stopPropagation(); resumeFromJob('${escapeHtml(job.session_id)}', '${escapeHtml(truncate(job.prompt, 40))}', '${escapeHtml(job.cwd || '')}')`);
|
|
874
|
-
}
|
|
875
|
-
}
|
|
876
|
-
const newActions = jobActionsHtml(id, job.status, job.session_id, job.cwd);
|
|
877
|
-
if (cells[6].innerHTML !== newActions) {
|
|
878
|
-
cells[6].innerHTML = newActions;
|
|
879
|
-
}
|
|
880
|
-
}
|
|
881
|
-
existing.className = isExpanded ? 'expanded' : '';
|
|
882
|
-
delete existingRows[id];
|
|
883
|
-
} else if (!existing) {
|
|
884
|
-
const tr = document.createElement('tr');
|
|
885
|
-
tr.dataset.jobId = id;
|
|
886
|
-
tr.className = isExpanded ? 'expanded' : '';
|
|
887
|
-
tr.setAttribute('onclick', `toggleJobExpand('${escapeHtml(id)}')`);
|
|
888
|
-
tr.innerHTML = `
|
|
889
|
-
<td class="job-id">${escapeHtml(String(id).slice(0, 8))}</td>
|
|
890
|
-
<td>${statusBadgeHtml(job.status)}</td>
|
|
891
|
-
<td class="prompt-cell" title="${escapeHtml(job.prompt)}">${renderPromptHtml(job.prompt)}</td>
|
|
892
|
-
<td class="job-cwd" title="${escapeHtml(job.cwd || '')}">${escapeHtml(formatCwd(job.cwd))}</td>
|
|
893
|
-
<td class="job-session${job.session_id ? ' clickable' : ''}" title="${escapeHtml(job.session_id || '')}" ${job.session_id ? `onclick="event.stopPropagation(); resumeFromJob('${escapeHtml(job.session_id)}', '${escapeHtml(truncate(job.prompt, 40))}', '${escapeHtml(job.cwd || '')}')"` : ''}>${job.session_id ? escapeHtml(job.session_id.slice(0, 8)) : (job.status === 'running' ? '<span style="color:var(--text-muted);font-size:0.7rem;">—</span>' : '-')}</td>
|
|
894
|
-
<td class="job-time">${formatTime(job.created || job.created_at)}</td>
|
|
895
|
-
<td>${jobActionsHtml(id, job.status, job.session_id, job.cwd)}</td>`;
|
|
896
|
-
tbody.appendChild(tr);
|
|
897
|
-
} else {
|
|
898
|
-
delete existingRows[id];
|
|
899
|
-
}
|
|
900
|
-
|
|
901
|
-
const expandKey = id + '__expand';
|
|
902
|
-
const existingExpand = existingRows[expandKey] || tbody.querySelector(`tr[data-job-id="${CSS.escape(expandKey)}"]`);
|
|
903
|
-
|
|
904
|
-
if (isExpanded) {
|
|
905
|
-
if (!existingExpand) {
|
|
906
|
-
const expandTr = document.createElement('tr');
|
|
907
|
-
expandTr.className = 'expand-row';
|
|
908
|
-
expandTr.dataset.jobId = expandKey;
|
|
909
|
-
const sessionId = job.session_id || '';
|
|
910
|
-
const jobCwd = job.cwd || '';
|
|
911
|
-
expandTr.innerHTML = `<td colspan="7">
|
|
912
|
-
<div class="stream-panel" id="streamPanel-${escapeHtml(id)}" data-session-id="${escapeHtml(sessionId)}" data-cwd="${escapeHtml(jobCwd)}">
|
|
913
|
-
<div class="stream-content" id="streamContent-${escapeHtml(id)}">
|
|
914
|
-
<div class="stream-empty">
|
|
915
|
-
스트림 데이터를 불러오는 중...
|
|
916
|
-
</div>
|
|
917
|
-
</div>
|
|
918
|
-
${job.status === 'done' ? '<div class="stream-done-banner">✓ 작업 완료</div>' : ''}
|
|
919
|
-
${job.status === 'failed' ? '<div class="stream-done-banner failed">✗ 작업 실패</div>' : ''}
|
|
920
|
-
${job.status !== 'running' ? `<div class="stream-actions"><button class="btn btn-sm" onclick="event.stopPropagation(); retryJob('${escapeHtml(id)}')"><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> 다시 실행</button><button class="btn btn-sm" onclick="event.stopPropagation(); copyStreamResult('${escapeHtml(id)}')"><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> 전체 복사</button><button class="btn btn-sm btn-danger" onclick="event.stopPropagation(); deleteJob('${escapeHtml(id)}')"><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> 작업 제거</button></div>` : ''}
|
|
921
|
-
${(job.status !== 'running' && sessionId) ? `<div class="stream-followup"><span class="stream-followup-label">이어서</span><div class="followup-input-wrap"><input type="text" class="followup-input" id="followupInput-${escapeHtml(id)}" placeholder="이 세션에 이어서 실행할 명령..." onkeydown="if(event.key==='Enter'){event.stopPropagation();sendFollowUp('${escapeHtml(id)}')}" onclick="event.stopPropagation()"><button class="btn btn-primary btn-sm" onclick="event.stopPropagation(); sendFollowUp('${escapeHtml(id)}')" style="white-space:nowrap;"><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> 전송</button></div></div>` : ''}
|
|
922
|
-
</div>
|
|
923
|
-
</td>`;
|
|
924
|
-
const jobRow = tbody.querySelector(`tr[data-job-id="${CSS.escape(id)}"]`);
|
|
925
|
-
if (jobRow && jobRow.nextSibling) {
|
|
926
|
-
tbody.insertBefore(expandTr, jobRow.nextSibling);
|
|
927
|
-
} else {
|
|
928
|
-
tbody.appendChild(expandTr);
|
|
929
|
-
}
|
|
930
|
-
initStream(id);
|
|
931
|
-
} else {
|
|
932
|
-
delete existingRows[expandKey];
|
|
933
|
-
}
|
|
934
|
-
} else if (existingExpand) {
|
|
935
|
-
existingExpand.remove();
|
|
936
|
-
delete existingRows[expandKey];
|
|
937
|
-
}
|
|
938
|
-
|
|
939
|
-
// ── 실행 중인 작업: 미리보기 행 (마지막 2줄) ──
|
|
940
|
-
const previewKey = id + '__preview';
|
|
941
|
-
const existingPreview = existingRows[previewKey] || tbody.querySelector(`tr[data-job-id="${CSS.escape(previewKey)}"]`);
|
|
942
|
-
|
|
943
|
-
if (job.status === 'running' && !isExpanded) {
|
|
944
|
-
// 스트림 자동 시작
|
|
945
|
-
if (!streamState[id]) {
|
|
946
|
-
streamState[id] = { offset: 0, timer: null, done: false, jobData: job, events: [] };
|
|
947
|
-
}
|
|
948
|
-
if (!streamState[id].timer) {
|
|
949
|
-
initStream(id);
|
|
950
|
-
}
|
|
951
|
-
// 미리보기 행 생성/갱신
|
|
952
|
-
if (!existingPreview) {
|
|
953
|
-
const pvTr = document.createElement('tr');
|
|
954
|
-
pvTr.className = 'preview-row';
|
|
955
|
-
pvTr.dataset.jobId = previewKey;
|
|
956
|
-
pvTr.innerHTML = `<td colspan="7"><div class="job-preview" id="jobPreview-${escapeHtml(id)}"><span class="preview-text">대기 중...</span></div></td>`;
|
|
957
|
-
const jobRow = tbody.querySelector(`tr[data-job-id="${CSS.escape(id)}"]`);
|
|
958
|
-
if (jobRow && jobRow.nextSibling) {
|
|
959
|
-
tbody.insertBefore(pvTr, jobRow.nextSibling);
|
|
960
|
-
} else {
|
|
961
|
-
tbody.appendChild(pvTr);
|
|
962
|
-
}
|
|
963
|
-
newIds.splice(newIds.indexOf(id) + 1, 0, previewKey);
|
|
964
|
-
} else {
|
|
965
|
-
delete existingRows[previewKey];
|
|
966
|
-
newIds.splice(newIds.indexOf(id) + 1, 0, previewKey);
|
|
967
|
-
}
|
|
968
|
-
updateJobPreview(id);
|
|
969
|
-
} else {
|
|
970
|
-
// 실행 완료 또는 expand 상태 — 미리보기 제거
|
|
971
|
-
if (existingPreview) {
|
|
972
|
-
existingPreview.remove();
|
|
973
|
-
delete existingRows[previewKey];
|
|
974
|
-
}
|
|
975
|
-
}
|
|
976
|
-
}
|
|
977
|
-
|
|
978
|
-
for (const [key, row] of Object.entries(existingRows)) {
|
|
979
|
-
row.remove();
|
|
980
|
-
}
|
|
981
|
-
|
|
982
|
-
const currentOrder = [...tbody.querySelectorAll('tr[data-job-id]')].map(r => r.dataset.jobId);
|
|
983
|
-
if (JSON.stringify(currentOrder) !== JSON.stringify(newIds)) {
|
|
984
|
-
for (const nid of newIds) {
|
|
985
|
-
const row = tbody.querySelector(`tr[data-job-id="${CSS.escape(nid)}"]`);
|
|
986
|
-
if (row) tbody.appendChild(row);
|
|
987
|
-
}
|
|
988
|
-
}
|
|
989
|
-
|
|
990
|
-
const hasCompleted = jobs.some(j => j.status === 'done' || j.status === 'failed');
|
|
991
|
-
const deleteBtn = document.getElementById('btnDeleteCompleted');
|
|
992
|
-
deleteBtn.style.display = hasCompleted ? 'inline-flex' : 'none';
|
|
993
|
-
}
|
|
994
|
-
|
|
995
|
-
function updateJobRowStatus(jobId, status) {
|
|
996
|
-
const tbody = document.getElementById('jobTableBody');
|
|
997
|
-
const row = tbody.querySelector(`tr[data-job-id="${CSS.escape(jobId)}"]`);
|
|
998
|
-
if (!row || row.classList.contains('expand-row')) return;
|
|
999
|
-
const cells = row.querySelectorAll('td');
|
|
1000
|
-
if (cells.length >= 2) {
|
|
1001
|
-
const newBadge = statusBadgeHtml(status);
|
|
1002
|
-
if (cells[1].innerHTML !== newBadge) cells[1].innerHTML = newBadge;
|
|
1003
|
-
}
|
|
1004
|
-
}
|
|
1005
|
-
|
|
1006
|
-
function toggleJobExpand(id) {
|
|
1007
|
-
const tbody = document.getElementById('jobTableBody');
|
|
1008
|
-
if (expandedJobId === id) {
|
|
1009
|
-
stopStream(expandedJobId);
|
|
1010
|
-
const expandRow = tbody.querySelector(`tr[data-job-id="${CSS.escape(id + '__expand')}"]`);
|
|
1011
|
-
if (expandRow) expandRow.remove();
|
|
1012
|
-
const jobRow = tbody.querySelector(`tr[data-job-id="${CSS.escape(id)}"]`);
|
|
1013
|
-
if (jobRow) jobRow.className = '';
|
|
1014
|
-
expandedJobId = null;
|
|
1015
|
-
} else {
|
|
1016
|
-
if (expandedJobId) {
|
|
1017
|
-
stopStream(expandedJobId);
|
|
1018
|
-
const prevExpand = tbody.querySelector(`tr[data-job-id="${CSS.escape(expandedJobId + '__expand')}"]`);
|
|
1019
|
-
if (prevExpand) prevExpand.remove();
|
|
1020
|
-
const prevRow = tbody.querySelector(`tr[data-job-id="${CSS.escape(expandedJobId)}"]`);
|
|
1021
|
-
if (prevRow) prevRow.className = '';
|
|
1022
|
-
}
|
|
1023
|
-
expandedJobId = id;
|
|
1024
|
-
fetchJobs();
|
|
1025
|
-
}
|
|
1026
|
-
}
|
|
1027
|
-
|
|
1028
|
-
// ── Stream Polling ──
|
|
1029
|
-
// ── 실행 중 작업 미리보기 갱신 (마지막 2줄, stream-content 스타일) ──
|
|
1030
|
-
function updateJobPreview(jobId) {
|
|
1031
|
-
const el = document.getElementById(`jobPreview-${jobId}`);
|
|
1032
|
-
if (!el) return;
|
|
1033
|
-
|
|
1034
|
-
const state = streamState[jobId];
|
|
1035
|
-
if (!state || state.events.length === 0) return;
|
|
1036
|
-
|
|
1037
|
-
// 마지막 2개 이벤트에서 스트림 이벤트 형식으로 렌더링
|
|
1038
|
-
const recent = state.events.slice(-2);
|
|
1039
|
-
const lines = recent.map(evt => {
|
|
1040
|
-
if (evt.type === 'tool_use') {
|
|
1041
|
-
const input = escapeHtml((typeof evt.input === 'string' ? evt.input : JSON.stringify(evt.input || '')).slice(0, 150));
|
|
1042
|
-
return `<div class="preview-line"><span class="preview-tool">${escapeHtml(evt.tool || 'Tool')}</span>${input}</div>`;
|
|
1043
|
-
}
|
|
1044
|
-
if (evt.type === 'result') {
|
|
1045
|
-
const text = (typeof evt.result === 'string' ? evt.result : '').slice(0, 150);
|
|
1046
|
-
return `<div class="preview-line preview-result">${escapeHtml(text)}</div>`;
|
|
1047
|
-
}
|
|
1048
|
-
const text = (evt.text || '').split('\n').pop().slice(0, 150);
|
|
1049
|
-
if (!text) return '';
|
|
1050
|
-
return `<div class="preview-line">${escapeHtml(text)}</div>`;
|
|
1051
|
-
}).filter(Boolean);
|
|
1052
|
-
|
|
1053
|
-
if (lines.length > 0) {
|
|
1054
|
-
el.innerHTML = `<div class="preview-lines">${lines.join('')}</div>`;
|
|
1055
|
-
}
|
|
1056
|
-
}
|
|
1057
|
-
|
|
1058
|
-
function initStream(jobId) {
|
|
1059
|
-
if (streamState[jobId] && streamState[jobId].timer) return;
|
|
1060
|
-
|
|
1061
|
-
if (!streamState[jobId]) {
|
|
1062
|
-
streamState[jobId] = { offset: 0, timer: null, done: false, jobData: null, events: [] };
|
|
1063
|
-
}
|
|
1064
|
-
|
|
1065
|
-
const state = streamState[jobId];
|
|
1066
|
-
if (state.done && state.events.length > 0) {
|
|
1067
|
-
renderStreamEvents(jobId);
|
|
1068
|
-
return;
|
|
1069
|
-
}
|
|
1070
|
-
|
|
1071
|
-
pollStream(jobId);
|
|
1072
|
-
state.timer = setInterval(() => pollStream(jobId), 500);
|
|
1073
|
-
}
|
|
1074
|
-
|
|
1075
|
-
function stopStream(jobId) {
|
|
1076
|
-
const state = streamState[jobId];
|
|
1077
|
-
if (!state) return;
|
|
1078
|
-
if (state.timer) {
|
|
1079
|
-
clearInterval(state.timer);
|
|
1080
|
-
state.timer = null;
|
|
1081
|
-
}
|
|
1082
|
-
}
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
async function pollStream(jobId) {
|
|
1086
|
-
const state = streamState[jobId];
|
|
1087
|
-
if (!state || state.done) {
|
|
1088
|
-
stopStream(jobId);
|
|
1089
|
-
return;
|
|
1090
|
-
}
|
|
1091
|
-
|
|
1092
|
-
try {
|
|
1093
|
-
const data = await apiFetch(`/api/jobs/${encodeURIComponent(jobId)}/stream?offset=${state.offset}`);
|
|
1094
|
-
const events = data.events || [];
|
|
1095
|
-
const newOffset = data.offset !== undefined ? data.offset : state.offset + events.length;
|
|
1096
|
-
const done = !!data.done;
|
|
1097
|
-
|
|
1098
|
-
if (events.length > 0) {
|
|
1099
|
-
state.events = state.events.concat(events);
|
|
1100
|
-
state.offset = newOffset;
|
|
1101
|
-
renderStreamEvents(jobId);
|
|
1102
|
-
updateJobPreview(jobId);
|
|
1103
|
-
}
|
|
1104
|
-
|
|
1105
|
-
if (done) {
|
|
1106
|
-
state.done = true;
|
|
1107
|
-
stopStream(jobId);
|
|
1108
|
-
renderStreamDone(jobId);
|
|
1109
|
-
updateJobRowStatus(jobId, state.jobData ? state.jobData.status : 'done');
|
|
1110
|
-
// 완료 시 미리보기 행 제거
|
|
1111
|
-
const pvRow = document.querySelector(`tr[data-job-id="${CSS.escape(jobId + '__preview')}"]`);
|
|
1112
|
-
if (pvRow) pvRow.remove();
|
|
1113
|
-
}
|
|
1114
|
-
} catch {
|
|
1115
|
-
// Network error — keep retrying silently
|
|
1116
|
-
}
|
|
1117
|
-
}
|
|
1118
|
-
|
|
1119
|
-
function renderStreamEvents(jobId) {
|
|
1120
|
-
const container = document.getElementById(`streamContent-${jobId}`);
|
|
1121
|
-
if (!container) return;
|
|
1122
|
-
|
|
1123
|
-
const state = streamState[jobId];
|
|
1124
|
-
if (!state || state.events.length === 0) return;
|
|
1125
|
-
|
|
1126
|
-
const wasAtBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 40;
|
|
1127
|
-
|
|
1128
|
-
let html = '';
|
|
1129
|
-
for (const evt of state.events) {
|
|
1130
|
-
const type = (evt.type || 'text').toLowerCase();
|
|
1131
|
-
switch (type) {
|
|
1132
|
-
case 'tool_use':
|
|
1133
|
-
html += `<div class="stream-event stream-event-tool">
|
|
1134
|
-
<span class="stream-tool-badge">${escapeHtml(evt.tool || 'Tool')}</span>
|
|
1135
|
-
<span class="stream-tool-input">${escapeHtml(typeof evt.input === 'string' ? evt.input : JSON.stringify(evt.input || ''))}</span>
|
|
1136
|
-
</div>`;
|
|
1137
|
-
break;
|
|
1138
|
-
case 'result':
|
|
1139
|
-
html += `<div class="stream-event stream-event-result">
|
|
1140
|
-
<span class="stream-result-icon">✓</span>
|
|
1141
|
-
<span class="stream-result-text">${escapeHtml(typeof evt.result === 'string' ? evt.result : JSON.stringify(evt.result || ''))}</span>
|
|
1142
|
-
</div>`;
|
|
1143
|
-
if (evt.session_id) {
|
|
1144
|
-
const panel = document.getElementById(`streamPanel-${jobId}`);
|
|
1145
|
-
if (panel) panel.dataset.sessionId = evt.session_id;
|
|
1146
|
-
// 테이블 행의 Session ID 셀도 즉시 업데이트
|
|
1147
|
-
const jobRow = document.querySelector(`tr[data-job-id="${CSS.escape(jobId)}"]`);
|
|
1148
|
-
if (jobRow) {
|
|
1149
|
-
const cells = jobRow.querySelectorAll('td');
|
|
1150
|
-
if (cells.length >= 5 && cells[4].textContent !== evt.session_id.slice(0, 8)) {
|
|
1151
|
-
cells[4].textContent = evt.session_id.slice(0, 8);
|
|
1152
|
-
cells[4].className = 'job-session clickable';
|
|
1153
|
-
cells[4].title = evt.session_id;
|
|
1154
|
-
const evtCwd = panel ? (panel.dataset.cwd || '') : '';
|
|
1155
|
-
cells[4].setAttribute('onclick', `event.stopPropagation(); resumeFromJob('${escapeHtml(evt.session_id)}', '', '${escapeHtml(evtCwd)}')`);
|
|
1156
|
-
}
|
|
1157
|
-
}
|
|
1158
|
-
}
|
|
1159
|
-
break;
|
|
1160
|
-
case 'error':
|
|
1161
|
-
html += `<div class="stream-event stream-event-error">
|
|
1162
|
-
<span class="stream-error-icon">✗</span>
|
|
1163
|
-
<span class="stream-error-text">${escapeHtml(evt.text || evt.error || evt.message || 'Unknown error')}</span>
|
|
1164
|
-
</div>`;
|
|
1165
|
-
break;
|
|
1166
|
-
case 'text':
|
|
1167
|
-
default:
|
|
1168
|
-
html += `<div class="stream-event stream-event-text">${escapeHtml(evt.text || '')}</div>`;
|
|
1169
|
-
break;
|
|
1170
|
-
}
|
|
1171
|
-
}
|
|
1172
|
-
|
|
1173
|
-
container.innerHTML = html;
|
|
1174
|
-
|
|
1175
|
-
if (wasAtBottom) {
|
|
1176
|
-
container.scrollTop = container.scrollHeight;
|
|
1177
|
-
}
|
|
1178
|
-
}
|
|
1179
|
-
|
|
1180
|
-
function renderStreamDone(jobId) {
|
|
1181
|
-
const panel = document.getElementById(`streamPanel-${jobId}`);
|
|
1182
|
-
if (!panel) return;
|
|
1183
|
-
|
|
1184
|
-
const state = streamState[jobId];
|
|
1185
|
-
const status = state && state.jobData ? state.jobData.status : 'done';
|
|
1186
|
-
const isFailed = status === 'failed';
|
|
1187
|
-
|
|
1188
|
-
let banner = panel.querySelector('.stream-done-banner');
|
|
1189
|
-
if (!banner) {
|
|
1190
|
-
banner = document.createElement('div');
|
|
1191
|
-
banner.className = `stream-done-banner${isFailed ? ' failed' : ''}`;
|
|
1192
|
-
banner.textContent = isFailed ? '✗ 작업 실패' : '✓ 작업 완료';
|
|
1193
|
-
panel.appendChild(banner);
|
|
1194
|
-
}
|
|
1195
|
-
|
|
1196
|
-
let actions = panel.querySelector('.stream-actions');
|
|
1197
|
-
if (!actions) {
|
|
1198
|
-
actions = document.createElement('div');
|
|
1199
|
-
actions.className = 'stream-actions';
|
|
1200
|
-
actions.innerHTML = `
|
|
1201
|
-
<button class="btn btn-sm" onclick="event.stopPropagation(); copyStreamResult('${escapeHtml(jobId)}')">
|
|
1202
|
-
<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>
|
|
1203
|
-
전체 복사
|
|
1204
|
-
</button>
|
|
1205
|
-
<button class="btn btn-sm btn-danger" onclick="event.stopPropagation(); deleteJob('${escapeHtml(jobId)}')">
|
|
1206
|
-
<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>
|
|
1207
|
-
작업 제거
|
|
1208
|
-
</button>`;
|
|
1209
|
-
panel.appendChild(actions);
|
|
1210
|
-
}
|
|
1211
|
-
|
|
1212
|
-
const sessionId = panel.dataset.sessionId;
|
|
1213
|
-
if (sessionId && !panel.querySelector('.stream-followup')) {
|
|
1214
|
-
const followup = document.createElement('div');
|
|
1215
|
-
followup.className = 'stream-followup';
|
|
1216
|
-
followup.innerHTML = `
|
|
1217
|
-
<span class="stream-followup-label">이어서</span>
|
|
1218
|
-
<div class="followup-input-wrap">
|
|
1219
|
-
<input type="text" class="followup-input" id="followupInput-${escapeHtml(jobId)}"
|
|
1220
|
-
placeholder="이 세션에 이어서 실행할 명령... (파일/이미지 붙여넣기 가능)"
|
|
1221
|
-
onkeydown="if(event.key==='Enter'){event.stopPropagation();sendFollowUp('${escapeHtml(jobId)}')}"
|
|
1222
|
-
onclick="event.stopPropagation()">
|
|
1223
|
-
<button class="btn btn-primary btn-sm" onclick="event.stopPropagation(); sendFollowUp('${escapeHtml(jobId)}')" style="white-space:nowrap;">
|
|
1224
|
-
<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> 전송
|
|
1225
|
-
</button>
|
|
1226
|
-
</div>
|
|
1227
|
-
<div class="followup-previews" id="followupPreviews-${escapeHtml(jobId)}"></div>`;
|
|
1228
|
-
panel.appendChild(followup);
|
|
1229
|
-
|
|
1230
|
-
// followup input에 paste/drop 이벤트 바인딩
|
|
1231
|
-
const fInput = document.getElementById(`followupInput-${jobId}`);
|
|
1232
|
-
if (fInput) {
|
|
1233
|
-
fInput.addEventListener('paste', function(e) {
|
|
1234
|
-
const files = e.clipboardData?.files;
|
|
1235
|
-
if (files && files.length > 0) {
|
|
1236
|
-
e.preventDefault();
|
|
1237
|
-
handleFollowUpFiles(jobId, files);
|
|
1238
|
-
}
|
|
1239
|
-
});
|
|
1240
|
-
fInput.addEventListener('drop', function(e) {
|
|
1241
|
-
if (e.dataTransfer.files.length > 0) {
|
|
1242
|
-
e.preventDefault();
|
|
1243
|
-
handleFollowUpFiles(jobId, e.dataTransfer.files);
|
|
1244
|
-
}
|
|
1245
|
-
});
|
|
1246
|
-
fInput.addEventListener('dragover', function(e) { e.preventDefault(); });
|
|
1247
|
-
}
|
|
1248
|
-
}
|
|
1249
|
-
}
|
|
1250
|
-
|
|
1251
|
-
// ── Copy Stream Result ──
|
|
1252
|
-
function copyStreamResult(jobId) {
|
|
1253
|
-
const state = streamState[jobId];
|
|
1254
|
-
if (!state || state.events.length === 0) {
|
|
1255
|
-
showToast(t('msg_copy_no_result'), 'error');
|
|
1256
|
-
return;
|
|
1257
|
-
}
|
|
1258
|
-
|
|
1259
|
-
const textParts = [];
|
|
1260
|
-
for (const evt of state.events) {
|
|
1261
|
-
const type = (evt.type || 'text').toLowerCase();
|
|
1262
|
-
switch (type) {
|
|
1263
|
-
case 'text':
|
|
1264
|
-
if (evt.text) textParts.push(evt.text);
|
|
1265
|
-
break;
|
|
1266
|
-
case 'result':
|
|
1267
|
-
const r = typeof evt.result === 'string' ? evt.result : JSON.stringify(evt.result || '');
|
|
1268
|
-
if (r) textParts.push(`[Result] ${r}`);
|
|
1269
|
-
break;
|
|
1270
|
-
case 'tool_use':
|
|
1271
|
-
const toolName = evt.tool || 'Tool';
|
|
1272
|
-
const toolInput = typeof evt.input === 'string' ? evt.input : JSON.stringify(evt.input || '');
|
|
1273
|
-
textParts.push(`[${toolName}] ${toolInput}`);
|
|
1274
|
-
break;
|
|
1275
|
-
case 'error':
|
|
1276
|
-
const errMsg = evt.text || evt.error || evt.message || 'Unknown error';
|
|
1277
|
-
textParts.push(`[Error] ${errMsg}`);
|
|
1278
|
-
break;
|
|
1279
|
-
}
|
|
1280
|
-
}
|
|
1281
|
-
|
|
1282
|
-
const text = textParts.join('\n').trim();
|
|
1283
|
-
if (!text) {
|
|
1284
|
-
showToast(t('msg_copy_no_text'), 'error');
|
|
1285
|
-
return;
|
|
1286
|
-
}
|
|
1287
|
-
|
|
1288
|
-
navigator.clipboard.writeText(text).then(() => {
|
|
1289
|
-
showToast(t('msg_copy_done'));
|
|
1290
|
-
}).catch(() => {
|
|
1291
|
-
showToast(t('msg_copy_failed'), 'error');
|
|
1292
|
-
});
|
|
1293
|
-
}
|
|
1294
|
-
|
|
1295
|
-
// ── Delete Individual Job ──
|
|
1296
|
-
async function deleteJob(jobId) {
|
|
1297
|
-
try {
|
|
1298
|
-
await apiFetch(`/api/jobs/${encodeURIComponent(jobId)}`, { method: 'DELETE' });
|
|
1299
|
-
if (streamState[jobId]) {
|
|
1300
|
-
stopStream(jobId);
|
|
1301
|
-
delete streamState[jobId];
|
|
1302
|
-
}
|
|
1303
|
-
if (expandedJobId === jobId) expandedJobId = null;
|
|
1304
|
-
showToast(t('msg_job_deleted'));
|
|
1305
|
-
fetchJobs();
|
|
1306
|
-
} catch (err) {
|
|
1307
|
-
showToast(`${t('msg_delete_failed')}: ${err.message}`, 'error');
|
|
1308
|
-
}
|
|
1309
|
-
}
|
|
1310
|
-
|
|
1311
|
-
// ── Delete All Completed Jobs ──
|
|
1312
|
-
async function deleteCompletedJobs() {
|
|
1313
|
-
try {
|
|
1314
|
-
const data = await apiFetch('/api/jobs', { method: 'DELETE' });
|
|
1315
|
-
const count = data.count || 0;
|
|
1316
|
-
for (const id of (data.deleted || [])) {
|
|
1317
|
-
if (streamState[id]) {
|
|
1318
|
-
stopStream(id);
|
|
1319
|
-
delete streamState[id];
|
|
1320
|
-
}
|
|
1321
|
-
if (expandedJobId === id) expandedJobId = null;
|
|
1322
|
-
}
|
|
1323
|
-
showToast(count + t('msg_batch_deleted'));
|
|
1324
|
-
fetchJobs();
|
|
1325
|
-
} catch (err) {
|
|
1326
|
-
showToast(`${t('msg_batch_delete_failed')}: ${err.message}`, 'error');
|
|
1327
|
-
}
|
|
1328
|
-
}
|
|
1329
|
-
|
|
1330
|
-
// ── CWD badge ──
|
|
1331
|
-
function updateCwdBadge(path) {
|
|
1332
|
-
const badge = document.getElementById('cwdBadge');
|
|
1333
|
-
if (!badge) return;
|
|
1334
|
-
if (!path) {
|
|
1335
|
-
badge.textContent = '';
|
|
1336
|
-
return;
|
|
1337
|
-
}
|
|
1338
|
-
const parts = path.replace(/\/+$/, '').split('/');
|
|
1339
|
-
const folderName = parts[parts.length - 1] || path;
|
|
1340
|
-
badge.textContent = folderName;
|
|
1341
|
-
badge.title = path;
|
|
1342
|
-
}
|
|
1343
|
-
|
|
1344
|
-
// ── Recent Directories ──
|
|
1345
|
-
const MAX_RECENT_DIRS = 8;
|
|
1346
|
-
let _recentDirsCache = [];
|
|
1347
|
-
|
|
1348
|
-
function getRecentDirs() {
|
|
1349
|
-
return _recentDirsCache;
|
|
1350
|
-
}
|
|
1351
|
-
|
|
1352
|
-
async function loadRecentDirs() {
|
|
1353
|
-
try {
|
|
1354
|
-
const res = await apiFetch('/api/recent-dirs');
|
|
1355
|
-
_recentDirsCache = Array.isArray(res) ? res : [];
|
|
1356
|
-
} catch { _recentDirsCache = []; }
|
|
1357
|
-
renderRecentDirs();
|
|
1358
|
-
}
|
|
1359
|
-
|
|
1360
|
-
function _saveRecentDirs() {
|
|
1361
|
-
apiFetch('/api/recent-dirs', {
|
|
1362
|
-
method: 'POST',
|
|
1363
|
-
body: JSON.stringify({ dirs: _recentDirsCache })
|
|
1364
|
-
}).catch(() => {});
|
|
1365
|
-
}
|
|
1366
|
-
|
|
1367
|
-
function addRecentDir(path) {
|
|
1368
|
-
if (!path) return;
|
|
1369
|
-
_recentDirsCache = _recentDirsCache.filter(d => d !== path);
|
|
1370
|
-
_recentDirsCache.unshift(path);
|
|
1371
|
-
if (_recentDirsCache.length > MAX_RECENT_DIRS) _recentDirsCache = _recentDirsCache.slice(0, MAX_RECENT_DIRS);
|
|
1372
|
-
_saveRecentDirs();
|
|
1373
|
-
renderRecentDirs();
|
|
1374
|
-
}
|
|
1375
|
-
|
|
1376
|
-
function removeRecentDir(path) {
|
|
1377
|
-
_recentDirsCache = _recentDirsCache.filter(d => d !== path);
|
|
1378
|
-
_saveRecentDirs();
|
|
1379
|
-
if (document.getElementById('cwdInput').value === path) {
|
|
1380
|
-
clearDirSelection();
|
|
1381
|
-
}
|
|
1382
|
-
renderRecentDirs();
|
|
1383
|
-
}
|
|
1384
|
-
|
|
1385
|
-
function renderRecentDirs() {
|
|
1386
|
-
const container = document.getElementById('recentDirs');
|
|
1387
|
-
const dirs = _recentDirsCache;
|
|
1388
|
-
const currentCwd = document.getElementById('cwdInput').value;
|
|
1389
|
-
|
|
1390
|
-
if (dirs.length === 0) {
|
|
1391
|
-
container.innerHTML = '';
|
|
1392
|
-
return;
|
|
1393
|
-
}
|
|
1394
|
-
|
|
1395
|
-
let html = '<span class="recent-dirs-label">최근</span>';
|
|
1396
|
-
html += dirs.map(dir => {
|
|
1397
|
-
const parts = dir.replace(/\/+$/, '').split('/');
|
|
1398
|
-
const name = parts[parts.length - 1] || dir;
|
|
1399
|
-
const isActive = dir === currentCwd ? ' active' : '';
|
|
1400
|
-
const escapedDir = dir.replace(/'/g, "\\'");
|
|
1401
|
-
return `<span class="recent-chip${isActive}" onclick="selectRecentDir('${escapedDir}')" title="${dir}">
|
|
1402
|
-
<span class="recent-chip-name">${name}</span>
|
|
1403
|
-
<button class="recent-chip-remove" onclick="event.stopPropagation(); removeRecentDir('${escapedDir}')" title="제거">
|
|
1404
|
-
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
|
1405
|
-
</button>
|
|
1406
|
-
</span>`;
|
|
1407
|
-
}).join('');
|
|
1408
|
-
|
|
1409
|
-
container.innerHTML = html;
|
|
1410
|
-
}
|
|
1411
|
-
|
|
1412
|
-
function selectRecentDir(path) {
|
|
1413
|
-
const current = document.getElementById('cwdInput').value;
|
|
1414
|
-
// 같은 칩을 다시 클릭하면 선택 해제
|
|
1415
|
-
if (current === path) {
|
|
1416
|
-
clearDirSelection();
|
|
1417
|
-
return;
|
|
1418
|
-
}
|
|
1419
|
-
document.getElementById('cwdInput').value = path;
|
|
1420
|
-
updateCwdBadge(path);
|
|
1421
|
-
const text = document.getElementById('dirPickerText');
|
|
1422
|
-
text.textContent = path;
|
|
1423
|
-
document.getElementById('dirPickerDisplay').classList.add('has-value');
|
|
1424
|
-
document.getElementById('dirPickerClear').classList.add('visible');
|
|
1425
|
-
renderRecentDirs();
|
|
1426
|
-
if (dirBrowserOpen) {
|
|
1427
|
-
browseTo(path);
|
|
1428
|
-
} else {
|
|
1429
|
-
dirBrowserCurrentPath = path;
|
|
1430
|
-
}
|
|
1431
|
-
}
|
|
1432
|
-
|
|
1433
|
-
// ── Inline Directory Browser ──
|
|
1434
|
-
let dirBrowserCurrentPath = '';
|
|
1435
|
-
let dirBrowserOpen = false;
|
|
1436
|
-
|
|
1437
|
-
function toggleDirBrowser() {
|
|
1438
|
-
if (dirBrowserOpen) {
|
|
1439
|
-
closeDirBrowser();
|
|
1440
|
-
} else {
|
|
1441
|
-
openDirBrowser();
|
|
1442
|
-
}
|
|
1443
|
-
}
|
|
1444
|
-
|
|
1445
|
-
function openDirBrowser() {
|
|
1446
|
-
const panel = document.getElementById('dirBrowserPanel');
|
|
1447
|
-
const chevron = document.getElementById('dirPickerChevron');
|
|
1448
|
-
const currentCwd = document.getElementById('cwdInput').value;
|
|
1449
|
-
const startPath = currentCwd || '~';
|
|
1450
|
-
|
|
1451
|
-
panel.classList.add('open');
|
|
1452
|
-
if (chevron) chevron.style.transform = 'rotate(180deg)';
|
|
1453
|
-
dirBrowserOpen = true;
|
|
1454
|
-
browseTo(startPath);
|
|
1455
|
-
}
|
|
1456
|
-
|
|
1457
|
-
function closeDirBrowser() {
|
|
1458
|
-
const panel = document.getElementById('dirBrowserPanel');
|
|
1459
|
-
const chevron = document.getElementById('dirPickerChevron');
|
|
1460
|
-
if (panel) panel.classList.remove('open');
|
|
1461
|
-
if (chevron) chevron.style.transform = '';
|
|
1462
|
-
dirBrowserOpen = false;
|
|
1463
|
-
}
|
|
1464
|
-
|
|
1465
|
-
async function browseTo(path) {
|
|
1466
|
-
const list = document.getElementById('dirList');
|
|
1467
|
-
const breadcrumb = document.getElementById('dirBreadcrumb');
|
|
1468
|
-
const currentDisplay = document.getElementById('dirCurrentPath');
|
|
1469
|
-
|
|
1470
|
-
list.innerHTML = '<div class="dir-modal-loading"><span class="spinner"></span> 불러오는 중...</div>';
|
|
1471
|
-
|
|
1472
|
-
try {
|
|
1473
|
-
const data = await apiFetch(`/api/dirs?path=${encodeURIComponent(path)}`);
|
|
1474
|
-
dirBrowserCurrentPath = data.current;
|
|
1475
|
-
currentDisplay.textContent = data.current;
|
|
1476
|
-
currentDisplay.title = data.current;
|
|
1477
|
-
|
|
1478
|
-
document.getElementById('cwdInput').value = data.current;
|
|
1479
|
-
document.getElementById('dirPickerText').textContent = data.current;
|
|
1480
|
-
document.getElementById('dirPickerDisplay').classList.add('has-value');
|
|
1481
|
-
document.getElementById('dirPickerClear').classList.add('visible');
|
|
1482
|
-
renderRecentDirs();
|
|
1483
|
-
|
|
1484
|
-
renderBreadcrumb(data.current, breadcrumb);
|
|
1485
|
-
|
|
1486
|
-
const dirs = data.entries.filter(e => e.type === 'dir');
|
|
1487
|
-
if (dirs.length === 0) {
|
|
1488
|
-
list.innerHTML = '<div class="dir-modal-loading" style="color:var(--text-muted);">하위 디렉토리가 없습니다</div>';
|
|
1489
|
-
return;
|
|
1490
|
-
}
|
|
1491
|
-
|
|
1492
|
-
list.innerHTML = dirs.map(entry => {
|
|
1493
|
-
const isParent = entry.name === '..';
|
|
1494
|
-
const icon = isParent
|
|
1495
|
-
? '<svg class="dir-item-icon is-parent" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>'
|
|
1496
|
-
: '<svg class="dir-item-icon is-dir" 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>';
|
|
1497
|
-
const label = isParent ? '상위 디렉토리' : entry.name;
|
|
1498
|
-
return `<div class="dir-item" onclick="browseTo('${entry.path.replace(/'/g, "\\'")}')">
|
|
1499
|
-
${icon}
|
|
1500
|
-
<span class="dir-item-name is-dir">${label}</span>
|
|
1501
|
-
</div>`;
|
|
1502
|
-
}).join('');
|
|
1503
|
-
|
|
1504
|
-
} catch (err) {
|
|
1505
|
-
list.innerHTML = `<div class="dir-modal-loading" style="color:var(--red);">불러오기 실패: ${err.message}</div>`;
|
|
1506
|
-
}
|
|
1507
|
-
}
|
|
1508
|
-
|
|
1509
|
-
function renderBreadcrumb(fullPath, container) {
|
|
1510
|
-
const parts = fullPath.split('/').filter(Boolean);
|
|
1511
|
-
let html = `<span class="breadcrumb-seg" onclick="browseTo('/')">/</span>`;
|
|
1512
|
-
let accumulated = '';
|
|
1513
|
-
for (const part of parts) {
|
|
1514
|
-
accumulated += '/' + part;
|
|
1515
|
-
const p = accumulated;
|
|
1516
|
-
html += `<span class="breadcrumb-sep">/</span><span class="breadcrumb-seg" onclick="browseTo('${p.replace(/'/g, "\\'")}')">${part}</span>`;
|
|
1517
|
-
}
|
|
1518
|
-
container.innerHTML = html;
|
|
1519
|
-
}
|
|
1520
|
-
|
|
1521
|
-
function selectCurrentDir() {
|
|
1522
|
-
if (!dirBrowserCurrentPath) return;
|
|
1523
|
-
document.getElementById('cwdInput').value = dirBrowserCurrentPath;
|
|
1524
|
-
updateCwdBadge(dirBrowserCurrentPath);
|
|
1525
|
-
|
|
1526
|
-
const text = document.getElementById('dirPickerText');
|
|
1527
|
-
text.textContent = dirBrowserCurrentPath;
|
|
1528
|
-
document.getElementById('dirPickerDisplay').classList.add('has-value');
|
|
1529
|
-
document.getElementById('dirPickerClear').classList.add('visible');
|
|
1530
|
-
|
|
1531
|
-
addRecentDir(dirBrowserCurrentPath);
|
|
1532
|
-
closeDirBrowser();
|
|
1533
|
-
renderRecentDirs();
|
|
1534
|
-
}
|
|
1535
|
-
|
|
1536
|
-
function clearDirSelection() {
|
|
1537
|
-
document.getElementById('cwdInput').value = '';
|
|
1538
|
-
updateCwdBadge('');
|
|
1539
|
-
const text = document.getElementById('dirPickerText');
|
|
1540
|
-
text.textContent = t('select_directory');
|
|
1541
|
-
document.getElementById('dirPickerDisplay').classList.remove('has-value');
|
|
1542
|
-
document.getElementById('dirPickerClear').classList.remove('visible');
|
|
1543
|
-
renderRecentDirs();
|
|
1544
|
-
}
|
|
1545
|
-
|
|
1546
|
-
// ESC 키로 패널 닫기
|
|
1547
|
-
document.addEventListener('keydown', function(e) {
|
|
1548
|
-
if (e.key === 'Escape') {
|
|
1549
|
-
closeDirBrowser();
|
|
1550
|
-
}
|
|
1551
|
-
});
|
|
1552
|
-
|
|
1553
|
-
// ── Refresh All ──
|
|
1554
|
-
function refreshAll() {
|
|
1555
|
-
checkStatus();
|
|
1556
|
-
fetchJobs();
|
|
1557
|
-
showToast(t('msg_refresh_done'));
|
|
1558
|
-
}
|
|
1559
|
-
|
|
1560
|
-
// ── Initialize ──
|
|
1561
19
|
async function autoConnect() {
|
|
1562
20
|
const isSameOrigin = location.hostname === 'localhost' || location.hostname === '127.0.0.1';
|
|
1563
21
|
|
|
1564
22
|
if (isSameOrigin) {
|
|
1565
|
-
// 로컬 서빙 — same-origin 모드
|
|
1566
23
|
API = '';
|
|
1567
24
|
_backendConnected = true;
|
|
1568
25
|
} else {
|
|
1569
|
-
// 원격 배포 (claude.won-space.com 등) — localhost 자동 감지
|
|
1570
26
|
try {
|
|
1571
27
|
const resp = await fetch(`${LOCAL_BACKEND}/api/status`, { signal: AbortSignal.timeout(3000) });
|
|
1572
28
|
if (resp.ok) {
|
|
@@ -1581,12 +37,21 @@ async function autoConnect() {
|
|
|
1581
37
|
|
|
1582
38
|
async function init() {
|
|
1583
39
|
applyI18n();
|
|
40
|
+
applyTheme(localStorage.getItem('theme') || 'dark');
|
|
1584
41
|
await autoConnect();
|
|
1585
42
|
loadRecentDirs();
|
|
43
|
+
fetchPersonas();
|
|
44
|
+
fetchPipelines();
|
|
1586
45
|
checkStatus();
|
|
46
|
+
fetchRegisteredProjects();
|
|
1587
47
|
fetchJobs();
|
|
48
|
+
fetchStats();
|
|
49
|
+
_applyJobListCollapse();
|
|
50
|
+
requestNotificationPermission();
|
|
1588
51
|
|
|
1589
52
|
jobPollTimer = setInterval(fetchJobs, 3000);
|
|
53
|
+
setInterval(fetchStats, 15000);
|
|
54
|
+
setInterval(fetchRegisteredProjects, 30000);
|
|
1590
55
|
setInterval(checkStatus, 10000);
|
|
1591
56
|
|
|
1592
57
|
const promptInput = document.getElementById('promptInput');
|
|
@@ -1597,14 +62,13 @@ async function init() {
|
|
|
1597
62
|
}
|
|
1598
63
|
});
|
|
1599
64
|
|
|
1600
|
-
// Prompt mirror sync
|
|
1601
65
|
promptInput.addEventListener('input', updatePromptMirror);
|
|
1602
66
|
promptInput.addEventListener('scroll', function() {
|
|
1603
67
|
const mirror = document.getElementById('promptMirror');
|
|
1604
68
|
if (mirror) mirror.scrollTop = this.scrollTop;
|
|
1605
69
|
});
|
|
1606
70
|
|
|
1607
|
-
// ── File Drag & Drop
|
|
71
|
+
// ── File Drag & Drop ──
|
|
1608
72
|
const wrapper = document.getElementById('promptWrapper');
|
|
1609
73
|
const dropZone = document.getElementById('sendTask');
|
|
1610
74
|
let dragCounter = 0;
|
|
@@ -1637,10 +101,8 @@ async function init() {
|
|
|
1637
101
|
}
|
|
1638
102
|
});
|
|
1639
103
|
|
|
1640
|
-
// ── 페이지 전체 기본 drop 방지 (파일을 새 탭에 여는 것 방지) ──
|
|
1641
104
|
document.addEventListener('dragover', function(e) { e.preventDefault(); });
|
|
1642
105
|
document.addEventListener('drop', function(e) {
|
|
1643
|
-
// sendTask 영역 밖에 드롭해도 파일 첨부로 처리
|
|
1644
106
|
if (e.dataTransfer.files.length > 0) {
|
|
1645
107
|
e.preventDefault();
|
|
1646
108
|
handleFiles(e.dataTransfer.files);
|
|
@@ -1664,378 +126,4 @@ async function init() {
|
|
|
1664
126
|
});
|
|
1665
127
|
}
|
|
1666
128
|
|
|
1667
|
-
// ══════════════════════════════════════════════════
|
|
1668
|
-
// i18n — 국제화 (Internationalization)
|
|
1669
|
-
// ══════════════════════════════════════════════════
|
|
1670
|
-
|
|
1671
|
-
const I18N = {
|
|
1672
|
-
ko: {
|
|
1673
|
-
title:'Controller Service', send_task:'새 작업 전송', prompt:'프롬프트',
|
|
1674
|
-
prompt_placeholder:'Claude에게 전달할 명령을 입력하세요... (파일/이미지 드래그 또는 붙여넣기 가능)',
|
|
1675
|
-
drop_files:'파일을 여기에 놓으세요', select_directory:'디렉토리를 선택하세요...',
|
|
1676
|
-
browse_directory:'디렉토리 탐색', reset:'초기화', send:'전송',
|
|
1677
|
-
job_list:'작업 목록', status:'상태', folder:'폴더', created_at:'생성 시간',
|
|
1678
|
-
delete_completed:'완료 삭제', loading_jobs:'작업 목록을 불러오는 중...',
|
|
1679
|
-
local_server_connection:'로컬 서버 연결', server_address:'서버 주소',
|
|
1680
|
-
server_address_desc:'로컬에서 실행 중인 Controller 서버 주소',
|
|
1681
|
-
auth_token:'인증 토큰', auth_token_desc:'서버 시작 시 터미널에 표시되는 Auth Token',
|
|
1682
|
-
disconnect:'연결 해제', connect:'연결', settings:'설정',
|
|
1683
|
-
language_settings:'언어 설정', display_language:'표시 언어',
|
|
1684
|
-
display_language_desc:'인터페이스에 표시되는 언어를 선택합니다',
|
|
1685
|
-
close:'닫기', save:'저장', select_session:'세션 선택',
|
|
1686
|
-
this_project_only:'이 프로젝트만', search_prompt:'프롬프트 검색...',
|
|
1687
|
-
msg_settings_saved:'설정이 저장되었습니다', msg_settings_save_failed:'설정 저장 실패',
|
|
1688
|
-
msg_prompt_required:'프롬프트를 입력해주세요.', msg_task_sent:'작업이 전송되었습니다.',
|
|
1689
|
-
msg_send_failed:'전송 실패', msg_upload_failed:'업로드 실패',
|
|
1690
|
-
msg_file_read_failed:'파일 읽기 실패',
|
|
1691
|
-
msg_copy_done:'결과가 클립보드에 복사되었습니다.', msg_copy_failed:'클립보드 복사에 실패했습니다.',
|
|
1692
|
-
msg_copy_no_result:'복사할 결과가 없습니다.', msg_copy_no_text:'복사할 텍스트 결과가 없습니다.',
|
|
1693
|
-
msg_job_deleted:'작업이 제거되었습니다.', msg_delete_failed:'제거 실패',
|
|
1694
|
-
msg_batch_deleted:'개 완료 작업이 제거되었습니다.', msg_batch_delete_failed:'일괄 제거 실패',
|
|
1695
|
-
msg_connected:'로컬 서버에 연결되었습니다', msg_disconnected:'연결이 해제되었습니다',
|
|
1696
|
-
msg_session_select:'세션 선택',
|
|
1697
|
-
msg_fork_mode:'Fork 모드로 전환됨', msg_fork_input:'새 프롬프트를 입력하세요.',
|
|
1698
|
-
msg_continue_input:'이어서 실행할 명령을 입력해주세요.',
|
|
1699
|
-
msg_no_session_id:'세션 ID가 없어서 이어서 실행할 수 없습니다.',
|
|
1700
|
-
msg_continue_sent:'세션 이어서 명령이 전송되었습니다.',
|
|
1701
|
-
msg_no_original_prompt:'원본 프롬프트를 찾을 수 없습니다.',
|
|
1702
|
-
msg_rerun_done:'같은 프롬프트로 다시 실행되었습니다.', msg_rerun_failed:'재실행 실패',
|
|
1703
|
-
msg_refresh_done:'전체 새로고침 완료',
|
|
1704
|
-
msg_service_start:'서비스 시작 요청 완료', msg_service_stop:'서비스 중지 요청 완료',
|
|
1705
|
-
msg_service_restart:'서비스 재시작 요청 완료', msg_service_failed:'서비스 요청 실패',
|
|
1706
|
-
status_running:'실행 중', status_done:'완료', status_failed:'실패', status_pending:'대기 중',
|
|
1707
|
-
no_jobs:'작업이 없습니다', connected_to:'연결됨', no_project:'프로젝트 미지정',
|
|
1708
|
-
},
|
|
1709
|
-
en: {
|
|
1710
|
-
title:'Controller Service', send_task:'Send Task', prompt:'Prompt',
|
|
1711
|
-
prompt_placeholder:'Enter a command for Claude... (drag & drop or paste files/images)',
|
|
1712
|
-
drop_files:'Drop files here', select_directory:'Select a directory...',
|
|
1713
|
-
browse_directory:'Browse Directory', reset:'Reset', send:'Send',
|
|
1714
|
-
job_list:'Job List', status:'Status', folder:'Folder', created_at:'Created',
|
|
1715
|
-
delete_completed:'Delete Done', loading_jobs:'Loading job list...',
|
|
1716
|
-
local_server_connection:'Local Server Connection', server_address:'Server Address',
|
|
1717
|
-
server_address_desc:'Address of the locally running Controller server',
|
|
1718
|
-
auth_token:'Auth Token', auth_token_desc:'Auth Token shown in the terminal when the server starts',
|
|
1719
|
-
disconnect:'Disconnect', connect:'Connect', settings:'Settings',
|
|
1720
|
-
language_settings:'Language', display_language:'Display Language',
|
|
1721
|
-
display_language_desc:'Select the language for the interface',
|
|
1722
|
-
close:'Close', save:'Save', select_session:'Select Session',
|
|
1723
|
-
this_project_only:'This project only', search_prompt:'Search prompts...',
|
|
1724
|
-
msg_settings_saved:'Settings saved', msg_settings_save_failed:'Failed to save settings',
|
|
1725
|
-
msg_prompt_required:'Please enter a prompt.', msg_task_sent:'Task has been sent.',
|
|
1726
|
-
msg_send_failed:'Send failed', msg_upload_failed:'Upload failed',
|
|
1727
|
-
msg_file_read_failed:'File read failed',
|
|
1728
|
-
msg_copy_done:'Result copied to clipboard.', msg_copy_failed:'Failed to copy to clipboard.',
|
|
1729
|
-
msg_copy_no_result:'No result to copy.', msg_copy_no_text:'No text result to copy.',
|
|
1730
|
-
msg_job_deleted:'Job deleted.', msg_delete_failed:'Delete failed',
|
|
1731
|
-
msg_batch_deleted:' completed jobs deleted.', msg_batch_delete_failed:'Batch delete failed',
|
|
1732
|
-
msg_connected:'Connected to local server', msg_disconnected:'Disconnected',
|
|
1733
|
-
msg_session_select:'Session selected',
|
|
1734
|
-
msg_fork_mode:'Switched to Fork mode', msg_fork_input:'Enter a new prompt.',
|
|
1735
|
-
msg_continue_input:'Enter a command to continue.',
|
|
1736
|
-
msg_no_session_id:'Cannot continue: no session ID.',
|
|
1737
|
-
msg_continue_sent:'Continue command sent.',
|
|
1738
|
-
msg_no_original_prompt:'Original prompt not found.',
|
|
1739
|
-
msg_rerun_done:'Re-run with the same prompt.', msg_rerun_failed:'Re-run failed',
|
|
1740
|
-
msg_refresh_done:'Fully refreshed',
|
|
1741
|
-
msg_service_start:'Service start requested', msg_service_stop:'Service stop requested',
|
|
1742
|
-
msg_service_restart:'Service restart requested', msg_service_failed:'Service request failed',
|
|
1743
|
-
status_running:'Running', status_done:'Done', status_failed:'Failed', status_pending:'Pending',
|
|
1744
|
-
no_jobs:'No jobs', connected_to:'Connected', no_project:'No project',
|
|
1745
|
-
},
|
|
1746
|
-
ja: {
|
|
1747
|
-
title:'Controller Service', send_task:'新しいタスク送信', prompt:'プロンプト',
|
|
1748
|
-
prompt_placeholder:'Claudeに送信するコマンドを入力... (ファイル/画像のドラッグ&ドロップ可能)',
|
|
1749
|
-
drop_files:'ここにファイルをドロップ', select_directory:'ディレクトリを選択...',
|
|
1750
|
-
browse_directory:'ディレクトリ参照', reset:'リセット', send:'送信',
|
|
1751
|
-
job_list:'ジョブ一覧', status:'ステータス', folder:'フォルダ', created_at:'作成日時',
|
|
1752
|
-
delete_completed:'完了を削除', loading_jobs:'ジョブ一覧を読み込み中...',
|
|
1753
|
-
local_server_connection:'ローカルサーバー接続', server_address:'サーバーアドレス',
|
|
1754
|
-
server_address_desc:'ローカルで実行中のControllerサーバーアドレス',
|
|
1755
|
-
auth_token:'認証トークン', auth_token_desc:'サーバー起動時にターミナルに表示されるAuth Token',
|
|
1756
|
-
disconnect:'切断', connect:'接続', settings:'設定',
|
|
1757
|
-
language_settings:'言語設定', display_language:'表示言語',
|
|
1758
|
-
display_language_desc:'インターフェースの表示言語を選択します',
|
|
1759
|
-
close:'閉じる', save:'保存', select_session:'セッション選択',
|
|
1760
|
-
this_project_only:'このプロジェクトのみ', search_prompt:'プロンプト検索...',
|
|
1761
|
-
msg_settings_saved:'設定が保存されました', msg_settings_save_failed:'設定の保存に失敗',
|
|
1762
|
-
msg_prompt_required:'プロンプトを入力してください。', msg_task_sent:'タスクが送信されました。',
|
|
1763
|
-
msg_send_failed:'送信失敗', msg_upload_failed:'アップロード失敗',
|
|
1764
|
-
msg_file_read_failed:'ファイル読み取り失敗',
|
|
1765
|
-
msg_copy_done:'結果がクリップボードにコピーされました。', msg_copy_failed:'クリップボードへのコピーに失敗。',
|
|
1766
|
-
msg_copy_no_result:'コピーする結果がありません。', msg_copy_no_text:'コピーするテキスト結果がありません。',
|
|
1767
|
-
msg_job_deleted:'ジョブが削除されました。', msg_delete_failed:'削除失敗',
|
|
1768
|
-
msg_batch_deleted:'件の完了ジョブが削除されました。', msg_batch_delete_failed:'一括削除失敗',
|
|
1769
|
-
msg_connected:'ローカルサーバーに接続しました', msg_disconnected:'切断されました',
|
|
1770
|
-
msg_session_select:'セッション選択',
|
|
1771
|
-
msg_fork_mode:'Forkモードに切り替えました', msg_fork_input:'新しいプロンプトを入力してください。',
|
|
1772
|
-
msg_continue_input:'続行するコマンドを入力してください。',
|
|
1773
|
-
msg_no_session_id:'セッションIDがないため続行できません。',
|
|
1774
|
-
msg_continue_sent:'セッション続行コマンドが送信されました。',
|
|
1775
|
-
msg_no_original_prompt:'元のプロンプトが見つかりません。',
|
|
1776
|
-
msg_rerun_done:'同じプロンプトで再実行しました。', msg_rerun_failed:'再実行失敗',
|
|
1777
|
-
msg_refresh_done:'全体リフレッシュ完了',
|
|
1778
|
-
msg_service_start:'サービス開始を要求', msg_service_stop:'サービス停止を要求',
|
|
1779
|
-
msg_service_restart:'サービス再起動を要求', msg_service_failed:'サービスリクエスト失敗',
|
|
1780
|
-
status_running:'実行中', status_done:'完了', status_failed:'失敗', status_pending:'待機中',
|
|
1781
|
-
no_jobs:'ジョブがありません', connected_to:'接続済み', no_project:'プロジェクト未指定',
|
|
1782
|
-
},
|
|
1783
|
-
'zh-CN': {
|
|
1784
|
-
title:'Controller Service', send_task:'发送任务', prompt:'提示词',
|
|
1785
|
-
prompt_placeholder:'输入要发送给Claude的指令... (可拖放或粘贴文件/图片)',
|
|
1786
|
-
drop_files:'将文件拖放到此处', select_directory:'选择目录...',
|
|
1787
|
-
browse_directory:'浏览目录', reset:'重置', send:'发送',
|
|
1788
|
-
job_list:'任务列表', status:'状态', folder:'文件夹', created_at:'创建时间',
|
|
1789
|
-
delete_completed:'删除已完成', loading_jobs:'正在加载任务列表...',
|
|
1790
|
-
local_server_connection:'本地服务器连接', server_address:'服务器地址',
|
|
1791
|
-
server_address_desc:'本地运行的Controller服务器地址',
|
|
1792
|
-
auth_token:'认证令牌', auth_token_desc:'服务器启动时在终端显示的Auth Token',
|
|
1793
|
-
disconnect:'断开连接', connect:'连接', settings:'设置',
|
|
1794
|
-
language_settings:'语言设置', display_language:'显示语言',
|
|
1795
|
-
display_language_desc:'选择界面显示的语言',
|
|
1796
|
-
close:'关闭', save:'保存', select_session:'选择会话',
|
|
1797
|
-
this_project_only:'仅此项目', search_prompt:'搜索提示词...',
|
|
1798
|
-
msg_settings_saved:'设置已保存', msg_settings_save_failed:'保存设置失败',
|
|
1799
|
-
msg_prompt_required:'请输入提示词。', msg_task_sent:'任务已发送。',
|
|
1800
|
-
msg_send_failed:'发送失败', msg_upload_failed:'上传失败',
|
|
1801
|
-
msg_file_read_failed:'文件读取失败',
|
|
1802
|
-
msg_copy_done:'结果已复制到剪贴板。', msg_copy_failed:'复制到剪贴板失败。',
|
|
1803
|
-
msg_copy_no_result:'没有可复制的结果。', msg_copy_no_text:'没有可复制的文本结果。',
|
|
1804
|
-
msg_job_deleted:'任务已删除。', msg_delete_failed:'删除失败',
|
|
1805
|
-
msg_batch_deleted:'个已完成任务已删除。', msg_batch_delete_failed:'批量删除失败',
|
|
1806
|
-
msg_connected:'已连接到本地服务器', msg_disconnected:'已断开连接',
|
|
1807
|
-
msg_session_select:'已选择会话',
|
|
1808
|
-
msg_fork_mode:'已切换到Fork模式', msg_fork_input:'请输入新的提示词。',
|
|
1809
|
-
msg_continue_input:'请输入继续执行的命令。', msg_no_session_id:'没有会话ID,无法继续。',
|
|
1810
|
-
msg_continue_sent:'会话继续命令已发送。', msg_no_original_prompt:'找不到原始提示词。',
|
|
1811
|
-
msg_rerun_done:'已使用相同提示词重新执行。', msg_rerun_failed:'重新执行失败',
|
|
1812
|
-
msg_refresh_done:'全部刷新完成',
|
|
1813
|
-
msg_service_start:'服务启动请求已完成', msg_service_stop:'服务停止请求已完成',
|
|
1814
|
-
msg_service_restart:'服务重启请求已完成', msg_service_failed:'服务请求失败',
|
|
1815
|
-
status_running:'运行中', status_done:'完成', status_failed:'失败', status_pending:'等待中',
|
|
1816
|
-
no_jobs:'没有任务', connected_to:'已连接', no_project:'未指定项目',
|
|
1817
|
-
},
|
|
1818
|
-
'zh-TW': {
|
|
1819
|
-
title:'Controller Service', send_task:'傳送任務', prompt:'提示詞',
|
|
1820
|
-
prompt_placeholder:'輸入要傳送給Claude的指令... (可拖放或貼上檔案/圖片)',
|
|
1821
|
-
drop_files:'將檔案拖放到此處', select_directory:'選擇目錄...',
|
|
1822
|
-
browse_directory:'瀏覽目錄', reset:'重設', send:'傳送',
|
|
1823
|
-
job_list:'任務列表', status:'狀態', folder:'資料夾', created_at:'建立時間',
|
|
1824
|
-
delete_completed:'刪除已完成', loading_jobs:'正在載入任務列表...',
|
|
1825
|
-
local_server_connection:'本機伺服器連線', server_address:'伺服器位址',
|
|
1826
|
-
server_address_desc:'本機執行的Controller伺服器位址',
|
|
1827
|
-
auth_token:'認證權杖', auth_token_desc:'伺服器啟動時在終端顯示的Auth Token',
|
|
1828
|
-
disconnect:'斷開連線', connect:'連線', settings:'設定',
|
|
1829
|
-
language_settings:'語言設定', display_language:'顯示語言',
|
|
1830
|
-
display_language_desc:'選擇介面顯示的語言',
|
|
1831
|
-
close:'關閉', save:'儲存', select_session:'選擇工作階段',
|
|
1832
|
-
this_project_only:'僅此專案', search_prompt:'搜尋提示詞...',
|
|
1833
|
-
msg_settings_saved:'設定已儲存', msg_settings_save_failed:'儲存設定失敗',
|
|
1834
|
-
msg_prompt_required:'請輸入提示詞。', msg_task_sent:'任務已傳送。',
|
|
1835
|
-
msg_send_failed:'傳送失敗', msg_upload_failed:'上傳失敗',
|
|
1836
|
-
msg_file_read_failed:'檔案讀取失敗',
|
|
1837
|
-
msg_copy_done:'結果已複製到剪貼簿。', msg_copy_failed:'複製到剪貼簿失敗。',
|
|
1838
|
-
msg_copy_no_result:'沒有可複製的結果。', msg_copy_no_text:'沒有可複製的文字結果。',
|
|
1839
|
-
msg_job_deleted:'任務已刪除。', msg_delete_failed:'刪除失敗',
|
|
1840
|
-
msg_batch_deleted:'個已完成任務已刪除。', msg_batch_delete_failed:'批次刪除失敗',
|
|
1841
|
-
msg_connected:'已連線到本機伺服器', msg_disconnected:'已斷開連線',
|
|
1842
|
-
msg_session_select:'已選擇工作階段',
|
|
1843
|
-
msg_fork_mode:'已切換到Fork模式', msg_fork_input:'請輸入新的提示詞。',
|
|
1844
|
-
msg_continue_input:'請輸入繼續執行的命令。', msg_no_session_id:'沒有工作階段ID,無法繼續。',
|
|
1845
|
-
msg_continue_sent:'工作階段繼續命令已傳送。', msg_no_original_prompt:'找不到原始提示詞。',
|
|
1846
|
-
msg_rerun_done:'已使用相同提示詞重新執行。', msg_rerun_failed:'重新執行失敗',
|
|
1847
|
-
msg_refresh_done:'全部重新整理完成',
|
|
1848
|
-
msg_service_start:'服務啟動請求已完成', msg_service_stop:'服務停止請求已完成',
|
|
1849
|
-
msg_service_restart:'服務重新啟動請求已完成', msg_service_failed:'服務請求失敗',
|
|
1850
|
-
status_running:'執行中', status_done:'完成', status_failed:'失敗', status_pending:'等待中',
|
|
1851
|
-
no_jobs:'沒有任務', connected_to:'已連線', no_project:'未指定專案',
|
|
1852
|
-
},
|
|
1853
|
-
es: {
|
|
1854
|
-
title:'Controller Service', send_task:'Enviar Tarea', prompt:'Prompt',
|
|
1855
|
-
prompt_placeholder:'Ingrese un comando para Claude... (arrastre o pegue archivos/imágenes)',
|
|
1856
|
-
drop_files:'Suelte archivos aquí', select_directory:'Seleccionar directorio...',
|
|
1857
|
-
browse_directory:'Explorar Directorio', reset:'Restablecer', send:'Enviar',
|
|
1858
|
-
job_list:'Lista de Tareas', status:'Estado', folder:'Carpeta', created_at:'Creado',
|
|
1859
|
-
delete_completed:'Eliminar completadas', loading_jobs:'Cargando lista de tareas...',
|
|
1860
|
-
local_server_connection:'Conexión al Servidor Local', server_address:'Dirección del Servidor',
|
|
1861
|
-
server_address_desc:'Dirección del servidor Controller local',
|
|
1862
|
-
auth_token:'Token de Autenticación', auth_token_desc:'Token mostrado en la terminal al iniciar',
|
|
1863
|
-
disconnect:'Desconectar', connect:'Conectar', settings:'Configuración',
|
|
1864
|
-
language_settings:'Idioma', display_language:'Idioma de Interfaz',
|
|
1865
|
-
display_language_desc:'Seleccione el idioma de la interfaz',
|
|
1866
|
-
close:'Cerrar', save:'Guardar', select_session:'Seleccionar Sesión',
|
|
1867
|
-
this_project_only:'Solo este proyecto', search_prompt:'Buscar prompts...',
|
|
1868
|
-
msg_settings_saved:'Configuración guardada', msg_settings_save_failed:'Error al guardar',
|
|
1869
|
-
msg_prompt_required:'Ingrese un prompt.', msg_task_sent:'Tarea enviada.',
|
|
1870
|
-
msg_send_failed:'Error al enviar', msg_upload_failed:'Error al subir',
|
|
1871
|
-
msg_file_read_failed:'Error al leer archivo',
|
|
1872
|
-
msg_copy_done:'Resultado copiado.', msg_copy_failed:'Error al copiar.',
|
|
1873
|
-
msg_copy_no_result:'No hay resultado.', msg_copy_no_text:'No hay texto.',
|
|
1874
|
-
msg_job_deleted:'Tarea eliminada.', msg_delete_failed:'Error al eliminar',
|
|
1875
|
-
msg_batch_deleted:' tareas eliminadas.', msg_batch_delete_failed:'Error en eliminación masiva',
|
|
1876
|
-
msg_connected:'Conectado al servidor local', msg_disconnected:'Desconectado',
|
|
1877
|
-
msg_session_select:'Sesión seleccionada',
|
|
1878
|
-
msg_fork_mode:'Modo Fork activado', msg_fork_input:'Ingrese un nuevo prompt.',
|
|
1879
|
-
msg_continue_input:'Ingrese un comando para continuar.',
|
|
1880
|
-
msg_no_session_id:'Sin ID de sesión.', msg_continue_sent:'Comando de continuación enviado.',
|
|
1881
|
-
msg_no_original_prompt:'Prompt original no encontrado.',
|
|
1882
|
-
msg_rerun_done:'Re-ejecutado.', msg_rerun_failed:'Error al re-ejecutar',
|
|
1883
|
-
msg_refresh_done:'Actualización completa',
|
|
1884
|
-
msg_service_start:'Servicio iniciado', msg_service_stop:'Servicio detenido',
|
|
1885
|
-
msg_service_restart:'Servicio reiniciado', msg_service_failed:'Error de servicio',
|
|
1886
|
-
status_running:'Ejecutando', status_done:'Completado', status_failed:'Fallido', status_pending:'Pendiente',
|
|
1887
|
-
no_jobs:'Sin tareas', connected_to:'Conectado', no_project:'Sin proyecto',
|
|
1888
|
-
},
|
|
1889
|
-
fr: {
|
|
1890
|
-
title:'Controller Service', send_task:'Envoyer une Tâche', prompt:'Prompt',
|
|
1891
|
-
prompt_placeholder:'Entrez une commande pour Claude... (glisser-déposer ou coller fichiers/images)',
|
|
1892
|
-
drop_files:'Déposez les fichiers ici', select_directory:'Sélectionner un répertoire...',
|
|
1893
|
-
browse_directory:'Parcourir', reset:'Réinitialiser', send:'Envoyer',
|
|
1894
|
-
job_list:'Liste des Tâches', status:'Statut', folder:'Dossier', created_at:'Créé le',
|
|
1895
|
-
delete_completed:'Supprimer terminées', loading_jobs:'Chargement...',
|
|
1896
|
-
local_server_connection:'Connexion au Serveur Local', server_address:'Adresse du Serveur',
|
|
1897
|
-
server_address_desc:'Adresse du serveur Controller local',
|
|
1898
|
-
auth_token:'Jeton d\'Authentification', auth_token_desc:'Jeton affiché au démarrage du serveur',
|
|
1899
|
-
disconnect:'Déconnecter', connect:'Connecter', settings:'Paramètres',
|
|
1900
|
-
language_settings:'Langue', display_language:'Langue d\'Affichage',
|
|
1901
|
-
display_language_desc:'Sélectionnez la langue de l\'interface',
|
|
1902
|
-
close:'Fermer', save:'Enregistrer', select_session:'Sélectionner une Session',
|
|
1903
|
-
this_project_only:'Ce projet uniquement', search_prompt:'Rechercher...',
|
|
1904
|
-
msg_settings_saved:'Paramètres enregistrés', msg_settings_save_failed:'Échec de l\'enregistrement',
|
|
1905
|
-
msg_prompt_required:'Veuillez entrer un prompt.', msg_task_sent:'Tâche envoyée.',
|
|
1906
|
-
msg_send_failed:'Échec de l\'envoi', msg_upload_failed:'Échec du téléchargement',
|
|
1907
|
-
msg_file_read_failed:'Échec de la lecture',
|
|
1908
|
-
msg_copy_done:'Résultat copié.', msg_copy_failed:'Échec de la copie.',
|
|
1909
|
-
msg_copy_no_result:'Aucun résultat.', msg_copy_no_text:'Aucun texte.',
|
|
1910
|
-
msg_job_deleted:'Tâche supprimée.', msg_delete_failed:'Échec de la suppression',
|
|
1911
|
-
msg_batch_deleted:' tâches supprimées.', msg_batch_delete_failed:'Échec de la suppression groupée',
|
|
1912
|
-
msg_connected:'Connecté au serveur local', msg_disconnected:'Déconnecté',
|
|
1913
|
-
msg_session_select:'Session sélectionnée',
|
|
1914
|
-
msg_fork_mode:'Mode Fork activé', msg_fork_input:'Entrez un nouveau prompt.',
|
|
1915
|
-
msg_continue_input:'Entrez une commande pour continuer.',
|
|
1916
|
-
msg_no_session_id:'Pas d\'ID de session.', msg_continue_sent:'Commande de continuation envoyée.',
|
|
1917
|
-
msg_no_original_prompt:'Prompt original introuvable.',
|
|
1918
|
-
msg_rerun_done:'Re-exécuté.', msg_rerun_failed:'Échec de la re-exécution',
|
|
1919
|
-
msg_refresh_done:'Rafraîchissement complet',
|
|
1920
|
-
msg_service_start:'Démarrage du service', msg_service_stop:'Arrêt du service',
|
|
1921
|
-
msg_service_restart:'Redémarrage du service', msg_service_failed:'Échec du service',
|
|
1922
|
-
status_running:'En cours', status_done:'Terminé', status_failed:'Échoué', status_pending:'En attente',
|
|
1923
|
-
no_jobs:'Aucune tâche', connected_to:'Connecté', no_project:'Aucun projet',
|
|
1924
|
-
},
|
|
1925
|
-
de: {
|
|
1926
|
-
title:'Controller Service', send_task:'Aufgabe senden', prompt:'Prompt',
|
|
1927
|
-
prompt_placeholder:'Befehl für Claude eingeben... (Dateien/Bilder per Drag & Drop)',
|
|
1928
|
-
drop_files:'Dateien hier ablegen', select_directory:'Verzeichnis auswählen...',
|
|
1929
|
-
browse_directory:'Verzeichnis durchsuchen', reset:'Zurücksetzen', send:'Senden',
|
|
1930
|
-
job_list:'Aufgabenliste', status:'Status', folder:'Ordner', created_at:'Erstellt',
|
|
1931
|
-
delete_completed:'Erledigte löschen', loading_jobs:'Laden...',
|
|
1932
|
-
local_server_connection:'Lokale Serververbindung', server_address:'Serveradresse',
|
|
1933
|
-
server_address_desc:'Adresse des lokalen Controller-Servers',
|
|
1934
|
-
auth_token:'Auth-Token', auth_token_desc:'Auth Token beim Serverstart im Terminal',
|
|
1935
|
-
disconnect:'Trennen', connect:'Verbinden', settings:'Einstellungen',
|
|
1936
|
-
language_settings:'Sprache', display_language:'Anzeigesprache',
|
|
1937
|
-
display_language_desc:'Sprache der Benutzeroberfläche auswählen',
|
|
1938
|
-
close:'Schließen', save:'Speichern', select_session:'Sitzung auswählen',
|
|
1939
|
-
this_project_only:'Nur dieses Projekt', search_prompt:'Prompts suchen...',
|
|
1940
|
-
msg_settings_saved:'Einstellungen gespeichert', msg_settings_save_failed:'Speichern fehlgeschlagen',
|
|
1941
|
-
msg_prompt_required:'Bitte Prompt eingeben.', msg_task_sent:'Aufgabe gesendet.',
|
|
1942
|
-
msg_send_failed:'Senden fehlgeschlagen', msg_upload_failed:'Upload fehlgeschlagen',
|
|
1943
|
-
msg_file_read_failed:'Datei lesen fehlgeschlagen',
|
|
1944
|
-
msg_copy_done:'Ergebnis kopiert.', msg_copy_failed:'Kopieren fehlgeschlagen.',
|
|
1945
|
-
msg_copy_no_result:'Kein Ergebnis.', msg_copy_no_text:'Kein Text.',
|
|
1946
|
-
msg_job_deleted:'Aufgabe gelöscht.', msg_delete_failed:'Löschen fehlgeschlagen',
|
|
1947
|
-
msg_batch_deleted:' Aufgaben gelöscht.', msg_batch_delete_failed:'Massenlöschung fehlgeschlagen',
|
|
1948
|
-
msg_connected:'Mit Server verbunden', msg_disconnected:'Verbindung getrennt',
|
|
1949
|
-
msg_session_select:'Sitzung ausgewählt',
|
|
1950
|
-
msg_fork_mode:'Fork-Modus aktiviert', msg_fork_input:'Neuen Prompt eingeben.',
|
|
1951
|
-
msg_continue_input:'Befehl zum Fortfahren eingeben.',
|
|
1952
|
-
msg_no_session_id:'Keine Sitzungs-ID.', msg_continue_sent:'Fortsetzungsbefehl gesendet.',
|
|
1953
|
-
msg_no_original_prompt:'Ursprünglicher Prompt nicht gefunden.',
|
|
1954
|
-
msg_rerun_done:'Erneut ausgeführt.', msg_rerun_failed:'Erneute Ausführung fehlgeschlagen',
|
|
1955
|
-
msg_refresh_done:'Vollständig aktualisiert',
|
|
1956
|
-
msg_service_start:'Dienststart angefordert', msg_service_stop:'Dienststopp angefordert',
|
|
1957
|
-
msg_service_restart:'Dienstneustart angefordert', msg_service_failed:'Dienstanforderung fehlgeschlagen',
|
|
1958
|
-
status_running:'Läuft', status_done:'Fertig', status_failed:'Fehlgeschlagen', status_pending:'Wartend',
|
|
1959
|
-
no_jobs:'Keine Aufgaben', connected_to:'Verbunden', no_project:'Kein Projekt',
|
|
1960
|
-
},
|
|
1961
|
-
};
|
|
1962
|
-
|
|
1963
|
-
let _currentLocale = localStorage.getItem('ctrl_locale') || 'ko';
|
|
1964
|
-
|
|
1965
|
-
function t(key) {
|
|
1966
|
-
const dict = I18N[_currentLocale] || I18N['ko'];
|
|
1967
|
-
return dict[key] || I18N['ko'][key] || key;
|
|
1968
|
-
}
|
|
1969
|
-
|
|
1970
|
-
function applyI18n() {
|
|
1971
|
-
document.querySelectorAll('[data-i18n]').forEach(el => {
|
|
1972
|
-
const key = el.getAttribute('data-i18n');
|
|
1973
|
-
const text = t(key);
|
|
1974
|
-
if (text) el.textContent = text;
|
|
1975
|
-
});
|
|
1976
|
-
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
|
|
1977
|
-
const key = el.getAttribute('data-i18n-placeholder');
|
|
1978
|
-
const text = t(key);
|
|
1979
|
-
if (text) el.placeholder = text;
|
|
1980
|
-
});
|
|
1981
|
-
document.documentElement.lang = _currentLocale.split('-')[0];
|
|
1982
|
-
document.documentElement.setAttribute('data-locale', _currentLocale);
|
|
1983
|
-
}
|
|
1984
|
-
|
|
1985
|
-
function setLocale(locale) {
|
|
1986
|
-
if (!I18N[locale]) return;
|
|
1987
|
-
_currentLocale = locale;
|
|
1988
|
-
localStorage.setItem('ctrl_locale', locale);
|
|
1989
|
-
applyI18n();
|
|
1990
|
-
}
|
|
1991
|
-
|
|
1992
|
-
function onLocaleChange() {
|
|
1993
|
-
const sel = document.getElementById('cfgLocale');
|
|
1994
|
-
if (sel) setLocale(sel.value);
|
|
1995
|
-
}
|
|
1996
|
-
|
|
1997
|
-
// ── Settings ──
|
|
1998
|
-
let _settingsData = {};
|
|
1999
|
-
|
|
2000
|
-
function openSettings() {
|
|
2001
|
-
loadSettings().then(() => {
|
|
2002
|
-
document.getElementById('settingsOverlay').classList.add('open');
|
|
2003
|
-
});
|
|
2004
|
-
}
|
|
2005
|
-
|
|
2006
|
-
function closeSettings() {
|
|
2007
|
-
document.getElementById('settingsOverlay').classList.remove('open');
|
|
2008
|
-
}
|
|
2009
|
-
|
|
2010
|
-
async function loadSettings() {
|
|
2011
|
-
try {
|
|
2012
|
-
const resp = await apiFetch('/api/config');
|
|
2013
|
-
_settingsData = await resp.json();
|
|
2014
|
-
} catch {
|
|
2015
|
-
_settingsData = {};
|
|
2016
|
-
}
|
|
2017
|
-
_populateSettingsUI();
|
|
2018
|
-
}
|
|
2019
|
-
|
|
2020
|
-
function _populateSettingsUI() {
|
|
2021
|
-
const d = _settingsData;
|
|
2022
|
-
const sel = document.getElementById('cfgLocale');
|
|
2023
|
-
if (sel) sel.value = d.locale || _currentLocale;
|
|
2024
|
-
}
|
|
2025
|
-
|
|
2026
|
-
async function saveSettings() {
|
|
2027
|
-
const locale = document.getElementById('cfgLocale').value;
|
|
2028
|
-
const payload = { locale };
|
|
2029
|
-
try {
|
|
2030
|
-
await apiFetch('/api/config', {
|
|
2031
|
-
method: 'POST',
|
|
2032
|
-
body: JSON.stringify(payload),
|
|
2033
|
-
});
|
|
2034
|
-
setLocale(locale);
|
|
2035
|
-
showToast(t('msg_settings_saved'));
|
|
2036
|
-
} catch (e) {
|
|
2037
|
-
showToast(t('msg_settings_save_failed') + ': ' + e.message, 'error');
|
|
2038
|
-
}
|
|
2039
|
-
}
|
|
2040
|
-
|
|
2041
129
|
init();
|