commit-ai-agent 1.0.5 → 1.0.7
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/.env.example +2 -1
- package/README.md +17 -12
- package/bin/cli.js +6 -7
- package/package.json +1 -1
- package/public/app.js +310 -149
- package/public/index.html +131 -2
- package/public/style.css +792 -162
- package/src/config.js +32 -0
- package/src/server.js +106 -18
package/public/app.js
CHANGED
|
@@ -3,10 +3,66 @@
|
|
|
3
3
|
// ── State ──
|
|
4
4
|
let selectedProject = null;
|
|
5
5
|
let isAnalyzing = false;
|
|
6
|
-
let analyzeMode =
|
|
6
|
+
let analyzeMode = "commit"; // 'commit' | 'status'
|
|
7
|
+
let isSingleProject = false;
|
|
8
|
+
let singleProjectName = "";
|
|
9
|
+
|
|
10
|
+
// ── Aria State Machine ──
|
|
11
|
+
function setAriaState(state, opts = {}) {
|
|
12
|
+
const robotWrap = document.getElementById("aria-robot-wrap");
|
|
13
|
+
const bubbleText = document.getElementById("aria-bubble-text");
|
|
14
|
+
const typingDots = document.getElementById("aria-typing-dots");
|
|
15
|
+
const chipDot = document.getElementById("aria-chip-dot");
|
|
16
|
+
const chipText = document.getElementById("aria-chip-text");
|
|
17
|
+
if (!robotWrap || !bubbleText) return;
|
|
18
|
+
|
|
19
|
+
// Base state (strip -commit / -status suffix for robot/chip)
|
|
20
|
+
const baseState = state.startsWith("ready") ? "ready" : state;
|
|
21
|
+
|
|
22
|
+
// Robot animation class
|
|
23
|
+
robotWrap.className = `aria-robot-wrap ${baseState}`;
|
|
24
|
+
|
|
25
|
+
// Header chip
|
|
26
|
+
const chipMap = {
|
|
27
|
+
idle: { cls: "idle", label: "Hanni · 대기 중" },
|
|
28
|
+
ready: { cls: "ready", label: "Hanni · 준비됨" },
|
|
29
|
+
thinking: { cls: "thinking", label: "Hanni · 분석 중..." },
|
|
30
|
+
done: { cls: "done", label: "Hanni · 완료" },
|
|
31
|
+
error: { cls: "error", label: "Hanni · 오류" },
|
|
32
|
+
};
|
|
33
|
+
const cm = chipMap[baseState] || chipMap.idle;
|
|
34
|
+
if (chipDot) chipDot.className = `aria-chip-dot ${cm.cls}`;
|
|
35
|
+
if (chipText) chipText.textContent = cm.label;
|
|
36
|
+
|
|
37
|
+
// Bubble messages
|
|
38
|
+
const p = opts.project ? `<strong>${opts.project}</strong>` : "";
|
|
39
|
+
const msgMap = {
|
|
40
|
+
idle: "어떤 프로젝트의 커밋을 분석해드릴까요?",
|
|
41
|
+
"ready-commit": `${p} 최근 커밋을 확인했어요. 분석을 시작할까요? 👀`,
|
|
42
|
+
"ready-status":
|
|
43
|
+
opts.n > 0
|
|
44
|
+
? `${p}에서 변경된 파일 <strong>${opts.n}개</strong>를 발견했어요. 리뷰해드릴까요?`
|
|
45
|
+
: `${p}에 현재 변경사항이 없어요.`,
|
|
46
|
+
thinking: "코드를 꼼꼼히 살펴보고 있어요",
|
|
47
|
+
done: "분석 완료! 리포트를 확인해보세요. 😊",
|
|
48
|
+
error: "앗, 문제가 발생했어요. 다시 시도해볼까요?",
|
|
49
|
+
};
|
|
50
|
+
const newMsg = msgMap[state] || msgMap.idle;
|
|
51
|
+
|
|
52
|
+
// Fade transition
|
|
53
|
+
bubbleText.style.opacity = "0";
|
|
54
|
+
bubbleText.style.transform = "translateY(4px)";
|
|
55
|
+
setTimeout(() => {
|
|
56
|
+
bubbleText.innerHTML = newMsg;
|
|
57
|
+
if (typingDots)
|
|
58
|
+
typingDots.style.display = state === "thinking" ? "inline-flex" : "none";
|
|
59
|
+
bubbleText.style.opacity = "1";
|
|
60
|
+
bubbleText.style.transform = "translateY(0)";
|
|
61
|
+
}, 180);
|
|
62
|
+
}
|
|
7
63
|
|
|
8
64
|
// ── Boot ──
|
|
9
|
-
document.addEventListener(
|
|
65
|
+
document.addEventListener("DOMContentLoaded", () => {
|
|
10
66
|
init();
|
|
11
67
|
});
|
|
12
68
|
|
|
@@ -14,40 +70,81 @@ async function init() {
|
|
|
14
70
|
setupTabs();
|
|
15
71
|
setupModeToggle();
|
|
16
72
|
await checkConfig();
|
|
17
|
-
|
|
73
|
+
|
|
74
|
+
if (isSingleProject) {
|
|
75
|
+
await enterSingleProjectMode();
|
|
76
|
+
} else {
|
|
77
|
+
await loadProjects();
|
|
78
|
+
setAriaState("idle");
|
|
79
|
+
}
|
|
18
80
|
|
|
19
81
|
// wire static event listeners (elements guaranteed to exist now)
|
|
20
|
-
document
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
document
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
document.getElementById(
|
|
27
|
-
|
|
82
|
+
document
|
|
83
|
+
.getElementById("refresh-projects")
|
|
84
|
+
.addEventListener("click", loadProjects);
|
|
85
|
+
document
|
|
86
|
+
.getElementById("analyze-btn")
|
|
87
|
+
.addEventListener("click", onAnalyzeClick);
|
|
88
|
+
document.getElementById("copy-btn").addEventListener("click", onCopy);
|
|
89
|
+
document.getElementById("close-report-btn").addEventListener("click", () => {
|
|
90
|
+
document.getElementById("report-viewer").style.display = "none";
|
|
28
91
|
});
|
|
29
|
-
document.getElementById(
|
|
30
|
-
togglePre(
|
|
92
|
+
document.getElementById("diff-toggle-btn").addEventListener("click", () => {
|
|
93
|
+
togglePre("diff-content", "diff-toggle-btn");
|
|
31
94
|
});
|
|
95
|
+
document
|
|
96
|
+
.getElementById("status-diff-toggle-btn")
|
|
97
|
+
.addEventListener("click", () => {
|
|
98
|
+
togglePre("status-diff-content", "status-diff-toggle-btn");
|
|
99
|
+
});
|
|
32
100
|
}
|
|
33
101
|
|
|
34
102
|
// ── Config check ──
|
|
35
103
|
async function checkConfig() {
|
|
36
104
|
try {
|
|
37
|
-
const res = await fetch(
|
|
38
|
-
const
|
|
39
|
-
if (!hasKey) {
|
|
40
|
-
document.getElementById(
|
|
105
|
+
const res = await fetch("/api/config");
|
|
106
|
+
const data = await res.json();
|
|
107
|
+
if (!data.hasKey) {
|
|
108
|
+
document.getElementById("api-key-warn").style.display = "flex";
|
|
109
|
+
}
|
|
110
|
+
if (data.isSingleProject) {
|
|
111
|
+
isSingleProject = true;
|
|
112
|
+
singleProjectName = data.singleProjectName || "project";
|
|
41
113
|
}
|
|
42
114
|
} catch {}
|
|
43
115
|
}
|
|
44
116
|
|
|
117
|
+
// ── Single Project Mode ──
|
|
118
|
+
async function enterSingleProjectMode() {
|
|
119
|
+
// 프로젝트 선택 UI 숨김
|
|
120
|
+
const header = document.querySelector(".selector-card .card-header");
|
|
121
|
+
const modeToggle = document.querySelector(".mode-toggle");
|
|
122
|
+
const projectGrid = document.getElementById("project-grid");
|
|
123
|
+
const selectedHint = document.getElementById("selected-hint");
|
|
124
|
+
if (header) header.style.display = "none";
|
|
125
|
+
if (modeToggle) modeToggle.style.display = "none";
|
|
126
|
+
if (projectGrid) projectGrid.style.display = "none";
|
|
127
|
+
if (selectedHint) selectedHint.style.display = "none";
|
|
128
|
+
|
|
129
|
+
// 현재 디렉토리를 프로젝트로 자동 선택
|
|
130
|
+
selectedProject = "__self__";
|
|
131
|
+
analyzeMode = "status";
|
|
132
|
+
|
|
133
|
+
document.getElementById("analyze-btn").disabled = false;
|
|
134
|
+
const btnText = document.getElementById("analyze-btn-text");
|
|
135
|
+
if (btnText) btnText.textContent = "Hanni에게 리뷰 요청";
|
|
136
|
+
|
|
137
|
+
// 변경사항 미리 로드
|
|
138
|
+
await fetchStatusPreview();
|
|
139
|
+
}
|
|
140
|
+
|
|
45
141
|
// ── Projects ──
|
|
46
142
|
async function loadProjects() {
|
|
47
|
-
const grid = document.getElementById(
|
|
48
|
-
grid.innerHTML =
|
|
143
|
+
const grid = document.getElementById("project-grid");
|
|
144
|
+
grid.innerHTML =
|
|
145
|
+
'<div class="skeleton-grid"><div class="skeleton"></div><div class="skeleton"></div><div class="skeleton"></div><div class="skeleton"></div></div>';
|
|
49
146
|
try {
|
|
50
|
-
const res = await fetch(
|
|
147
|
+
const res = await fetch("/api/projects");
|
|
51
148
|
const { projects } = await res.json();
|
|
52
149
|
renderProjects(projects);
|
|
53
150
|
} catch (err) {
|
|
@@ -56,43 +153,51 @@ async function loadProjects() {
|
|
|
56
153
|
}
|
|
57
154
|
|
|
58
155
|
function renderProjects(projects) {
|
|
59
|
-
const grid = document.getElementById(
|
|
156
|
+
const grid = document.getElementById("project-grid");
|
|
60
157
|
if (!projects || projects.length === 0) {
|
|
61
|
-
grid.innerHTML =
|
|
158
|
+
grid.innerHTML =
|
|
159
|
+
'<p style="color:var(--text3);font-size:14px">git 프로젝트가 없습니다.</p>';
|
|
62
160
|
return;
|
|
63
161
|
}
|
|
64
|
-
grid.innerHTML = projects
|
|
162
|
+
grid.innerHTML = projects
|
|
163
|
+
.map(
|
|
164
|
+
(p) => `
|
|
65
165
|
<div class="project-item" data-name="${p.name}">
|
|
66
166
|
<span class="proj-icon">${getProjectIcon(p.name)}</span>
|
|
67
167
|
<span class="proj-name">${p.name}</span>
|
|
68
168
|
</div>
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
169
|
+
`,
|
|
170
|
+
)
|
|
171
|
+
.join("");
|
|
172
|
+
grid.querySelectorAll(".project-item").forEach((el) => {
|
|
173
|
+
el.addEventListener("click", () => selectProject(el));
|
|
72
174
|
});
|
|
73
175
|
}
|
|
74
176
|
|
|
75
177
|
function getProjectIcon(name) {
|
|
76
|
-
if (name.includes(
|
|
77
|
-
if (name.includes(
|
|
78
|
-
if (name.includes(
|
|
79
|
-
if (name.includes(
|
|
80
|
-
if (name.includes(
|
|
81
|
-
if (name.includes(
|
|
82
|
-
return
|
|
178
|
+
if (name.includes("next") || name.includes("react")) return "⚛️";
|
|
179
|
+
if (name.includes("nest") || name.includes("api")) return "🐉";
|
|
180
|
+
if (name.includes("hook")) return "🪝";
|
|
181
|
+
if (name.includes("portfolio")) return "🎨";
|
|
182
|
+
if (name.includes("todo")) return "✅";
|
|
183
|
+
if (name.includes("doc")) return "📚";
|
|
184
|
+
return "📁";
|
|
83
185
|
}
|
|
84
186
|
|
|
85
187
|
// ── Project Selection ──
|
|
86
188
|
async function selectProject(el) {
|
|
87
|
-
document
|
|
88
|
-
|
|
189
|
+
document
|
|
190
|
+
.querySelectorAll(".project-item")
|
|
191
|
+
.forEach((e) => e.classList.remove("selected"));
|
|
192
|
+
el.classList.add("selected");
|
|
89
193
|
selectedProject = el.dataset.name;
|
|
90
|
-
document.getElementById(
|
|
91
|
-
|
|
92
|
-
document.getElementById(
|
|
93
|
-
document.getElementById(
|
|
194
|
+
document.getElementById("selected-hint").textContent =
|
|
195
|
+
`선택됨: ${selectedProject}`;
|
|
196
|
+
document.getElementById("analyze-btn").disabled = false;
|
|
197
|
+
document.getElementById("commit-card").style.display = "none";
|
|
198
|
+
document.getElementById("status-card").style.display = "none";
|
|
94
199
|
|
|
95
|
-
if (analyzeMode ===
|
|
200
|
+
if (analyzeMode === "commit") {
|
|
96
201
|
await fetchCommitPreview();
|
|
97
202
|
} else {
|
|
98
203
|
await fetchStatusPreview();
|
|
@@ -100,91 +205,127 @@ async function selectProject(el) {
|
|
|
100
205
|
}
|
|
101
206
|
|
|
102
207
|
async function fetchCommitPreview() {
|
|
208
|
+
const displayName = isSingleProject ? singleProjectName : selectedProject;
|
|
103
209
|
try {
|
|
104
|
-
const res = await fetch(
|
|
210
|
+
const res = await fetch(
|
|
211
|
+
`/api/projects/${encodeURIComponent(selectedProject)}/commit`,
|
|
212
|
+
);
|
|
105
213
|
const { commit, error } = await res.json();
|
|
106
214
|
if (error) throw new Error(error);
|
|
107
215
|
renderCommitCard(commit);
|
|
108
|
-
document.getElementById(
|
|
216
|
+
document.getElementById("commit-card").style.display = "block";
|
|
217
|
+
setAriaState("ready-commit", { project: displayName });
|
|
109
218
|
} catch (e) {
|
|
110
|
-
console.warn(
|
|
219
|
+
console.warn("commit preview failed:", e.message);
|
|
220
|
+
setAriaState("ready-commit", { project: displayName });
|
|
111
221
|
}
|
|
112
222
|
}
|
|
113
223
|
|
|
114
224
|
async function fetchStatusPreview() {
|
|
225
|
+
const displayName = isSingleProject ? singleProjectName : selectedProject;
|
|
115
226
|
try {
|
|
116
|
-
const res = await fetch(
|
|
227
|
+
const res = await fetch(
|
|
228
|
+
`/api/projects/${encodeURIComponent(selectedProject)}/status`,
|
|
229
|
+
);
|
|
117
230
|
const { status, error } = await res.json();
|
|
118
231
|
if (error) throw new Error(error);
|
|
119
232
|
if (status) {
|
|
120
233
|
renderStatusCard(status);
|
|
121
|
-
document.getElementById(
|
|
234
|
+
document.getElementById("status-card").style.display = "block";
|
|
235
|
+
setAriaState("ready-status", {
|
|
236
|
+
project: displayName,
|
|
237
|
+
n: status.totalFiles,
|
|
238
|
+
});
|
|
122
239
|
} else {
|
|
123
|
-
document.getElementById(
|
|
240
|
+
const hint = document.getElementById("selected-hint");
|
|
241
|
+
if (hint) hint.textContent = `${displayName} — 변경사항 없음`;
|
|
242
|
+
setAriaState("ready-status", { project: displayName, n: 0 });
|
|
124
243
|
}
|
|
125
244
|
} catch (e) {
|
|
126
|
-
console.warn(
|
|
245
|
+
console.warn("status preview failed:", e.message);
|
|
246
|
+
setAriaState("ready-status", { project: displayName, n: 0 });
|
|
127
247
|
}
|
|
128
248
|
}
|
|
129
249
|
|
|
130
250
|
// ── Commit Card ──
|
|
131
251
|
function renderCommitCard(c) {
|
|
132
|
-
document.getElementById(
|
|
252
|
+
document.getElementById("commit-meta").innerHTML = `
|
|
133
253
|
<div class="meta-item"><div class="meta-label">해시</div><div class="meta-value hash">${c.shortHash}</div></div>
|
|
134
254
|
<div class="meta-item"><div class="meta-label">메시지</div><div class="meta-value">${escHtml(c.message)}</div></div>
|
|
135
255
|
<div class="meta-item"><div class="meta-label">작성자</div><div class="meta-value">${escHtml(c.author)}</div></div>
|
|
136
256
|
<div class="meta-item"><div class="meta-label">날짜</div><div class="meta-value">${escHtml(c.date)}</div></div>
|
|
137
257
|
`;
|
|
138
|
-
const pre = document.getElementById(
|
|
139
|
-
pre.textContent = c.diffContent ||
|
|
140
|
-
pre.style.display =
|
|
141
|
-
document.getElementById(
|
|
258
|
+
const pre = document.getElementById("diff-content");
|
|
259
|
+
pre.textContent = c.diffContent || "(diff 없음)";
|
|
260
|
+
pre.style.display = "none";
|
|
261
|
+
document.getElementById("diff-toggle-btn").textContent = "diff 보기 ▾";
|
|
142
262
|
}
|
|
143
263
|
|
|
144
264
|
// ── Status Card ──
|
|
145
265
|
function renderStatusCard(s) {
|
|
146
266
|
const badges = [
|
|
147
|
-
s.stagedCount
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
s.
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
267
|
+
s.stagedCount
|
|
268
|
+
? `<span class="stat-chip staged">${s.stagedCount} staged</span>`
|
|
269
|
+
: "",
|
|
270
|
+
s.modifiedCount
|
|
271
|
+
? `<span class="stat-chip modified">${s.modifiedCount} modified</span>`
|
|
272
|
+
: "",
|
|
273
|
+
s.deletedCount
|
|
274
|
+
? `<span class="stat-chip deleted">${s.deletedCount} deleted</span>`
|
|
275
|
+
: "",
|
|
276
|
+
s.untrackedCount
|
|
277
|
+
? `<span class="stat-chip untracked">${s.untrackedCount} untracked</span>`
|
|
278
|
+
: "",
|
|
279
|
+
]
|
|
280
|
+
.filter(Boolean)
|
|
281
|
+
.join("");
|
|
282
|
+
|
|
283
|
+
document.getElementById("status-meta").innerHTML = `
|
|
154
284
|
<div class="meta-item" style="grid-column:1/-1">
|
|
155
285
|
<div class="meta-label">변경된 파일 (총 ${s.totalFiles}개)</div>
|
|
156
286
|
<div class="meta-value" style="display:flex;gap:6px;flex-wrap:wrap;margin-top:8px">${badges || '<span style="color:var(--text3)">변경사항 없음</span>'}</div>
|
|
157
287
|
</div>
|
|
158
288
|
`;
|
|
159
|
-
const pre = document.getElementById(
|
|
160
|
-
pre.textContent = s.diffContent ||
|
|
161
|
-
pre.style.display =
|
|
162
|
-
document.getElementById(
|
|
289
|
+
const pre = document.getElementById("status-diff-content");
|
|
290
|
+
pre.textContent = s.diffContent || "(diff 없음)";
|
|
291
|
+
pre.style.display = "none";
|
|
292
|
+
document.getElementById("status-diff-toggle-btn").textContent = "diff 보기 ▾";
|
|
163
293
|
}
|
|
164
294
|
|
|
165
295
|
// ── Mode Toggle ──
|
|
166
296
|
function setupModeToggle() {
|
|
167
|
-
document
|
|
168
|
-
|
|
297
|
+
document
|
|
298
|
+
.getElementById("mode-commit")
|
|
299
|
+
?.addEventListener("click", () => switchMode("commit"));
|
|
300
|
+
document
|
|
301
|
+
.getElementById("mode-status")
|
|
302
|
+
?.addEventListener("click", () => switchMode("status"));
|
|
169
303
|
}
|
|
170
304
|
|
|
171
305
|
function switchMode(mode) {
|
|
172
306
|
analyzeMode = mode;
|
|
173
|
-
document
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
document.getElementById(
|
|
177
|
-
document.getElementById(
|
|
307
|
+
document
|
|
308
|
+
.querySelectorAll(".mode-btn")
|
|
309
|
+
.forEach((b) => b.classList.remove("active"));
|
|
310
|
+
document.getElementById(`mode-${mode}`).classList.add("active");
|
|
311
|
+
document.getElementById("commit-card").style.display = "none";
|
|
312
|
+
document.getElementById("status-card").style.display = "none";
|
|
313
|
+
document.getElementById("result-card").style.display = "none";
|
|
314
|
+
|
|
315
|
+
const btnText = document.getElementById("analyze-btn-text");
|
|
316
|
+
if (btnText)
|
|
317
|
+
btnText.textContent =
|
|
318
|
+
mode === "commit" ? "Hanni에게 분석 요청" : "Hanni에게 리뷰 요청";
|
|
178
319
|
|
|
179
320
|
if (!selectedProject) return;
|
|
180
|
-
if (mode ===
|
|
321
|
+
if (mode === "commit") fetchCommitPreview();
|
|
181
322
|
else fetchStatusPreview();
|
|
182
323
|
}
|
|
183
324
|
|
|
184
325
|
// ── Analyze button ──
|
|
185
326
|
function onAnalyzeClick() {
|
|
186
|
-
if (analyzeMode ===
|
|
187
|
-
else startAnalysis(
|
|
327
|
+
if (analyzeMode === "commit") startAnalysis("/api/analyze");
|
|
328
|
+
else startAnalysis("/api/analyze-status");
|
|
188
329
|
}
|
|
189
330
|
|
|
190
331
|
// ── Generic SSE Analysis ──
|
|
@@ -192,158 +333,178 @@ async function startAnalysis(endpoint) {
|
|
|
192
333
|
if (isAnalyzing || !selectedProject) return;
|
|
193
334
|
isAnalyzing = true;
|
|
194
335
|
|
|
195
|
-
const resultCard = document.getElementById(
|
|
196
|
-
const analysisBody = document.getElementById(
|
|
197
|
-
const reportSaved = document.getElementById(
|
|
198
|
-
const analyzeBtn = document.getElementById(
|
|
199
|
-
const btnIcon = analyzeBtn.querySelector(
|
|
200
|
-
|
|
201
|
-
resultCard.style.display =
|
|
202
|
-
analysisBody.innerHTML =
|
|
203
|
-
reportSaved.textContent =
|
|
204
|
-
document.getElementById(
|
|
205
|
-
setStatus(
|
|
336
|
+
const resultCard = document.getElementById("result-card");
|
|
337
|
+
const analysisBody = document.getElementById("analysis-body");
|
|
338
|
+
const reportSaved = document.getElementById("report-saved");
|
|
339
|
+
const analyzeBtn = document.getElementById("analyze-btn");
|
|
340
|
+
const btnIcon = analyzeBtn.querySelector(".btn-icon");
|
|
341
|
+
|
|
342
|
+
resultCard.style.display = "block";
|
|
343
|
+
analysisBody.innerHTML = "";
|
|
344
|
+
reportSaved.textContent = "";
|
|
345
|
+
document.getElementById("copy-btn").style.display = "none"; // 분석 시작 시 숨김
|
|
346
|
+
setStatus("loading", "Hanni가 코드를 살펴보고 있어요...");
|
|
347
|
+
setAriaState("thinking");
|
|
206
348
|
analyzeBtn.disabled = true;
|
|
207
|
-
btnIcon.textContent =
|
|
349
|
+
btnIcon.textContent = "⏳";
|
|
208
350
|
|
|
209
|
-
let fullText =
|
|
351
|
+
let fullText = "";
|
|
210
352
|
|
|
211
353
|
try {
|
|
212
354
|
const res = await fetch(endpoint, {
|
|
213
|
-
method:
|
|
214
|
-
headers: {
|
|
355
|
+
method: "POST",
|
|
356
|
+
headers: { "Content-Type": "application/json" },
|
|
215
357
|
body: JSON.stringify({ projectName: selectedProject }),
|
|
216
358
|
});
|
|
217
359
|
|
|
218
360
|
// 400 에러 처리 (API 키 없음 등)
|
|
219
361
|
if (!res.ok) {
|
|
220
|
-
const err = await res
|
|
221
|
-
|
|
362
|
+
const err = await res
|
|
363
|
+
.json()
|
|
364
|
+
.catch(() => ({ error: `HTTP ${res.status}` }));
|
|
365
|
+
setStatus("error", `오류: ${err.error}`);
|
|
222
366
|
return;
|
|
223
367
|
}
|
|
224
368
|
|
|
225
369
|
const reader = res.body.getReader();
|
|
226
370
|
const decoder = new TextDecoder();
|
|
227
|
-
let buffer =
|
|
371
|
+
let buffer = "";
|
|
228
372
|
|
|
229
373
|
while (true) {
|
|
230
374
|
const { done, value } = await reader.read();
|
|
231
375
|
if (done) break;
|
|
232
376
|
buffer += decoder.decode(value, { stream: true });
|
|
233
|
-
const lines = buffer.split(
|
|
377
|
+
const lines = buffer.split("\n");
|
|
234
378
|
buffer = lines.pop();
|
|
235
379
|
for (const line of lines) {
|
|
236
|
-
if (!line.startsWith(
|
|
380
|
+
if (!line.startsWith("data: ")) continue;
|
|
237
381
|
try {
|
|
238
382
|
const data = JSON.parse(line.slice(6));
|
|
239
|
-
if (data.type ===
|
|
240
|
-
setStatus(
|
|
241
|
-
} else if (data.type ===
|
|
383
|
+
if (data.type === "status") {
|
|
384
|
+
setStatus("loading", data.message);
|
|
385
|
+
} else if (data.type === "commit") {
|
|
242
386
|
renderCommitCard(data.commit);
|
|
243
|
-
document.getElementById(
|
|
244
|
-
} else if (data.type ===
|
|
387
|
+
document.getElementById("commit-card").style.display = "block";
|
|
388
|
+
} else if (data.type === "working-status") {
|
|
245
389
|
renderStatusCard(data.workingStatus);
|
|
246
|
-
document.getElementById(
|
|
247
|
-
} else if (data.type ===
|
|
390
|
+
document.getElementById("status-card").style.display = "block";
|
|
391
|
+
} else if (data.type === "analysis") {
|
|
248
392
|
fullText = data.analysis;
|
|
249
393
|
analysisBody.innerHTML = marked.parse(data.analysis);
|
|
250
394
|
if (data.reportFilename) {
|
|
251
395
|
reportSaved.textContent = `✓ 저장됨: ${data.reportFilename}`;
|
|
252
396
|
}
|
|
253
|
-
} else if (data.type ===
|
|
254
|
-
setStatus(
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
397
|
+
} else if (data.type === "done") {
|
|
398
|
+
setStatus("done", "✅ 분석 완료!");
|
|
399
|
+
setAriaState("done");
|
|
400
|
+
document.getElementById("copy-btn").style.display = "inline-flex"; // 완료 시에만 표시
|
|
401
|
+
} else if (data.type === "error") {
|
|
402
|
+
setStatus("error", `오류: ${data.message}`);
|
|
403
|
+
setAriaState("error");
|
|
258
404
|
}
|
|
259
405
|
} catch {}
|
|
260
406
|
}
|
|
261
407
|
}
|
|
262
408
|
} catch (err) {
|
|
263
|
-
setStatus(
|
|
409
|
+
setStatus("error", `네트워크 오류: ${err.message}`);
|
|
410
|
+
setAriaState("error");
|
|
264
411
|
} finally {
|
|
265
412
|
isAnalyzing = false;
|
|
266
413
|
analyzeBtn.disabled = false;
|
|
267
|
-
btnIcon.textContent =
|
|
268
|
-
document.getElementById(
|
|
269
|
-
resultCard.scrollIntoView({ behavior:
|
|
414
|
+
btnIcon.textContent = "🤖";
|
|
415
|
+
document.getElementById("copy-btn")._text = fullText;
|
|
416
|
+
resultCard.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
270
417
|
}
|
|
271
418
|
}
|
|
272
419
|
|
|
273
420
|
// ── Status bar ──
|
|
274
421
|
function setStatus(type, msg) {
|
|
275
|
-
const bar = document.getElementById(
|
|
276
|
-
const dot = bar.querySelector(
|
|
277
|
-
const msgEl = document.getElementById(
|
|
422
|
+
const bar = document.getElementById("status-bar");
|
|
423
|
+
const dot = bar.querySelector(".status-dot");
|
|
424
|
+
const msgEl = document.getElementById("status-msg");
|
|
278
425
|
msgEl.textContent = msg;
|
|
279
|
-
dot.className =
|
|
280
|
-
bar.className =
|
|
426
|
+
dot.className = "status-dot " + type;
|
|
427
|
+
bar.className = "status-bar " + (type === "loading" ? "" : type);
|
|
281
428
|
}
|
|
282
429
|
|
|
283
430
|
// ── Copy ──
|
|
284
431
|
function onCopy() {
|
|
285
|
-
const btn = document.getElementById(
|
|
286
|
-
const text = btn._text || document.getElementById(
|
|
432
|
+
const btn = document.getElementById("copy-btn");
|
|
433
|
+
const text = btn._text || document.getElementById("analysis-body").innerText;
|
|
287
434
|
navigator.clipboard.writeText(text).then(() => {
|
|
288
|
-
btn.textContent =
|
|
289
|
-
setTimeout(() => {
|
|
435
|
+
btn.textContent = "✓ 복사됨";
|
|
436
|
+
setTimeout(() => {
|
|
437
|
+
btn.textContent = "📋 복사";
|
|
438
|
+
}, 2000);
|
|
290
439
|
});
|
|
291
440
|
}
|
|
292
441
|
|
|
293
442
|
// ── Reports Tab ──
|
|
294
443
|
async function loadReports() {
|
|
295
|
-
const listEl = document.getElementById(
|
|
444
|
+
const listEl = document.getElementById("reports-list");
|
|
296
445
|
listEl.innerHTML = '<p class="empty-state">불러오는 중...</p>';
|
|
297
446
|
try {
|
|
298
|
-
const res = await fetch(
|
|
447
|
+
const res = await fetch("/api/reports");
|
|
299
448
|
const { reports } = await res.json();
|
|
300
449
|
if (!reports.length) {
|
|
301
|
-
listEl.innerHTML =
|
|
450
|
+
listEl.innerHTML =
|
|
451
|
+
'<p class="empty-state">아직 저장된 리포트가 없습니다.</p>';
|
|
302
452
|
return;
|
|
303
453
|
}
|
|
304
|
-
listEl.innerHTML = reports
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
454
|
+
listEl.innerHTML = reports
|
|
455
|
+
.map((r) => {
|
|
456
|
+
const parts = r.replace(".md", "").split("-");
|
|
457
|
+
const date = parts.slice(-2).join(" ");
|
|
458
|
+
const proj = parts.slice(0, -2).join("-");
|
|
459
|
+
return `<div class="report-item" data-file="${r}">
|
|
309
460
|
<span class="report-item-name">📄 ${proj}</span>
|
|
310
461
|
<span class="report-item-date">${date}</span>
|
|
311
462
|
</div>`;
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
463
|
+
})
|
|
464
|
+
.join("");
|
|
465
|
+
listEl.querySelectorAll(".report-item").forEach((el) => {
|
|
466
|
+
el.addEventListener("click", () => openReport(el.dataset.file));
|
|
315
467
|
});
|
|
316
468
|
} catch {
|
|
317
|
-
listEl.innerHTML =
|
|
469
|
+
listEl.innerHTML =
|
|
470
|
+
'<p class="empty-state">리포트를 불러오지 못했습니다.</p>';
|
|
318
471
|
}
|
|
319
472
|
}
|
|
320
473
|
|
|
321
474
|
async function openReport(filename) {
|
|
322
|
-
const viewer = document.getElementById(
|
|
323
|
-
const body = document.getElementById(
|
|
324
|
-
document.getElementById(
|
|
475
|
+
const viewer = document.getElementById("report-viewer");
|
|
476
|
+
const body = document.getElementById("report-viewer-body");
|
|
477
|
+
document.getElementById("report-viewer-title").textContent = filename.replace(
|
|
478
|
+
".md",
|
|
479
|
+
"",
|
|
480
|
+
);
|
|
325
481
|
body.innerHTML = '<p style="color:var(--text2)">불러오는 중...</p>';
|
|
326
|
-
viewer.style.display =
|
|
482
|
+
viewer.style.display = "block";
|
|
327
483
|
try {
|
|
328
484
|
const res = await fetch(`/api/reports/${encodeURIComponent(filename)}`);
|
|
329
485
|
const { content } = await res.json();
|
|
330
486
|
body.innerHTML = marked.parse(content);
|
|
331
487
|
} catch {
|
|
332
|
-
body.innerHTML =
|
|
488
|
+
body.innerHTML =
|
|
489
|
+
'<p style="color:var(--danger)">리포트를 불러오지 못했습니다.</p>';
|
|
333
490
|
}
|
|
334
|
-
viewer.scrollIntoView({ behavior:
|
|
491
|
+
viewer.scrollIntoView({ behavior: "smooth" });
|
|
335
492
|
}
|
|
336
493
|
|
|
337
494
|
// ── Tabs ──
|
|
338
495
|
function setupTabs() {
|
|
339
|
-
document.querySelectorAll(
|
|
340
|
-
btn.addEventListener(
|
|
496
|
+
document.querySelectorAll(".nav-btn").forEach((btn) => {
|
|
497
|
+
btn.addEventListener("click", () => {
|
|
341
498
|
const tab = btn.dataset.tab;
|
|
342
|
-
document
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
document
|
|
346
|
-
|
|
499
|
+
document
|
|
500
|
+
.querySelectorAll(".nav-btn")
|
|
501
|
+
.forEach((b) => b.classList.remove("active"));
|
|
502
|
+
document
|
|
503
|
+
.querySelectorAll(".tab-content")
|
|
504
|
+
.forEach((t) => t.classList.remove("active"));
|
|
505
|
+
btn.classList.add("active");
|
|
506
|
+
document.getElementById("tab-" + tab).classList.add("active");
|
|
507
|
+
if (tab === "reports") loadReports();
|
|
347
508
|
});
|
|
348
509
|
});
|
|
349
510
|
}
|
|
@@ -352,14 +513,14 @@ function setupTabs() {
|
|
|
352
513
|
function togglePre(preId, btnId) {
|
|
353
514
|
const pre = document.getElementById(preId);
|
|
354
515
|
const btn = document.getElementById(btnId);
|
|
355
|
-
const shown = pre.style.display !==
|
|
356
|
-
pre.style.display = shown ?
|
|
357
|
-
btn.textContent = shown ?
|
|
516
|
+
const shown = pre.style.display !== "none";
|
|
517
|
+
pre.style.display = shown ? "none" : "block";
|
|
518
|
+
btn.textContent = shown ? "diff 보기 ▾" : "diff 닫기 ▴";
|
|
358
519
|
}
|
|
359
520
|
|
|
360
521
|
function escHtml(str) {
|
|
361
522
|
return String(str)
|
|
362
|
-
.replace(/&/g,
|
|
363
|
-
.replace(/</g,
|
|
364
|
-
.replace(/>/g,
|
|
523
|
+
.replace(/&/g, "&")
|
|
524
|
+
.replace(/</g, "<")
|
|
525
|
+
.replace(/>/g, ">");
|
|
365
526
|
}
|