@hapico/cli 0.0.28 → 0.0.30
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/index.js +1 -1
- package/bin/tools/vibe/index.js +282 -182
- package/bun.lock +6 -0
- package/dist/index.js +1 -1
- package/dist/tools/vibe/index.js +282 -182
- package/index.ts +1 -1
- package/package.json +3 -1
- package/tools/vibe/index.ts +315 -198
package/bin/index.js
CHANGED
|
@@ -411,7 +411,7 @@ class RoomState {
|
|
|
411
411
|
return this.isConnected;
|
|
412
412
|
}
|
|
413
413
|
}
|
|
414
|
-
commander_1.program.version("0.0.
|
|
414
|
+
commander_1.program.version("0.0.30").description("Hapico CLI for project management");
|
|
415
415
|
commander_1.program
|
|
416
416
|
.command("clone <id>")
|
|
417
417
|
.description("Clone a project by ID")
|
package/bin/tools/vibe/index.js
CHANGED
|
@@ -3,16 +3,49 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
-
exports.vibe = void 0;
|
|
6
|
+
exports.vibe = exports.applyPatch = void 0;
|
|
7
7
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
8
8
|
const promises_1 = __importDefault(require("node:fs/promises"));
|
|
9
9
|
const node_path_1 = __importDefault(require("node:path"));
|
|
10
|
-
const
|
|
11
|
-
const node_util_1 = __importDefault(require("node:util"));
|
|
10
|
+
const diff_match_patch_1 = require("diff-match-patch");
|
|
12
11
|
const express_1 = __importDefault(require("express"));
|
|
13
12
|
const open_1 = __importDefault(require("open"));
|
|
14
|
-
const execAsync = node_util_1.default.promisify(node_child_process_1.exec);
|
|
15
13
|
const node_net_1 = __importDefault(require("node:net"));
|
|
14
|
+
const scanRecursively = async (dir, baseDir) => {
|
|
15
|
+
let results = [];
|
|
16
|
+
try {
|
|
17
|
+
const list = await promises_1.default.readdir(dir, { withFileTypes: true });
|
|
18
|
+
for (const entry of list) {
|
|
19
|
+
const fullPath = node_path_1.default.join(dir, entry.name);
|
|
20
|
+
// Chuẩn hóa đường dẫn về forward slash để nhất quán giữa các hệ điều hành
|
|
21
|
+
const relativePath = node_path_1.default.relative(baseDir, fullPath).split(node_path_1.default.sep).join('/');
|
|
22
|
+
if (shouldIgnorePath(relativePath))
|
|
23
|
+
continue;
|
|
24
|
+
if (entry.isDirectory()) {
|
|
25
|
+
const subResults = await scanRecursively(fullPath, baseDir);
|
|
26
|
+
results = results.concat(subResults);
|
|
27
|
+
}
|
|
28
|
+
else if (entry.isFile()) {
|
|
29
|
+
try {
|
|
30
|
+
const content = await promises_1.default.readFile(fullPath, "utf-8");
|
|
31
|
+
results.push({
|
|
32
|
+
path: relativePath,
|
|
33
|
+
fullPath: fullPath,
|
|
34
|
+
content: content,
|
|
35
|
+
extension: entry.name.split(".").pop() || "txt",
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
catch (err) {
|
|
39
|
+
// Bỏ qua file nhị phân hoặc không đọc được
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
// Bỏ qua nếu lỗi permission hoặc path không tồn tại
|
|
46
|
+
}
|
|
47
|
+
return results;
|
|
48
|
+
};
|
|
16
49
|
// random port from 3000-3999
|
|
17
50
|
// Check if port is available would be better in real-world usage
|
|
18
51
|
const isPortInUse = (port) => {
|
|
@@ -81,98 +114,226 @@ const shouldIgnorePath = (filePath) => {
|
|
|
81
114
|
}
|
|
82
115
|
return false;
|
|
83
116
|
};
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
}
|
|
97
|
-
else if (entry.isFile()) {
|
|
98
|
-
try {
|
|
99
|
-
const content = await promises_1.default.readFile(fullPath, "utf-8");
|
|
100
|
-
results.push({
|
|
101
|
-
path: relativePath,
|
|
102
|
-
fullPath: fullPath,
|
|
103
|
-
content: content,
|
|
104
|
-
extension: entry.name.split(".").pop() || "txt",
|
|
105
|
-
});
|
|
106
|
-
}
|
|
107
|
-
catch (err) {
|
|
108
|
-
// Bỏ qua file nhị phân hoặc không đọc được
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
catch (error) {
|
|
114
|
-
// Bỏ qua nếu lỗi permission hoặc path không tồn tại
|
|
117
|
+
/**
|
|
118
|
+
* ------------------------------------------------------------------
|
|
119
|
+
* HELPER: STRING SIMILARITY (Levenshtein based)
|
|
120
|
+
* Tính độ giống nhau giữa 2 chuỗi (0 -> 1)
|
|
121
|
+
* ------------------------------------------------------------------
|
|
122
|
+
*/
|
|
123
|
+
const calculateSimilarity = (s1, s2) => {
|
|
124
|
+
const longer = s1.length > s2.length ? s1 : s2;
|
|
125
|
+
const shorter = s1.length > s2.length ? s2 : s1;
|
|
126
|
+
const longerLength = longer.length;
|
|
127
|
+
if (longerLength === 0) {
|
|
128
|
+
return 1.0;
|
|
115
129
|
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
const
|
|
119
|
-
|
|
130
|
+
// Sử dụng dmp để tính Levenshtein distance cho chính xác và nhanh
|
|
131
|
+
const dmp = new diff_match_patch_1.diff_match_patch();
|
|
132
|
+
const diffs = dmp.diff_main(longer, shorter);
|
|
133
|
+
const levenshtein = dmp.diff_levenshtein(diffs);
|
|
134
|
+
return (longerLength - levenshtein) / longerLength;
|
|
120
135
|
};
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
136
|
+
/**
|
|
137
|
+
* ------------------------------------------------------------------
|
|
138
|
+
* HELPER: FUZZY LINE MATCHING
|
|
139
|
+
* Tìm đoạn code khớp nhất trong file gốc dựa trên điểm số (Confidence)
|
|
140
|
+
* ------------------------------------------------------------------
|
|
141
|
+
*/
|
|
142
|
+
const applyFuzzyLineMatch = (original, search, replace, threshold = 0.85 // Ngưỡng chấp nhận (85% giống nhau)
|
|
143
|
+
) => {
|
|
144
|
+
const originalLines = original.split(/\r?\n/);
|
|
145
|
+
const searchLines = search.split(/\r?\n/);
|
|
146
|
+
// Lọc bỏ dòng trống ở đầu/cuối search block để match chính xác hơn
|
|
147
|
+
while (searchLines.length > 0 && searchLines[0].trim() === "")
|
|
125
148
|
searchLines.shift();
|
|
126
|
-
|
|
127
|
-
while (searchLines.length > 0 &&
|
|
128
|
-
searchLines[searchLines.length - 1].trim() === "") {
|
|
149
|
+
while (searchLines.length > 0 && searchLines[searchLines.length - 1].trim() === "")
|
|
129
150
|
searchLines.pop();
|
|
130
|
-
}
|
|
131
151
|
if (searchLines.length === 0)
|
|
132
|
-
return null;
|
|
152
|
+
return { result: null, confidence: 0 };
|
|
153
|
+
let bestMatchIndex = -1;
|
|
154
|
+
let bestMatchScore = 0;
|
|
155
|
+
// Duyệt qua file gốc (Sliding Window)
|
|
156
|
+
// Chỉ cần duyệt đến vị trí mà số dòng còn lại đủ để chứa searchLines
|
|
133
157
|
for (let i = 0; i <= originalLines.length - searchLines.length; i++) {
|
|
134
|
-
let
|
|
158
|
+
let currentScoreTotal = 0;
|
|
159
|
+
let possible = true;
|
|
135
160
|
for (let j = 0; j < searchLines.length; j++) {
|
|
136
|
-
|
|
137
|
-
|
|
161
|
+
const originalLine = originalLines[i + j].trim();
|
|
162
|
+
const searchLine = searchLines[j].trim();
|
|
163
|
+
// So sánh nhanh: Nếu giống hệt nhau
|
|
164
|
+
if (originalLine === searchLine) {
|
|
165
|
+
currentScoreTotal += 1;
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
// So sánh chậm: Tính độ tương đồng
|
|
169
|
+
const similarity = calculateSimilarity(originalLine, searchLine);
|
|
170
|
+
// Nếu một dòng quá khác biệt (< 60%), coi như block này không khớp
|
|
171
|
+
// (Giúp tối ưu hiệu năng, cắt nhánh sớm)
|
|
172
|
+
if (similarity < 0.6) {
|
|
173
|
+
possible = false;
|
|
138
174
|
break;
|
|
139
175
|
}
|
|
176
|
+
currentScoreTotal += similarity;
|
|
140
177
|
}
|
|
141
|
-
if (
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
178
|
+
if (possible) {
|
|
179
|
+
const avgScore = currentScoreTotal / searchLines.length;
|
|
180
|
+
if (avgScore > bestMatchScore) {
|
|
181
|
+
bestMatchScore = avgScore;
|
|
182
|
+
bestMatchIndex = i;
|
|
183
|
+
}
|
|
145
184
|
}
|
|
146
185
|
}
|
|
147
|
-
|
|
186
|
+
// LOG CONFIDENCE ĐỂ DEBUG
|
|
187
|
+
if (bestMatchScore > 0) {
|
|
188
|
+
console.log(`[FuzzyMatch] Best match score: ${(bestMatchScore * 100).toFixed(2)}% at line ${bestMatchIndex + 1}`);
|
|
189
|
+
}
|
|
190
|
+
// Nếu tìm thấy vị trí có độ tin cậy cao hơn ngưỡng
|
|
191
|
+
if (bestMatchIndex !== -1 && bestMatchScore >= threshold) {
|
|
192
|
+
const before = originalLines.slice(0, bestMatchIndex);
|
|
193
|
+
// Lưu ý: Đoạn replace thay thế đúng số lượng dòng của searchLines
|
|
194
|
+
// (Giả định search và match trong original có cùng số dòng hiển thị)
|
|
195
|
+
const after = originalLines.slice(bestMatchIndex + searchLines.length);
|
|
196
|
+
return {
|
|
197
|
+
result: [...before, replace, ...after].join("\n"),
|
|
198
|
+
confidence: bestMatchScore
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
return { result: null, confidence: bestMatchScore };
|
|
148
202
|
};
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
203
|
+
/**
|
|
204
|
+
* ------------------------------------------------------------------
|
|
205
|
+
* 1. STATE MACHINE PARSER (FILE-AWARE)
|
|
206
|
+
* ------------------------------------------------------------------
|
|
207
|
+
*/
|
|
208
|
+
const parseLLMPatch = (patchContent) => {
|
|
209
|
+
const lines = patchContent.split(/\r?\n/);
|
|
210
|
+
const filePatches = {};
|
|
211
|
+
let currentFile = "unknown";
|
|
212
|
+
let inSearch = false;
|
|
213
|
+
let inReplace = false;
|
|
214
|
+
let searchLines = [];
|
|
215
|
+
let replaceLines = [];
|
|
216
|
+
const isFileMarker = (line) => {
|
|
217
|
+
const match = line.match(/^### File:\s*[`']?([^`'\s]+)[`']?/i);
|
|
218
|
+
return match ? match[1] : null;
|
|
219
|
+
};
|
|
220
|
+
const isSearchMarker = (line) => /^\s*<{7,}\s*SEARCH\s*$/.test(line);
|
|
221
|
+
const isDividerMarker = (line) => /^\s*={7,}\s*$/.test(line);
|
|
222
|
+
const isEndMarker = (line) => /^\s*>{7,}\s*REPLACE\s*$/.test(line);
|
|
223
|
+
for (const line of lines) {
|
|
224
|
+
const fileName = isFileMarker(line);
|
|
225
|
+
if (fileName) {
|
|
226
|
+
currentFile = fileName;
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
if (isSearchMarker(line)) {
|
|
230
|
+
inSearch = true;
|
|
231
|
+
inReplace = false;
|
|
232
|
+
searchLines = [];
|
|
233
|
+
replaceLines = [];
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
if (isDividerMarker(line)) {
|
|
237
|
+
if (inSearch) {
|
|
238
|
+
inSearch = false;
|
|
239
|
+
inReplace = true;
|
|
240
|
+
}
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
if (isEndMarker(line)) {
|
|
244
|
+
if (inReplace || inSearch) {
|
|
245
|
+
if (searchLines.length > 0 || replaceLines.length > 0) {
|
|
246
|
+
if (!filePatches[currentFile])
|
|
247
|
+
filePatches[currentFile] = [];
|
|
248
|
+
filePatches[currentFile].push({
|
|
249
|
+
search: searchLines.join("\n"),
|
|
250
|
+
replace: replaceLines.join("\n")
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
inSearch = false;
|
|
255
|
+
inReplace = false;
|
|
256
|
+
searchLines = [];
|
|
257
|
+
replaceLines = [];
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
if (inSearch)
|
|
261
|
+
searchLines.push(line);
|
|
262
|
+
else if (inReplace)
|
|
263
|
+
replaceLines.push(line);
|
|
155
264
|
}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
265
|
+
return filePatches;
|
|
266
|
+
};
|
|
267
|
+
/**
|
|
268
|
+
* ------------------------------------------------------------------
|
|
269
|
+
* 2. APPLY PATCH (UPDATED)
|
|
270
|
+
* ------------------------------------------------------------------
|
|
271
|
+
*/
|
|
272
|
+
const applyPatch = (originalContent, patches) => {
|
|
273
|
+
if (!patches || patches.length === 0)
|
|
274
|
+
return originalContent;
|
|
275
|
+
let result = originalContent;
|
|
276
|
+
for (const patch of patches) {
|
|
277
|
+
const { search, replace } = patch;
|
|
278
|
+
// NẾU SEARCH RỖNG (INSERT/APPEND)
|
|
279
|
+
if (search.trim() === "") {
|
|
280
|
+
console.log(`[ApplyPatch] Inserting/appending block.`);
|
|
281
|
+
result += `\n${replace}`;
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
// --- STRATEGY 1: EXACT MATCH ---
|
|
285
|
+
if (result.includes(search)) {
|
|
286
|
+
console.log(`[ApplyPatch] Exact match found.`);
|
|
287
|
+
result = result.replace(search, replace);
|
|
160
288
|
continue;
|
|
161
289
|
}
|
|
162
|
-
|
|
163
|
-
const
|
|
290
|
+
// --- STRATEGY 2: NORMALIZED MATCH (Trim) ---
|
|
291
|
+
const trimmedSearch = search.trim();
|
|
164
292
|
if (result.includes(trimmedSearch)) {
|
|
165
|
-
|
|
293
|
+
console.log(`[ApplyPatch] Trimmed match found.`);
|
|
294
|
+
result = result.replace(trimmedSearch, replace.trim());
|
|
166
295
|
continue;
|
|
167
296
|
}
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
297
|
+
// --- STRATEGY 3: FUZZY LINE MATCH (BEST FOR CODE) ---
|
|
298
|
+
// Tìm vị trí tốt nhất dựa trên similarity score của từng dòng
|
|
299
|
+
const fuzzyMatch = applyFuzzyLineMatch(result, search, replace, 0.80); // 80% confidence
|
|
300
|
+
if (fuzzyMatch.result !== null) {
|
|
301
|
+
console.log(`[ApplyPatch] Fuzzy line match applied (Confidence: ${(fuzzyMatch.confidence * 100).toFixed(0)}%)`);
|
|
302
|
+
result = fuzzyMatch.result;
|
|
171
303
|
continue;
|
|
172
304
|
}
|
|
305
|
+
else {
|
|
306
|
+
console.warn(`[ApplyPatch] Failed to match block. Max confidence was: ${(fuzzyMatch.confidence * 100).toFixed(0)}%`);
|
|
307
|
+
}
|
|
308
|
+
// --- STRATEGY 4: DMP FALLBACK (LAST RESORT) ---
|
|
309
|
+
// Nếu cả fuzzy line match cũng tạch (ví dụ do search block quá ngắn hoặc cấu trúc quá nát)
|
|
310
|
+
// thì mới dùng DMP patch_apply.
|
|
311
|
+
try {
|
|
312
|
+
const dmp = new diff_match_patch_1.diff_match_patch();
|
|
313
|
+
dmp.Match_Threshold = 0.5;
|
|
314
|
+
dmp.Match_Distance = 1000;
|
|
315
|
+
const dmpPatches = dmp.patch_make(search, replace);
|
|
316
|
+
const [newText, applyResults] = dmp.patch_apply(dmpPatches, result);
|
|
317
|
+
// Kiểm tra xem có apply được hết các hunk không
|
|
318
|
+
const successCount = applyResults.filter(Boolean).length;
|
|
319
|
+
const successRate = successCount / applyResults.length;
|
|
320
|
+
if (successRate === 1 && newText !== result) {
|
|
321
|
+
console.log(`[ApplyPatch] DMP Patch applied via fuzzy logic.`);
|
|
322
|
+
result = newText;
|
|
323
|
+
}
|
|
324
|
+
else {
|
|
325
|
+
console.error(`[ApplyPatch] All strategies failed for block starting with: ${search.substring(0, 30)}...`);
|
|
326
|
+
// Tuỳ chọn: Throw error để báo cho LLM biết là apply thất bại
|
|
327
|
+
// throw new Error("Could not apply patch block");
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
catch (e) {
|
|
331
|
+
console.error(`[ApplyPatch] DMP error:`, e);
|
|
332
|
+
}
|
|
173
333
|
}
|
|
174
334
|
return result;
|
|
175
335
|
};
|
|
336
|
+
exports.applyPatch = applyPatch;
|
|
176
337
|
// ==========================================
|
|
177
338
|
// 3. WEB UI SERVER (UPDATED UI)
|
|
178
339
|
// ==========================================
|
|
@@ -199,15 +360,42 @@ const startServer = async (rootDir) => {
|
|
|
199
360
|
});
|
|
200
361
|
// API 3: Apply Patch
|
|
201
362
|
app.post("/api/apply", async (req, res) => {
|
|
202
|
-
|
|
363
|
+
var _a;
|
|
364
|
+
const { llmResponse, filePaths: selectedPaths } = req.body;
|
|
365
|
+
// 1. Parse all patches from LLM response
|
|
366
|
+
const filePatches = parseLLMPatch(llmResponse);
|
|
367
|
+
// 2. Scan project files
|
|
203
368
|
const allFiles = await scanRecursively(rootDir, rootDir);
|
|
204
|
-
const selectedFiles = allFiles.filter((f) => filePaths.includes(f.path));
|
|
205
369
|
const modifiedFiles = [];
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
370
|
+
// 3. Match patches to files and apply
|
|
371
|
+
for (const file of allFiles) {
|
|
372
|
+
// Find patches meant for this specific file path
|
|
373
|
+
// Note: LLM might use different path separators, so we normalize
|
|
374
|
+
const normalizedPath = file.path.replace(/\\/g, '/');
|
|
375
|
+
const patchesForFile = (_a = Object.entries(filePatches).find(([path]) => {
|
|
376
|
+
const normalizedKey = path.replace(/\\/g, '/');
|
|
377
|
+
return normalizedKey === normalizedPath || normalizedPath.endsWith('/' + normalizedKey);
|
|
378
|
+
})) === null || _a === void 0 ? void 0 : _a[1];
|
|
379
|
+
// Only apply if the file was selected in UI AND has patches in the response
|
|
380
|
+
if (selectedPaths.includes(file.path) && patchesForFile) {
|
|
381
|
+
console.log(`[Apply] Processing ${patchesForFile.length} blocks for: ${file.path}`);
|
|
382
|
+
const newContent = (0, exports.applyPatch)(file.content, patchesForFile);
|
|
383
|
+
if (newContent !== file.content) {
|
|
384
|
+
await promises_1.default.writeFile(file.fullPath, newContent, "utf-8");
|
|
385
|
+
modifiedFiles.push(file.path);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
// Fallback: If no matched files but only one file was selected, try applying "unknown" patches
|
|
390
|
+
if (modifiedFiles.length === 0 && selectedPaths.length === 1 && filePatches["unknown"]) {
|
|
391
|
+
const file = allFiles.find(f => f.path === selectedPaths[0]);
|
|
392
|
+
if (file) {
|
|
393
|
+
console.log(`[Apply] Fallback: Applying unknown blocks to single selected file: ${file.path}`);
|
|
394
|
+
const newContent = (0, exports.applyPatch)(file.content, filePatches["unknown"]);
|
|
395
|
+
if (newContent !== file.content) {
|
|
396
|
+
await promises_1.default.writeFile(file.fullPath, newContent, "utf-8");
|
|
397
|
+
modifiedFiles.push(file.path);
|
|
398
|
+
}
|
|
211
399
|
}
|
|
212
400
|
}
|
|
213
401
|
res.json({ modified: modifiedFiles });
|
|
@@ -335,7 +523,8 @@ const startServer = async (rootDir) => {
|
|
|
335
523
|
|
|
336
524
|
<div class="flex-1 flex flex-col bg-gray-850 min-w-0">
|
|
337
525
|
|
|
338
|
-
|
|
526
|
+
<!-- 1. Instruction (Takes more space and resizable) -->
|
|
527
|
+
<div class="flex-1 p-6 flex flex-col min-h-0 border-b border-apple-border">
|
|
339
528
|
<div class="flex justify-between items-end mb-3">
|
|
340
529
|
<div>
|
|
341
530
|
<h2 class="text-sm font-semibold text-gray-200">1. Instruction</h2>
|
|
@@ -351,12 +540,12 @@ const startServer = async (rootDir) => {
|
|
|
351
540
|
</button>
|
|
352
541
|
</div>
|
|
353
542
|
<textarea x-model="instruction"
|
|
354
|
-
class="w-full bg-apple-panel border border-apple-border rounded p-3 text-sm text-gray-200 placeholder-gray-600 focus:outline-none focus:border-apple-blue/50 focus:ring-1 focus:ring-apple-blue/50 transition resize-
|
|
355
|
-
rows="3"
|
|
543
|
+
class="w-full flex-1 bg-apple-panel border border-apple-border rounded p-3 text-sm text-gray-200 placeholder-gray-600 focus:outline-none focus:border-apple-blue/50 focus:ring-1 focus:ring-apple-blue/50 transition resize-y font-mono min-h-[120px]"
|
|
356
544
|
placeholder="e.g. Refactor the Button component to use TypeScript interfaces..."></textarea>
|
|
357
545
|
</div>
|
|
358
546
|
|
|
359
|
-
|
|
547
|
+
<!-- 2. LLM Patch (Fixed smaller size) -->
|
|
548
|
+
<div class="h-64 p-6 flex flex-col flex-shrink-0">
|
|
360
549
|
<div class="flex justify-between items-end mb-3">
|
|
361
550
|
<div>
|
|
362
551
|
<h2 class="text-sm font-semibold text-gray-200">2. LLM Patch</h2>
|
|
@@ -369,11 +558,9 @@ const startServer = async (rootDir) => {
|
|
|
369
558
|
<span x-show="applying">Applying...</span>
|
|
370
559
|
</button>
|
|
371
560
|
</div>
|
|
372
|
-
<
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
placeholder="<<<<<<< SEARCH..."></textarea>
|
|
376
|
-
</div>
|
|
561
|
+
<textarea x-model="llmResponse"
|
|
562
|
+
class="w-full h-40 bg-apple-panel border border-apple-border rounded p-3 text-sm text-gray-300 font-mono placeholder-gray-600 focus:outline-none focus:border-emerald-500/50 focus:ring-1 focus:ring-emerald-500/50 transition resize-none leading-relaxed"
|
|
563
|
+
placeholder="<<<<<<< SEARCH..."></textarea>
|
|
377
564
|
</div>
|
|
378
565
|
|
|
379
566
|
</div>
|
|
@@ -418,7 +605,8 @@ const startServer = async (rootDir) => {
|
|
|
418
605
|
|
|
419
606
|
async init() {
|
|
420
607
|
try {
|
|
421
|
-
|
|
608
|
+
// Sử dụng relative URL để tránh lỗi phân giải localhost/127.0.0.1 trên Windows
|
|
609
|
+
const res = await fetch('/api/files');
|
|
422
610
|
const data = await res.json();
|
|
423
611
|
this.rawFiles = data;
|
|
424
612
|
this.buildTree();
|
|
@@ -428,98 +616,10 @@ const startServer = async (rootDir) => {
|
|
|
428
616
|
this.loading = false;
|
|
429
617
|
}
|
|
430
618
|
|
|
431
|
-
// Search watcher
|
|
432
619
|
this.$watch('search', () => this.buildTree());
|
|
433
|
-
|
|
434
|
-
// Auto-generate prompt
|
|
435
620
|
this.$watch('instruction', () => this.debouncedFetchPrompt());
|
|
436
621
|
this.$watch('selectedFiles', () => this.debouncedFetchPrompt());
|
|
437
622
|
},
|
|
438
|
-
|
|
439
|
-
// ===========================
|
|
440
|
-
// Tree Logic
|
|
441
|
-
// ===========================
|
|
442
|
-
|
|
443
|
-
buildTree() {
|
|
444
|
-
// 1. Convert paths to object structure
|
|
445
|
-
const root = {};
|
|
446
|
-
const searchLower = this.search.toLowerCase();
|
|
447
|
-
|
|
448
|
-
this.rawFiles.forEach(file => {
|
|
449
|
-
if (this.search && !file.path.toLowerCase().includes(searchLower)) return;
|
|
450
|
-
|
|
451
|
-
const parts = file.path.split(/[\\\\/]/);
|
|
452
|
-
let current = root;
|
|
453
|
-
|
|
454
|
-
parts.forEach((part, index) => {
|
|
455
|
-
if (!current[part]) {
|
|
456
|
-
current[part] = {
|
|
457
|
-
name: part,
|
|
458
|
-
path: parts.slice(0, index + 1).join('/'),
|
|
459
|
-
children: {},
|
|
460
|
-
type: index === parts.length - 1 ? 'file' : 'folder',
|
|
461
|
-
fullPath: file.path // Only meaningful for file
|
|
462
|
-
};
|
|
463
|
-
}
|
|
464
|
-
current = current[part].children;
|
|
465
|
-
});
|
|
466
|
-
});
|
|
467
|
-
|
|
468
|
-
// 2. Flatten object structure into renderable list with depth
|
|
469
|
-
this.treeNodes = [];
|
|
470
|
-
const traverse = (nodeMap, depth) => {
|
|
471
|
-
// Sort: Folders first, then Files. Alphabetical.
|
|
472
|
-
const keys = Object.keys(nodeMap).sort((a, b) => {
|
|
473
|
-
const nodeA = nodeMap[a];
|
|
474
|
-
const nodeB = nodeMap[b];
|
|
475
|
-
if (nodeA.type !== nodeB.type) return nodeA.type === 'folder' ? -1 : 1;
|
|
476
|
-
return a.localeCompare(b);
|
|
477
|
-
});
|
|
478
|
-
|
|
479
|
-
keys.forEach(key => {
|
|
480
|
-
const node = nodeMap[key];
|
|
481
|
-
const flatNode = {
|
|
482
|
-
id: node.path,
|
|
483
|
-
name: node.name,
|
|
484
|
-
path: node.path, // Relative path for selection
|
|
485
|
-
type: node.type,
|
|
486
|
-
depth: depth,
|
|
487
|
-
children: Object.keys(node.children).length > 0, // boolean for UI
|
|
488
|
-
expanded: this.search.length > 0 || depth < 1, // Auto expand on search or root
|
|
489
|
-
rawChildren: node.children // Keep ref for recursion
|
|
490
|
-
};
|
|
491
|
-
|
|
492
|
-
this.treeNodes.push(flatNode);
|
|
493
|
-
if (flatNode.children && flatNode.expanded) {
|
|
494
|
-
traverse(node.children, depth + 1);
|
|
495
|
-
}
|
|
496
|
-
});
|
|
497
|
-
};
|
|
498
|
-
|
|
499
|
-
traverse(root, 0);
|
|
500
|
-
},
|
|
501
|
-
|
|
502
|
-
// Re-calculate visible nodes (folding logic) without rebuilding everything
|
|
503
|
-
// But for simplicity in this SFC, we just rebuild the list when expanded changes.
|
|
504
|
-
toggleExpand(node) {
|
|
505
|
-
if (node.type !== 'folder') return;
|
|
506
|
-
|
|
507
|
-
// Find node in treeNodes and toggle
|
|
508
|
-
// Since treeNodes is flat and generated, we need to persist state or smarter logic.
|
|
509
|
-
// Simpler: Just modify the expanded state in the current flat list is tricky because children are dynamic.
|
|
510
|
-
// Better approach for this lightweight UI:
|
|
511
|
-
// We actually need a persistent "expanded" Set to survive re-renders if we want to be fancy.
|
|
512
|
-
// For now, let's just cheat: Update the boolean in the flat list works for collapsing,
|
|
513
|
-
// but expanding requires knowing the children.
|
|
514
|
-
|
|
515
|
-
// Let's reload the tree logic but using a \`expandedPaths\` set.
|
|
516
|
-
if (this.expandedPaths.has(node.path)) {
|
|
517
|
-
this.expandedPaths.delete(node.path);
|
|
518
|
-
} else {
|
|
519
|
-
this.expandedPaths.add(node.path);
|
|
520
|
-
}
|
|
521
|
-
this.refreshTreeVisibility();
|
|
522
|
-
},
|
|
523
623
|
|
|
524
624
|
// Alternative: Just use a computed property for \`visibleNodes\`.
|
|
525
625
|
// Since Alpine isn't React, we do this manually.
|
|
@@ -547,7 +647,8 @@ const startServer = async (rootDir) => {
|
|
|
547
647
|
|
|
548
648
|
this.rawFiles.forEach(file => {
|
|
549
649
|
if (this.search && !file.path.toLowerCase().includes(searchLower)) return;
|
|
550
|
-
|
|
650
|
+
// File path đã được chuẩn hóa '/' từ backend
|
|
651
|
+
const parts = file.path.split('/');
|
|
551
652
|
let current = this.rootObject;
|
|
552
653
|
parts.forEach((part, index) => {
|
|
553
654
|
if (!current[part]) {
|
|
@@ -648,9 +749,8 @@ const startServer = async (rootDir) => {
|
|
|
648
749
|
},
|
|
649
750
|
|
|
650
751
|
getAllDescendants(folderPath) {
|
|
651
|
-
// Simple filter from rawFiles
|
|
652
752
|
return this.rawFiles
|
|
653
|
-
.filter(f => f.path
|
|
753
|
+
.filter(f => f.path === folderPath || f.path.startsWith(folderPath + '/'))
|
|
654
754
|
.map(f => f.path);
|
|
655
755
|
},
|
|
656
756
|
|
|
@@ -683,7 +783,7 @@ const startServer = async (rootDir) => {
|
|
|
683
783
|
}
|
|
684
784
|
this.generating = true;
|
|
685
785
|
try {
|
|
686
|
-
const res = await fetch('
|
|
786
|
+
const res = await fetch('/api/prompt', {
|
|
687
787
|
method: 'POST',
|
|
688
788
|
headers: {'Content-Type': 'application/json'},
|
|
689
789
|
body: JSON.stringify({ filePaths: Array.from(this.selectedFiles), instruction: this.instruction })
|
|
@@ -710,7 +810,7 @@ const startServer = async (rootDir) => {
|
|
|
710
810
|
async applyPatch() {
|
|
711
811
|
this.applying = true;
|
|
712
812
|
try {
|
|
713
|
-
const res = await fetch('
|
|
813
|
+
const res = await fetch('/api/apply', {
|
|
714
814
|
method: 'POST',
|
|
715
815
|
headers: {'Content-Type': 'application/json'},
|
|
716
816
|
body: JSON.stringify({ filePaths: Array.from(this.selectedFiles), llmResponse: this.llmResponse })
|
package/bun.lock
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
"@babel/standalone": "^7.28.2",
|
|
8
8
|
"axios": "^1.11.0",
|
|
9
9
|
"commander": "^14.0.0",
|
|
10
|
+
"diff-match-patch": "^1.0.5",
|
|
10
11
|
"express": "^5.2.1",
|
|
11
12
|
"inquirer": "^12.9.6",
|
|
12
13
|
"lodash": "^4.17.21",
|
|
@@ -22,6 +23,7 @@
|
|
|
22
23
|
"devDependencies": {
|
|
23
24
|
"@types/babel__standalone": "^7.1.9",
|
|
24
25
|
"@types/commander": "^2.12.5",
|
|
26
|
+
"@types/diff-match-patch": "^1.0.36",
|
|
25
27
|
"@types/express": "^5.0.6",
|
|
26
28
|
"@types/lodash": "^4.17.20",
|
|
27
29
|
"@types/node": "^24.1.0",
|
|
@@ -92,6 +94,8 @@
|
|
|
92
94
|
|
|
93
95
|
"@types/connect": ["@types/connect@3.4.38", "", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="],
|
|
94
96
|
|
|
97
|
+
"@types/diff-match-patch": ["@types/diff-match-patch@1.0.36", "", {}, "sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg=="],
|
|
98
|
+
|
|
95
99
|
"@types/express": ["@types/express@5.0.6", "", { "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", "@types/serve-static": "^2" } }, "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA=="],
|
|
96
100
|
|
|
97
101
|
"@types/express-serve-static-core": ["@types/express-serve-static-core@5.1.0", "", { "dependencies": { "@types/node": "*", "@types/qs": "*", "@types/range-parser": "*", "@types/send": "*" } }, "sha512-jnHMsrd0Mwa9Cf4IdOzbz543y4XJepXrbia2T4b6+spXC2We3t1y6K44D3mR8XMFSXMCf3/l7rCgddfx7UNVBA=="],
|
|
@@ -180,6 +184,8 @@
|
|
|
180
184
|
|
|
181
185
|
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
|
|
182
186
|
|
|
187
|
+
"diff-match-patch": ["diff-match-patch@1.0.5", "", {}, "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw=="],
|
|
188
|
+
|
|
183
189
|
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
|
184
190
|
|
|
185
191
|
"duplexer2": ["duplexer2@0.1.4", "", { "dependencies": { "readable-stream": "^2.0.2" } }, "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA=="],
|
package/dist/index.js
CHANGED
|
@@ -411,7 +411,7 @@ class RoomState {
|
|
|
411
411
|
return this.isConnected;
|
|
412
412
|
}
|
|
413
413
|
}
|
|
414
|
-
commander_1.program.version("0.0.
|
|
414
|
+
commander_1.program.version("0.0.30").description("Hapico CLI for project management");
|
|
415
415
|
commander_1.program
|
|
416
416
|
.command("clone <id>")
|
|
417
417
|
.description("Clone a project by ID")
|