claude-controller 0.1.2 → 0.2.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/bin/ctl +867 -0
- package/bin/native-app.py +1 -1
- package/package.json +7 -5
- package/web/handler.py +190 -467
- package/web/handler_fs.py +152 -0
- package/web/handler_jobs.py +249 -0
- package/web/handler_sessions.py +132 -0
- package/web/jobs.py +9 -1
- package/web/pipeline.py +349 -0
- package/web/projects.py +192 -0
- package/web/static/api.js +54 -0
- package/web/static/app.js +17 -1937
- package/web/static/attachments.js +144 -0
- package/web/static/base.css +458 -0
- package/web/static/context.js +194 -0
- package/web/static/dirs.js +246 -0
- package/web/static/form.css +762 -0
- package/web/static/i18n.js +337 -0
- package/web/static/index.html +77 -11
- package/web/static/jobs.css +580 -0
- package/web/static/jobs.js +434 -0
- package/web/static/pipeline.css +31 -0
- package/web/static/pipelines.js +252 -0
- package/web/static/send.js +113 -0
- package/web/static/settings-style.css +260 -0
- package/web/static/settings.js +45 -0
- package/web/static/stream.js +311 -0
- package/web/static/utils.js +79 -0
- package/web/static/styles.css +0 -1922
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/* ═══════════════════════════════════════════════
|
|
2
|
+
Pipeline — on/off 자동화
|
|
3
|
+
상태: active (ON) / stopped (OFF)
|
|
4
|
+
═══════════════════════════════════════════════ */
|
|
5
|
+
|
|
6
|
+
let _pipelines = [];
|
|
7
|
+
let _pipelinePollTimer = null;
|
|
8
|
+
let _pipelineCountdownTimer = null;
|
|
9
|
+
|
|
10
|
+
async function fetchPipelines() {
|
|
11
|
+
try {
|
|
12
|
+
_pipelines = await apiFetch('/api/pipelines');
|
|
13
|
+
renderPipelines();
|
|
14
|
+
|
|
15
|
+
const hasActive = _pipelines.some(p => p.status === 'active');
|
|
16
|
+
if (hasActive && !_pipelinePollTimer) {
|
|
17
|
+
_pipelinePollTimer = setInterval(_pipelinePollTick, 5000);
|
|
18
|
+
} else if (!hasActive && _pipelinePollTimer) {
|
|
19
|
+
clearInterval(_pipelinePollTimer);
|
|
20
|
+
_pipelinePollTimer = null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const hasTimer = _pipelines.some(p => p.status === 'active');
|
|
24
|
+
if (hasTimer && !_pipelineCountdownTimer) {
|
|
25
|
+
_pipelineCountdownTimer = setInterval(_updateCountdowns, 1000);
|
|
26
|
+
} else if (!hasTimer && _pipelineCountdownTimer) {
|
|
27
|
+
clearInterval(_pipelineCountdownTimer);
|
|
28
|
+
_pipelineCountdownTimer = null;
|
|
29
|
+
}
|
|
30
|
+
} catch { /* silent */ }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function _pipelinePollTick() {
|
|
34
|
+
const hasRunning = _pipelines.some(p => p.status === 'active' && p.job_id);
|
|
35
|
+
if (hasRunning) {
|
|
36
|
+
try {
|
|
37
|
+
await apiFetch('/api/pipelines/tick-all', {
|
|
38
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}',
|
|
39
|
+
});
|
|
40
|
+
} catch { /* silent */ }
|
|
41
|
+
}
|
|
42
|
+
fetchPipelines();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function _formatTimer(sec) {
|
|
46
|
+
const m = Math.floor(sec / 60);
|
|
47
|
+
const s = sec % 60;
|
|
48
|
+
return `${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function _updateCountdowns() {
|
|
52
|
+
for (const p of _pipelines) {
|
|
53
|
+
if (p.status !== 'active') continue;
|
|
54
|
+
const el = document.querySelector(`[data-pipe-timer="${p.id}"]`);
|
|
55
|
+
if (!el) continue;
|
|
56
|
+
|
|
57
|
+
if (!p.next_run) {
|
|
58
|
+
el.textContent = _formatTimer(0);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const remaining = Math.max(0, Math.floor((new Date(p.next_run).getTime() - Date.now()) / 1000));
|
|
63
|
+
el.textContent = _formatTimer(remaining);
|
|
64
|
+
|
|
65
|
+
// 타이머 0 → 즉시 dispatch + 로컬에서 next_run 갱신
|
|
66
|
+
if (remaining <= 0 && !p._dispatching) {
|
|
67
|
+
p._dispatching = true;
|
|
68
|
+
// 로컬에서 즉시 next_run 갱신 → 타이머 끊김 방지
|
|
69
|
+
if (p.interval_sec) {
|
|
70
|
+
p.next_run = new Date(Date.now() + p.interval_sec * 1000).toISOString().slice(0, 19);
|
|
71
|
+
}
|
|
72
|
+
apiFetch(`/api/pipelines/${encodeURIComponent(p.id)}/run`, {
|
|
73
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}',
|
|
74
|
+
}).then(() => { fetchPipelines(); fetchJobs(); setTimeout(fetchJobs, 1000); })
|
|
75
|
+
.catch(() => {})
|
|
76
|
+
.finally(() => { p._dispatching = false; });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function renderPipelines() {
|
|
82
|
+
const container = document.getElementById('pipelineList');
|
|
83
|
+
const countEl = document.getElementById('pipelineCount');
|
|
84
|
+
if (!container) return;
|
|
85
|
+
|
|
86
|
+
if (countEl) countEl.textContent = _pipelines.length > 0 ? `(${_pipelines.length})` : '';
|
|
87
|
+
|
|
88
|
+
if (_pipelines.length === 0) {
|
|
89
|
+
container.innerHTML = '<div class="empty-state" style="padding:20px;text-align:center;color:var(--text-muted);font-size:0.8rem;">자동화가 없습니다.</div>';
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
container.innerHTML = _pipelines.map(p => {
|
|
94
|
+
const isOn = p.status === 'active';
|
|
95
|
+
const isRunning = isOn && p.job_id;
|
|
96
|
+
|
|
97
|
+
let timerHtml = '';
|
|
98
|
+
if (isOn) {
|
|
99
|
+
let remaining = 0;
|
|
100
|
+
if (p.next_run) {
|
|
101
|
+
remaining = Math.max(0, Math.floor((new Date(p.next_run).getTime() - Date.now()) / 1000));
|
|
102
|
+
}
|
|
103
|
+
timerHtml = `<span data-pipe-timer="${p.id}" style="font-family:var(--font-mono,monospace);font-size:0.72rem;color:var(--text-muted);">${_formatTimer(remaining)}</span>`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const cmdPreview = (p.command || '').substring(0, 80) + ((p.command || '').length > 80 ? '...' : '');
|
|
107
|
+
const intervalLabel = p.interval ? `<span style="font-size:0.65rem;padding:1px 5px;background:rgba(59,130,246,0.1);color:var(--primary);border-radius:3px;">${escapeHtml(p.interval)} 반복</span>` : '';
|
|
108
|
+
const runCount = p.run_count ? `<span style="font-size:0.65rem;color:var(--text-muted);">${p.run_count}회</span>` : '';
|
|
109
|
+
|
|
110
|
+
const toggleBtn = isOn
|
|
111
|
+
? `<button class="btn btn-sm" onclick="stopPipeline('${p.id}')">OFF</button>`
|
|
112
|
+
: `<button class="btn btn-sm btn-primary" onclick="runPipeline('${p.id}')">ON</button>`;
|
|
113
|
+
|
|
114
|
+
return `<div class="pipeline-card" data-pipe-id="${p.id}">
|
|
115
|
+
<div class="pipeline-card-header">
|
|
116
|
+
<div class="pipeline-card-title">${escapeHtml(p.name || p.id)}</div>
|
|
117
|
+
<div style="display:flex;gap:4px;align-items:center;">${intervalLabel} ${timerHtml}</div>
|
|
118
|
+
</div>
|
|
119
|
+
<div class="pipeline-card-goal" style="font-size:0.75rem;color:var(--text-secondary);margin:4px 0;font-family:var(--font-mono,monospace);">${escapeHtml(cmdPreview)}</div>
|
|
120
|
+
${p.last_error ? `<div style="font-size:0.7rem;color:var(--danger);margin:4px 0;padding:4px 8px;background:rgba(239,68,68,0.1);border-radius:4px;">${escapeHtml(p.last_error)}</div>` : ''}
|
|
121
|
+
<div style="display:flex;gap:8px;font-size:0.7rem;color:var(--text-muted);align-items:center;">
|
|
122
|
+
${runCount}
|
|
123
|
+
<span>${p.last_run || ''}</span>
|
|
124
|
+
</div>
|
|
125
|
+
<div class="pipeline-card-actions" style="margin-top:8px;display:flex;gap:6px;">
|
|
126
|
+
${toggleBtn}
|
|
127
|
+
<button class="btn btn-sm" onclick="editPipeline('${p.id}')">수정</button>
|
|
128
|
+
<button class="btn btn-sm btn-danger" onclick="deletePipeline('${p.id}')">삭제</button>
|
|
129
|
+
</div>
|
|
130
|
+
</div>`;
|
|
131
|
+
}).join('');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function runPipeline(pipeId) {
|
|
135
|
+
try {
|
|
136
|
+
const data = await apiFetch(`/api/pipelines/${encodeURIComponent(pipeId)}/run`, {
|
|
137
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
138
|
+
body: JSON.stringify({}),
|
|
139
|
+
});
|
|
140
|
+
showToast(`${data.name || ''}: ON`);
|
|
141
|
+
fetchPipelines();
|
|
142
|
+
fetchJobs();
|
|
143
|
+
setTimeout(fetchJobs, 1000);
|
|
144
|
+
} catch (err) {
|
|
145
|
+
showToast(err.message || '실행 실패', 'error');
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function stopPipeline(pipeId) {
|
|
150
|
+
try {
|
|
151
|
+
await apiFetch(`/api/pipelines/${encodeURIComponent(pipeId)}/stop`, {
|
|
152
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}',
|
|
153
|
+
});
|
|
154
|
+
showToast('자동화 OFF');
|
|
155
|
+
fetchPipelines();
|
|
156
|
+
} catch (err) {
|
|
157
|
+
showToast(err.message || 'OFF 실패', 'error');
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function editPipeline(pipeId) {
|
|
162
|
+
try {
|
|
163
|
+
const data = await apiFetch(`/api/pipelines/${encodeURIComponent(pipeId)}/status`);
|
|
164
|
+
const isOn = data.status === 'active';
|
|
165
|
+
const statusBadge = isOn
|
|
166
|
+
? '<span class="badge" style="background:var(--primary)20;color:var(--primary);">ON</span>'
|
|
167
|
+
: '<span class="badge" style="background:var(--text-muted)20;color:var(--text-muted);">OFF</span>';
|
|
168
|
+
|
|
169
|
+
const editId = 'pipeEdit_' + Date.now();
|
|
170
|
+
const overlay = document.createElement('div');
|
|
171
|
+
overlay.className = 'settings-overlay';
|
|
172
|
+
overlay.style.cssText = 'display:flex;align-items:center;justify-content:center;';
|
|
173
|
+
overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); };
|
|
174
|
+
overlay.innerHTML = `<div class="settings-panel" style="max-width:520px;margin:0;">
|
|
175
|
+
<div class="settings-header">
|
|
176
|
+
<div class="settings-title" style="display:flex;align-items:center;gap:8px;">
|
|
177
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
|
178
|
+
<span>자동화 수정</span>
|
|
179
|
+
${statusBadge}
|
|
180
|
+
</div>
|
|
181
|
+
<button class="settings-close" onclick="this.closest('.settings-overlay').remove()">
|
|
182
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
|
183
|
+
</button>
|
|
184
|
+
</div>
|
|
185
|
+
<div class="settings-body">
|
|
186
|
+
<div style="margin-bottom:12px;">
|
|
187
|
+
<label style="font-size:0.72rem;font-weight:600;color:var(--text-secondary);display:block;margin-bottom:4px;">이름</label>
|
|
188
|
+
<input id="${editId}_name" type="text" value="${escapeHtml(data.name || '')}"
|
|
189
|
+
style="width:100%;padding:6px 10px;border:1px solid var(--border);border-radius:6px;background:var(--bg-secondary);color:var(--text-primary);font-size:0.8rem;box-sizing:border-box;">
|
|
190
|
+
</div>
|
|
191
|
+
<div style="margin-bottom:12px;">
|
|
192
|
+
<label style="font-size:0.72rem;font-weight:600;color:var(--text-secondary);display:block;margin-bottom:4px;">명령어 (프롬프트)</label>
|
|
193
|
+
<textarea id="${editId}_cmd" rows="3"
|
|
194
|
+
style="width:100%;padding:6px 10px;border:1px solid var(--border);border-radius:6px;background:var(--bg-secondary);color:var(--text-primary);font-size:0.8rem;font-family:var(--font-mono,monospace);resize:vertical;box-sizing:border-box;">${escapeHtml(data.command || '')}</textarea>
|
|
195
|
+
</div>
|
|
196
|
+
<div style="margin-bottom:12px;">
|
|
197
|
+
<label style="font-size:0.72rem;font-weight:600;color:var(--text-secondary);display:block;margin-bottom:4px;">반복 간격 <span style="font-weight:400;color:var(--text-muted);">(예: 30s, 5m, 1h / 비우면 1회)</span></label>
|
|
198
|
+
<input id="${editId}_interval" type="text" value="${escapeHtml(data.interval || '')}" placeholder="예: 1m"
|
|
199
|
+
style="width:120px;padding:6px 10px;border:1px solid var(--border);border-radius:6px;background:var(--bg-secondary);color:var(--text-primary);font-size:0.8rem;">
|
|
200
|
+
</div>
|
|
201
|
+
<div style="font-size:0.7rem;color:var(--text-muted);display:flex;gap:12px;">
|
|
202
|
+
<span>경로: <code>${escapeHtml(data.project_path)}</code></span>
|
|
203
|
+
${data.run_count ? `<span>${data.run_count}회 실행</span>` : ''}
|
|
204
|
+
</div>
|
|
205
|
+
</div>
|
|
206
|
+
<div class="settings-footer" style="display:flex;gap:6px;">
|
|
207
|
+
<button class="btn btn-sm btn-primary" onclick="_savePipelineEdit('${data.id}','${editId}',this)">저장</button>
|
|
208
|
+
${isOn
|
|
209
|
+
? `<button class="btn btn-sm" onclick="stopPipeline('${data.id}');this.closest('.settings-overlay').remove();">OFF</button>`
|
|
210
|
+
: `<button class="btn btn-sm" onclick="runPipeline('${data.id}');this.closest('.settings-overlay').remove();">ON</button>`}
|
|
211
|
+
<button class="btn btn-sm" onclick="this.closest('.settings-overlay').remove()">닫기</button>
|
|
212
|
+
</div>
|
|
213
|
+
</div>`;
|
|
214
|
+
document.body.appendChild(overlay);
|
|
215
|
+
} catch (err) {
|
|
216
|
+
showToast(err.message || '조회 실패', 'error');
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function _savePipelineEdit(pipeId, editId, btn) {
|
|
221
|
+
const name = document.getElementById(editId + '_name').value.trim();
|
|
222
|
+
const command = document.getElementById(editId + '_cmd').value.trim();
|
|
223
|
+
const interval = document.getElementById(editId + '_interval').value.trim();
|
|
224
|
+
if (!command) { showToast('명령어를 입력하세요', 'error'); return; }
|
|
225
|
+
|
|
226
|
+
btn.disabled = true;
|
|
227
|
+
btn.textContent = '저장 중...';
|
|
228
|
+
try {
|
|
229
|
+
await apiFetch(`/api/pipelines/${encodeURIComponent(pipeId)}/update`, {
|
|
230
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
231
|
+
body: JSON.stringify({ name, command, interval }),
|
|
232
|
+
});
|
|
233
|
+
showToast('자동화 수정 완료');
|
|
234
|
+
btn.closest('.settings-overlay').remove();
|
|
235
|
+
fetchPipelines();
|
|
236
|
+
} catch (err) {
|
|
237
|
+
showToast(err.message || '수정 실패', 'error');
|
|
238
|
+
btn.disabled = false;
|
|
239
|
+
btn.textContent = '저장';
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function deletePipeline(pipeId) {
|
|
244
|
+
if (!confirm('이 자동화를 삭제하시겠습니까?')) return;
|
|
245
|
+
try {
|
|
246
|
+
await apiFetch(`/api/pipelines/${encodeURIComponent(pipeId)}`, { method: 'DELETE' });
|
|
247
|
+
showToast('자동화 삭제됨');
|
|
248
|
+
fetchPipelines();
|
|
249
|
+
} catch (err) {
|
|
250
|
+
showToast(err.message || '삭제 실패', 'error');
|
|
251
|
+
}
|
|
252
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/* ═══════════════════════════════════════════════
|
|
2
|
+
Send Task — 작업 전송 및 자동화 토글
|
|
3
|
+
═══════════════════════════════════════════════ */
|
|
4
|
+
|
|
5
|
+
let _sendLock = false;
|
|
6
|
+
let _automationMode = false;
|
|
7
|
+
|
|
8
|
+
function toggleAutomation() {
|
|
9
|
+
_automationMode = !_automationMode;
|
|
10
|
+
const row = document.getElementById('automationRow');
|
|
11
|
+
const btn = document.getElementById('btnAutoToggle');
|
|
12
|
+
const sendBtn = document.getElementById('btnSend');
|
|
13
|
+
if (_automationMode) {
|
|
14
|
+
row.style.display = 'flex';
|
|
15
|
+
btn.style.cssText = 'border-color:var(--accent);color:var(--accent);background:rgba(99,102,241,0.1);';
|
|
16
|
+
sendBtn.querySelector('span').textContent = '자동화 등록';
|
|
17
|
+
} else {
|
|
18
|
+
row.style.display = 'none';
|
|
19
|
+
btn.style.cssText = '';
|
|
20
|
+
sendBtn.querySelector('span').textContent = t('send');
|
|
21
|
+
document.getElementById('automationInterval').value = '';
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function clearPromptForm() {
|
|
26
|
+
document.getElementById('promptInput').value = '';
|
|
27
|
+
clearAttachments();
|
|
28
|
+
updatePromptMirror();
|
|
29
|
+
clearDirSelection();
|
|
30
|
+
if (_automationMode) toggleAutomation();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function sendTask(e) {
|
|
34
|
+
e.preventDefault();
|
|
35
|
+
if (_sendLock) return false;
|
|
36
|
+
|
|
37
|
+
const prompt = document.getElementById('promptInput').value.trim();
|
|
38
|
+
if (!prompt) {
|
|
39
|
+
showToast(t('msg_prompt_required'), 'error');
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ── 자동화 모드: 파이프라인 생성 ──
|
|
44
|
+
if (_automationMode) {
|
|
45
|
+
const cwd = document.getElementById('cwdInput').value.trim();
|
|
46
|
+
if (!cwd) { showToast('디렉토리를 선택하세요', 'error'); return false; }
|
|
47
|
+
const interval = document.getElementById('automationInterval').value.trim();
|
|
48
|
+
_sendLock = true;
|
|
49
|
+
const btn = document.getElementById('btnSend');
|
|
50
|
+
btn.disabled = true;
|
|
51
|
+
try {
|
|
52
|
+
const pipe = await apiFetch('/api/pipelines', {
|
|
53
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
54
|
+
body: JSON.stringify({ project_path: cwd, command: prompt, interval }),
|
|
55
|
+
});
|
|
56
|
+
showToast(`자동화 등록: ${pipe.name || pipe.id}`);
|
|
57
|
+
document.getElementById('promptInput').value = '';
|
|
58
|
+
toggleAutomation();
|
|
59
|
+
fetchPipelines();
|
|
60
|
+
runPipeline(pipe.id);
|
|
61
|
+
} catch (err) {
|
|
62
|
+
showToast(`등록 실패: ${err.message}`, 'error');
|
|
63
|
+
} finally {
|
|
64
|
+
_sendLock = false;
|
|
65
|
+
btn.disabled = false;
|
|
66
|
+
btn.querySelector('span').textContent = t('send');
|
|
67
|
+
}
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ── 일반 전송 모드 ──
|
|
72
|
+
_sendLock = true;
|
|
73
|
+
const cwd = document.getElementById('cwdInput').value.trim();
|
|
74
|
+
const btn = document.getElementById('btnSend');
|
|
75
|
+
btn.disabled = true;
|
|
76
|
+
btn.innerHTML = '<span class="spinner"></span> 전송 중...';
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
let finalPrompt = prompt;
|
|
80
|
+
const filePaths = [];
|
|
81
|
+
attachments.forEach((att, idx) => {
|
|
82
|
+
if (att && att.serverPath) {
|
|
83
|
+
finalPrompt = finalPrompt.replace(new RegExp(`@image${idx}\\b`, 'g'), `@${att.serverPath}`);
|
|
84
|
+
filePaths.push(att.serverPath);
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const body = { prompt: finalPrompt };
|
|
89
|
+
if (cwd) body.cwd = cwd;
|
|
90
|
+
|
|
91
|
+
if (_contextSessionId && (_contextMode === 'resume' || _contextMode === 'fork')) {
|
|
92
|
+
body.session = _contextMode + ':' + _contextSessionId;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (filePaths.length > 0) body.images = filePaths;
|
|
96
|
+
|
|
97
|
+
await apiFetch('/api/send', { method: 'POST', body: JSON.stringify(body) });
|
|
98
|
+
const modeMsg = _contextMode === 'resume' ? ' (resume)' : _contextMode === 'fork' ? ' (fork)' : '';
|
|
99
|
+
showToast(t('msg_task_sent') + modeMsg);
|
|
100
|
+
if (cwd) addRecentDir(cwd);
|
|
101
|
+
document.getElementById('promptInput').value = '';
|
|
102
|
+
clearAttachments();
|
|
103
|
+
clearContext();
|
|
104
|
+
fetchJobs();
|
|
105
|
+
} catch (err) {
|
|
106
|
+
showToast(`${t('msg_send_failed')}: ${err.message}`, 'error');
|
|
107
|
+
} finally {
|
|
108
|
+
_sendLock = false;
|
|
109
|
+
btn.disabled = false;
|
|
110
|
+
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>';
|
|
111
|
+
}
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/* ── Settings & Overlays ── */
|
|
2
|
+
|
|
3
|
+
/* ── Settings Panel ── */
|
|
4
|
+
.settings-fab {
|
|
5
|
+
position: fixed;
|
|
6
|
+
bottom: 24px;
|
|
7
|
+
right: 24px;
|
|
8
|
+
z-index: 300;
|
|
9
|
+
width: 44px;
|
|
10
|
+
height: 44px;
|
|
11
|
+
border-radius: 50%;
|
|
12
|
+
background: var(--surface);
|
|
13
|
+
border: 1px solid var(--border);
|
|
14
|
+
color: var(--text-secondary);
|
|
15
|
+
display: flex;
|
|
16
|
+
align-items: center;
|
|
17
|
+
justify-content: center;
|
|
18
|
+
cursor: pointer;
|
|
19
|
+
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
|
|
20
|
+
transition: all var(--transition);
|
|
21
|
+
}
|
|
22
|
+
.settings-fab:hover {
|
|
23
|
+
background: var(--surface-hover);
|
|
24
|
+
color: var(--accent);
|
|
25
|
+
border-color: var(--accent);
|
|
26
|
+
transform: rotate(30deg);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.settings-overlay {
|
|
30
|
+
display: none;
|
|
31
|
+
position: fixed;
|
|
32
|
+
inset: 0;
|
|
33
|
+
z-index: 400;
|
|
34
|
+
background: rgba(0,0,0,0.5);
|
|
35
|
+
backdrop-filter: blur(4px);
|
|
36
|
+
animation: fadeIn 0.2s ease;
|
|
37
|
+
}
|
|
38
|
+
.settings-overlay.open { display: flex; align-items: center; justify-content: center; }
|
|
39
|
+
|
|
40
|
+
.settings-panel {
|
|
41
|
+
width: 520px;
|
|
42
|
+
max-width: 90vw;
|
|
43
|
+
max-height: 85vh;
|
|
44
|
+
background: var(--surface);
|
|
45
|
+
border: 1px solid var(--border);
|
|
46
|
+
border-radius: var(--radius-lg);
|
|
47
|
+
box-shadow: 0 16px 48px rgba(0,0,0,0.5);
|
|
48
|
+
display: flex;
|
|
49
|
+
flex-direction: column;
|
|
50
|
+
overflow: hidden;
|
|
51
|
+
animation: panel-slide 0.2s ease-out;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
.settings-header {
|
|
55
|
+
display: flex;
|
|
56
|
+
align-items: center;
|
|
57
|
+
justify-content: space-between;
|
|
58
|
+
padding: 16px 20px;
|
|
59
|
+
border-bottom: 1px solid var(--border);
|
|
60
|
+
}
|
|
61
|
+
.settings-title {
|
|
62
|
+
font-size: 1rem;
|
|
63
|
+
font-weight: 700;
|
|
64
|
+
color: var(--text);
|
|
65
|
+
display: flex;
|
|
66
|
+
align-items: center;
|
|
67
|
+
gap: 8px;
|
|
68
|
+
}
|
|
69
|
+
.settings-title svg { width: 18px; height: 18px; color: var(--accent); }
|
|
70
|
+
.settings-close {
|
|
71
|
+
padding: 6px;
|
|
72
|
+
border-radius: var(--radius);
|
|
73
|
+
background: transparent;
|
|
74
|
+
color: var(--text-muted);
|
|
75
|
+
border: none;
|
|
76
|
+
cursor: pointer;
|
|
77
|
+
}
|
|
78
|
+
.settings-close:hover { background: var(--surface-active); color: var(--text); }
|
|
79
|
+
|
|
80
|
+
.settings-body {
|
|
81
|
+
flex: 1;
|
|
82
|
+
overflow-y: auto;
|
|
83
|
+
padding: 20px;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.settings-section {
|
|
87
|
+
margin-bottom: 20px;
|
|
88
|
+
}
|
|
89
|
+
.settings-section-title {
|
|
90
|
+
font-size: 0.72rem;
|
|
91
|
+
font-weight: 600;
|
|
92
|
+
color: var(--text-muted);
|
|
93
|
+
text-transform: uppercase;
|
|
94
|
+
letter-spacing: 0.06em;
|
|
95
|
+
margin-bottom: 12px;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.setting-row {
|
|
99
|
+
display: flex;
|
|
100
|
+
align-items: flex-start;
|
|
101
|
+
justify-content: space-between;
|
|
102
|
+
padding: 10px 0;
|
|
103
|
+
border-bottom: 1px solid var(--border);
|
|
104
|
+
gap: 16px;
|
|
105
|
+
}
|
|
106
|
+
.setting-row:last-child { border-bottom: none; }
|
|
107
|
+
|
|
108
|
+
.setting-info { flex: 1; min-width: 0; }
|
|
109
|
+
.setting-label {
|
|
110
|
+
font-size: 0.85rem;
|
|
111
|
+
font-weight: 500;
|
|
112
|
+
color: var(--text);
|
|
113
|
+
margin-bottom: 2px;
|
|
114
|
+
}
|
|
115
|
+
.setting-desc {
|
|
116
|
+
font-size: 0.72rem;
|
|
117
|
+
color: var(--text-muted);
|
|
118
|
+
line-height: 1.4;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
.setting-control { flex-shrink: 0; }
|
|
122
|
+
|
|
123
|
+
/* Toggle Switch */
|
|
124
|
+
.toggle {
|
|
125
|
+
position: relative;
|
|
126
|
+
width: 40px;
|
|
127
|
+
height: 22px;
|
|
128
|
+
cursor: pointer;
|
|
129
|
+
}
|
|
130
|
+
.toggle input { display: none; }
|
|
131
|
+
.toggle-track {
|
|
132
|
+
position: absolute;
|
|
133
|
+
inset: 0;
|
|
134
|
+
background: var(--border);
|
|
135
|
+
border-radius: 11px;
|
|
136
|
+
transition: background var(--transition);
|
|
137
|
+
}
|
|
138
|
+
.toggle input:checked + .toggle-track { background: var(--accent); }
|
|
139
|
+
.toggle-thumb {
|
|
140
|
+
position: absolute;
|
|
141
|
+
top: 2px;
|
|
142
|
+
left: 2px;
|
|
143
|
+
width: 18px;
|
|
144
|
+
height: 18px;
|
|
145
|
+
border-radius: 50%;
|
|
146
|
+
background: #fff;
|
|
147
|
+
transition: transform var(--transition);
|
|
148
|
+
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
|
|
149
|
+
}
|
|
150
|
+
.toggle input:checked ~ .toggle-thumb { transform: translateX(18px); }
|
|
151
|
+
|
|
152
|
+
.setting-input {
|
|
153
|
+
width: 100%;
|
|
154
|
+
padding: 7px 10px;
|
|
155
|
+
background: var(--bg);
|
|
156
|
+
border: 1px solid var(--border);
|
|
157
|
+
border-radius: var(--radius);
|
|
158
|
+
color: var(--text);
|
|
159
|
+
font-family: var(--font-mono);
|
|
160
|
+
font-size: 0.8rem;
|
|
161
|
+
transition: border-color var(--transition);
|
|
162
|
+
}
|
|
163
|
+
.setting-input:focus {
|
|
164
|
+
outline: none;
|
|
165
|
+
border-color: var(--accent);
|
|
166
|
+
box-shadow: 0 0 0 3px var(--accent-glow);
|
|
167
|
+
}
|
|
168
|
+
.setting-input-sm { width: 80px; text-align: center; }
|
|
169
|
+
|
|
170
|
+
.settings-footer {
|
|
171
|
+
display: flex;
|
|
172
|
+
align-items: center;
|
|
173
|
+
justify-content: flex-end;
|
|
174
|
+
gap: 8px;
|
|
175
|
+
padding: 14px 20px;
|
|
176
|
+
border-top: 1px solid var(--border);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
.setting-restart-hint {
|
|
180
|
+
font-size: 0.7rem;
|
|
181
|
+
color: var(--yellow);
|
|
182
|
+
margin-right: auto;
|
|
183
|
+
display: flex;
|
|
184
|
+
align-items: center;
|
|
185
|
+
gap: 4px;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/* ── Connection Modal ── */
|
|
189
|
+
.conn-message {
|
|
190
|
+
font-size: 0.8rem;
|
|
191
|
+
color: var(--red);
|
|
192
|
+
background: var(--red-dim);
|
|
193
|
+
border: 1px solid rgba(248, 113, 113, 0.25);
|
|
194
|
+
border-radius: var(--radius);
|
|
195
|
+
padding: 8px 12px;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/* ── Running Job Preview Row ── */
|
|
199
|
+
.preview-row {
|
|
200
|
+
cursor: default !important;
|
|
201
|
+
}
|
|
202
|
+
.preview-row,
|
|
203
|
+
.preview-row:hover {
|
|
204
|
+
background: transparent !important;
|
|
205
|
+
}
|
|
206
|
+
.preview-row td {
|
|
207
|
+
padding: 0 !important;
|
|
208
|
+
border-bottom: 1px solid var(--border) !important;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
.job-preview {
|
|
212
|
+
padding: 6px 14px;
|
|
213
|
+
background: var(--bg);
|
|
214
|
+
font-family: var(--font-mono);
|
|
215
|
+
font-size: 0.78rem;
|
|
216
|
+
line-height: 1.7;
|
|
217
|
+
color: var(--text-muted);
|
|
218
|
+
max-height: calc(0.78rem * 1.7 * 2 + 6px + 6px);
|
|
219
|
+
overflow: hidden;
|
|
220
|
+
box-sizing: content-box;
|
|
221
|
+
}
|
|
222
|
+
.preview-lines {
|
|
223
|
+
min-width: 0;
|
|
224
|
+
overflow: hidden;
|
|
225
|
+
}
|
|
226
|
+
.preview-line {
|
|
227
|
+
padding: 2px 0;
|
|
228
|
+
word-break: break-word;
|
|
229
|
+
}
|
|
230
|
+
.preview-line + .preview-line {
|
|
231
|
+
margin-top: 1px;
|
|
232
|
+
}
|
|
233
|
+
.preview-tool {
|
|
234
|
+
display: inline-flex;
|
|
235
|
+
align-items: center;
|
|
236
|
+
gap: 4px;
|
|
237
|
+
padding: 2px 8px;
|
|
238
|
+
border-radius: 4px;
|
|
239
|
+
background: var(--yellow-dim);
|
|
240
|
+
color: var(--yellow);
|
|
241
|
+
font-size: 0.7rem;
|
|
242
|
+
font-weight: 600;
|
|
243
|
+
white-space: nowrap;
|
|
244
|
+
margin-right: 4px;
|
|
245
|
+
}
|
|
246
|
+
.preview-tool::before {
|
|
247
|
+
content: '>';
|
|
248
|
+
opacity: 0.5;
|
|
249
|
+
}
|
|
250
|
+
.preview-result {
|
|
251
|
+
color: var(--green);
|
|
252
|
+
}
|
|
253
|
+
.preview-result::before {
|
|
254
|
+
content: '✓ ';
|
|
255
|
+
font-weight: 700;
|
|
256
|
+
}
|
|
257
|
+
.preview-text {
|
|
258
|
+
color: var(--text-muted);
|
|
259
|
+
}
|
|
260
|
+
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/* ═══════════════════════════════════════════════
|
|
2
|
+
Settings — 설정 패널
|
|
3
|
+
═══════════════════════════════════════════════ */
|
|
4
|
+
|
|
5
|
+
let _settingsData = {};
|
|
6
|
+
|
|
7
|
+
function openSettings() {
|
|
8
|
+
loadSettings().then(() => {
|
|
9
|
+
document.getElementById('settingsOverlay').classList.add('open');
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function closeSettings() {
|
|
14
|
+
document.getElementById('settingsOverlay').classList.remove('open');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function loadSettings() {
|
|
18
|
+
try {
|
|
19
|
+
_settingsData = await apiFetch('/api/config');
|
|
20
|
+
} catch {
|
|
21
|
+
_settingsData = {};
|
|
22
|
+
}
|
|
23
|
+
_populateSettingsUI();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function _populateSettingsUI() {
|
|
27
|
+
const d = _settingsData;
|
|
28
|
+
const sel = document.getElementById('cfgLocale');
|
|
29
|
+
if (sel) sel.value = d.locale || _currentLocale;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function saveSettings() {
|
|
33
|
+
const locale = document.getElementById('cfgLocale').value;
|
|
34
|
+
const payload = { locale };
|
|
35
|
+
try {
|
|
36
|
+
await apiFetch('/api/config', {
|
|
37
|
+
method: 'POST',
|
|
38
|
+
body: JSON.stringify(payload),
|
|
39
|
+
});
|
|
40
|
+
setLocale(locale);
|
|
41
|
+
showToast(t('msg_settings_saved'));
|
|
42
|
+
} catch (e) {
|
|
43
|
+
showToast(t('msg_settings_save_failed') + ': ' + e.message, 'error');
|
|
44
|
+
}
|
|
45
|
+
}
|