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