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