@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/tools/vibe/index.ts
CHANGED
|
@@ -1,18 +1,48 @@
|
|
|
1
1
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
2
|
import fs from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
import {
|
|
5
|
-
import { exec } from "node:child_process";
|
|
6
|
-
import util from "node:util";
|
|
4
|
+
import { diff_match_patch } from "diff-match-patch";
|
|
7
5
|
import express from "express";
|
|
8
6
|
import open from "open";
|
|
9
|
-
// import net
|
|
10
|
-
import os from "os";
|
|
11
|
-
|
|
12
|
-
const execAsync = util.promisify(exec);
|
|
13
7
|
import net from "node:net";
|
|
14
8
|
|
|
9
|
+
const scanRecursively = async (
|
|
10
|
+
dir: string,
|
|
11
|
+
baseDir: string
|
|
12
|
+
): Promise<FileNode[]> => {
|
|
13
|
+
let results: FileNode[] = [];
|
|
14
|
+
try {
|
|
15
|
+
const list = await fs.readdir(dir, { withFileTypes: true });
|
|
16
|
+
|
|
17
|
+
for (const entry of list) {
|
|
18
|
+
const fullPath = path.join(dir, entry.name);
|
|
19
|
+
// Chuẩn hóa đường dẫn về forward slash để nhất quán giữa các hệ điều hành
|
|
20
|
+
const relativePath = path.relative(baseDir, fullPath).split(path.sep).join('/');
|
|
15
21
|
|
|
22
|
+
if (shouldIgnorePath(relativePath)) continue;
|
|
23
|
+
|
|
24
|
+
if (entry.isDirectory()) {
|
|
25
|
+
const subResults = await scanRecursively(fullPath, baseDir);
|
|
26
|
+
results = results.concat(subResults);
|
|
27
|
+
} else if (entry.isFile()) {
|
|
28
|
+
try {
|
|
29
|
+
const content = await fs.readFile(fullPath, "utf-8");
|
|
30
|
+
results.push({
|
|
31
|
+
path: relativePath,
|
|
32
|
+
fullPath: fullPath,
|
|
33
|
+
content: content,
|
|
34
|
+
extension: entry.name.split(".").pop() || "txt",
|
|
35
|
+
});
|
|
36
|
+
} catch (err) {
|
|
37
|
+
// Bỏ qua file nhị phân hoặc không đọc được
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
} catch (error) {
|
|
42
|
+
// Bỏ qua nếu lỗi permission hoặc path không tồn tại
|
|
43
|
+
}
|
|
44
|
+
return results;
|
|
45
|
+
};
|
|
16
46
|
|
|
17
47
|
// random port from 3000-3999
|
|
18
48
|
// Check if port is available would be better in real-world usage
|
|
@@ -94,118 +124,261 @@ const shouldIgnorePath = (filePath: string): boolean => {
|
|
|
94
124
|
return false;
|
|
95
125
|
};
|
|
96
126
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
127
|
+
/**
|
|
128
|
+
* ------------------------------------------------------------------
|
|
129
|
+
* TYPES
|
|
130
|
+
* ------------------------------------------------------------------
|
|
131
|
+
*/
|
|
132
|
+
interface PatchBlock {
|
|
133
|
+
search: string;
|
|
134
|
+
replace: string;
|
|
135
|
+
}
|
|
104
136
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
137
|
+
/**
|
|
138
|
+
* ------------------------------------------------------------------
|
|
139
|
+
* HELPER: STRING SIMILARITY (Levenshtein based)
|
|
140
|
+
* Tính độ giống nhau giữa 2 chuỗi (0 -> 1)
|
|
141
|
+
* ------------------------------------------------------------------
|
|
142
|
+
*/
|
|
143
|
+
const calculateSimilarity = (s1: string, s2: string): number => {
|
|
144
|
+
const longer = s1.length > s2.length ? s1 : s2;
|
|
145
|
+
const shorter = s1.length > s2.length ? s2 : s1;
|
|
146
|
+
const longerLength = longer.length;
|
|
147
|
+
if (longerLength === 0) {
|
|
148
|
+
return 1.0;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Sử dụng dmp để tính Levenshtein distance cho chính xác và nhanh
|
|
152
|
+
const dmp = new diff_match_patch();
|
|
153
|
+
const diffs = dmp.diff_main(longer, shorter);
|
|
154
|
+
const levenshtein = dmp.diff_levenshtein(diffs);
|
|
155
|
+
|
|
156
|
+
return (longerLength - levenshtein) / longerLength;
|
|
157
|
+
};
|
|
108
158
|
|
|
109
|
-
|
|
159
|
+
/**
|
|
160
|
+
* ------------------------------------------------------------------
|
|
161
|
+
* HELPER: FUZZY LINE MATCHING
|
|
162
|
+
* Tìm đoạn code khớp nhất trong file gốc dựa trên điểm số (Confidence)
|
|
163
|
+
* ------------------------------------------------------------------
|
|
164
|
+
*/
|
|
165
|
+
const applyFuzzyLineMatch = (
|
|
166
|
+
original: string,
|
|
167
|
+
search: string,
|
|
168
|
+
replace: string,
|
|
169
|
+
threshold: number = 0.85 // Ngưỡng chấp nhận (85% giống nhau)
|
|
170
|
+
): { result: string | null; confidence: number } => {
|
|
171
|
+
const originalLines = original.split(/\r?\n/);
|
|
172
|
+
const searchLines = search.split(/\r?\n/);
|
|
173
|
+
|
|
174
|
+
// Lọc bỏ dòng trống ở đầu/cuối search block để match chính xác hơn
|
|
175
|
+
while (searchLines.length > 0 && searchLines[0].trim() === "") searchLines.shift();
|
|
176
|
+
while (searchLines.length > 0 && searchLines[searchLines.length - 1].trim() === "") searchLines.pop();
|
|
177
|
+
|
|
178
|
+
if (searchLines.length === 0) return { result: null, confidence: 0 };
|
|
179
|
+
|
|
180
|
+
let bestMatchIndex = -1;
|
|
181
|
+
let bestMatchScore = 0;
|
|
182
|
+
|
|
183
|
+
// Duyệt qua file gốc (Sliding Window)
|
|
184
|
+
// Chỉ cần duyệt đến vị trí mà số dòng còn lại đủ để chứa searchLines
|
|
185
|
+
for (let i = 0; i <= originalLines.length - searchLines.length; i++) {
|
|
186
|
+
let currentScoreTotal = 0;
|
|
187
|
+
let possible = true;
|
|
110
188
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
fullPath: fullPath,
|
|
120
|
-
content: content,
|
|
121
|
-
extension: entry.name.split(".").pop() || "txt",
|
|
122
|
-
});
|
|
123
|
-
} catch (err) {
|
|
124
|
-
// Bỏ qua file nhị phân hoặc không đọc được
|
|
125
|
-
}
|
|
189
|
+
for (let j = 0; j < searchLines.length; j++) {
|
|
190
|
+
const originalLine = originalLines[i + j].trim();
|
|
191
|
+
const searchLine = searchLines[j].trim();
|
|
192
|
+
|
|
193
|
+
// So sánh nhanh: Nếu giống hệt nhau
|
|
194
|
+
if (originalLine === searchLine) {
|
|
195
|
+
currentScoreTotal += 1;
|
|
196
|
+
continue;
|
|
126
197
|
}
|
|
198
|
+
|
|
199
|
+
// So sánh chậm: Tính độ tương đồng
|
|
200
|
+
const similarity = calculateSimilarity(originalLine, searchLine);
|
|
201
|
+
|
|
202
|
+
// Nếu một dòng quá khác biệt (< 60%), coi như block này không khớp
|
|
203
|
+
// (Giúp tối ưu hiệu năng, cắt nhánh sớm)
|
|
204
|
+
if (similarity < 0.6) {
|
|
205
|
+
possible = false;
|
|
206
|
+
break;
|
|
207
|
+
}
|
|
208
|
+
currentScoreTotal += similarity;
|
|
127
209
|
}
|
|
128
|
-
} catch (error) {
|
|
129
|
-
// Bỏ qua nếu lỗi permission hoặc path không tồn tại
|
|
130
|
-
}
|
|
131
|
-
return results;
|
|
132
|
-
};
|
|
133
210
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
211
|
+
if (possible) {
|
|
212
|
+
const avgScore = currentScoreTotal / searchLines.length;
|
|
213
|
+
if (avgScore > bestMatchScore) {
|
|
214
|
+
bestMatchScore = avgScore;
|
|
215
|
+
bestMatchIndex = i;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
137
219
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
replaceBlock: string
|
|
142
|
-
): string | null => {
|
|
143
|
-
const originalLines = originalContent.split(/\r?\n/);
|
|
144
|
-
const searchLines = searchBlock.split(/\r?\n/);
|
|
145
|
-
|
|
146
|
-
while (searchLines.length > 0 && searchLines[0].trim() === "") {
|
|
147
|
-
searchLines.shift();
|
|
220
|
+
// LOG CONFIDENCE ĐỂ DEBUG
|
|
221
|
+
if (bestMatchScore > 0) {
|
|
222
|
+
console.log(`[FuzzyMatch] Best match score: ${(bestMatchScore * 100).toFixed(2)}% at line ${bestMatchIndex + 1}`);
|
|
148
223
|
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
searchLines
|
|
224
|
+
|
|
225
|
+
// Nếu tìm thấy vị trí có độ tin cậy cao hơn ngưỡng
|
|
226
|
+
if (bestMatchIndex !== -1 && bestMatchScore >= threshold) {
|
|
227
|
+
const before = originalLines.slice(0, bestMatchIndex);
|
|
228
|
+
// Lưu ý: Đoạn replace thay thế đúng số lượng dòng của searchLines
|
|
229
|
+
// (Giả định search và match trong original có cùng số dòng hiển thị)
|
|
230
|
+
const after = originalLines.slice(bestMatchIndex + searchLines.length);
|
|
231
|
+
|
|
232
|
+
return {
|
|
233
|
+
result: [...before, replace, ...after].join("\n"),
|
|
234
|
+
confidence: bestMatchScore
|
|
235
|
+
};
|
|
154
236
|
}
|
|
155
237
|
|
|
156
|
-
|
|
238
|
+
return { result: null, confidence: bestMatchScore };
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* ------------------------------------------------------------------
|
|
243
|
+
* 1. STATE MACHINE PARSER (FILE-AWARE)
|
|
244
|
+
* ------------------------------------------------------------------
|
|
245
|
+
*/
|
|
246
|
+
const parseLLMPatch = (patchContent: string): Record<string, PatchBlock[]> => {
|
|
247
|
+
const lines = patchContent.split(/\r?\n/);
|
|
248
|
+
const filePatches: Record<string, PatchBlock[]> = {};
|
|
249
|
+
|
|
250
|
+
let currentFile = "unknown";
|
|
251
|
+
let inSearch = false;
|
|
252
|
+
let inReplace = false;
|
|
253
|
+
let searchLines: string[] = [];
|
|
254
|
+
let replaceLines: string[] = [];
|
|
255
|
+
|
|
256
|
+
const isFileMarker = (line: string) => {
|
|
257
|
+
const match = line.match(/^### File:\s*[`']?([^`'\s]+)[`']?/i);
|
|
258
|
+
return match ? match[1] : null;
|
|
259
|
+
};
|
|
260
|
+
const isSearchMarker = (line: string) => /^\s*<{7,}\s*SEARCH\s*$/.test(line);
|
|
261
|
+
const isDividerMarker = (line: string) => /^\s*={7,}\s*$/.test(line);
|
|
262
|
+
const isEndMarker = (line: string) => /^\s*>{7,}\s*REPLACE\s*$/.test(line);
|
|
263
|
+
|
|
264
|
+
for (const line of lines) {
|
|
265
|
+
const fileName = isFileMarker(line);
|
|
266
|
+
if (fileName) {
|
|
267
|
+
currentFile = fileName;
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
157
270
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
}
|
|
271
|
+
if (isSearchMarker(line)) {
|
|
272
|
+
inSearch = true;
|
|
273
|
+
inReplace = false;
|
|
274
|
+
searchLines = [];
|
|
275
|
+
replaceLines = [];
|
|
276
|
+
continue;
|
|
165
277
|
}
|
|
166
|
-
if (
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
return `${before}\n${replaceBlock}\n${after}`;
|
|
278
|
+
if (isDividerMarker(line)) {
|
|
279
|
+
if (inSearch) { inSearch = false; inReplace = true; }
|
|
280
|
+
continue;
|
|
170
281
|
}
|
|
282
|
+
if (isEndMarker(line)) {
|
|
283
|
+
if (inReplace || inSearch) {
|
|
284
|
+
if (searchLines.length > 0 || replaceLines.length > 0) {
|
|
285
|
+
if (!filePatches[currentFile]) filePatches[currentFile] = [];
|
|
286
|
+
filePatches[currentFile].push({
|
|
287
|
+
search: searchLines.join("\n"),
|
|
288
|
+
replace: replaceLines.join("\n")
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
inSearch = false; inReplace = false; searchLines = []; replaceLines = [];
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
if (inSearch) searchLines.push(line);
|
|
296
|
+
else if (inReplace) replaceLines.push(line);
|
|
171
297
|
}
|
|
172
|
-
return
|
|
298
|
+
return filePatches;
|
|
173
299
|
};
|
|
174
300
|
|
|
175
|
-
|
|
301
|
+
/**
|
|
302
|
+
* ------------------------------------------------------------------
|
|
303
|
+
* 2. APPLY PATCH (UPDATED)
|
|
304
|
+
* ------------------------------------------------------------------
|
|
305
|
+
*/
|
|
306
|
+
export const applyPatch = (
|
|
307
|
+
originalContent: string,
|
|
308
|
+
patches: PatchBlock[]
|
|
309
|
+
): string => {
|
|
310
|
+
if (!patches || patches.length === 0) return originalContent;
|
|
311
|
+
|
|
176
312
|
let result = originalContent;
|
|
177
|
-
const patchRegex =
|
|
178
|
-
/<<<<<<< SEARCH\s*\n([\s\S]*?)\n?=======\s*\n([\s\S]*?)\n?>>>>>>> REPLACE/g;
|
|
179
|
-
const matches = [...patchContent.matchAll(patchRegex)];
|
|
180
313
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
}
|
|
314
|
+
for (const patch of patches) {
|
|
315
|
+
const { search, replace } = patch;
|
|
184
316
|
|
|
185
|
-
|
|
186
|
-
|
|
317
|
+
// NẾU SEARCH RỖNG (INSERT/APPEND)
|
|
318
|
+
if (search.trim() === "") {
|
|
319
|
+
console.log(`[ApplyPatch] Inserting/appending block.`);
|
|
320
|
+
result += `\n${replace}`;
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
187
323
|
|
|
188
|
-
|
|
189
|
-
|
|
324
|
+
// --- STRATEGY 1: EXACT MATCH ---
|
|
325
|
+
if (result.includes(search)) {
|
|
326
|
+
console.log(`[ApplyPatch] Exact match found.`);
|
|
327
|
+
result = result.replace(search, replace);
|
|
190
328
|
continue;
|
|
191
329
|
}
|
|
192
330
|
|
|
193
|
-
|
|
194
|
-
const
|
|
331
|
+
// --- STRATEGY 2: NORMALIZED MATCH (Trim) ---
|
|
332
|
+
const trimmedSearch = search.trim();
|
|
195
333
|
if (result.includes(trimmedSearch)) {
|
|
196
|
-
|
|
334
|
+
console.log(`[ApplyPatch] Trimmed match found.`);
|
|
335
|
+
result = result.replace(trimmedSearch, replace.trim());
|
|
197
336
|
continue;
|
|
198
337
|
}
|
|
338
|
+
|
|
339
|
+
// --- STRATEGY 3: FUZZY LINE MATCH (BEST FOR CODE) ---
|
|
340
|
+
// Tìm vị trí tốt nhất dựa trên similarity score của từng dòng
|
|
341
|
+
const fuzzyMatch = applyFuzzyLineMatch(result, search, replace, 0.80); // 80% confidence
|
|
342
|
+
if (fuzzyMatch.result !== null) {
|
|
343
|
+
console.log(`[ApplyPatch] Fuzzy line match applied (Confidence: ${(fuzzyMatch.confidence * 100).toFixed(0)}%)`);
|
|
344
|
+
result = fuzzyMatch.result;
|
|
345
|
+
continue;
|
|
346
|
+
} else {
|
|
347
|
+
console.warn(`[ApplyPatch] Failed to match block. Max confidence was: ${(fuzzyMatch.confidence * 100).toFixed(0)}%`);
|
|
348
|
+
}
|
|
199
349
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
350
|
+
// --- STRATEGY 4: DMP FALLBACK (LAST RESORT) ---
|
|
351
|
+
// 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)
|
|
352
|
+
// thì mới dùng DMP patch_apply.
|
|
353
|
+
try {
|
|
354
|
+
const dmp = new diff_match_patch();
|
|
355
|
+
dmp.Match_Threshold = 0.5;
|
|
356
|
+
dmp.Match_Distance = 1000;
|
|
357
|
+
|
|
358
|
+
const dmpPatches = dmp.patch_make(search, replace);
|
|
359
|
+
const [newText, applyResults] = dmp.patch_apply(dmpPatches, result);
|
|
360
|
+
|
|
361
|
+
// Kiểm tra xem có apply được hết các hunk không
|
|
362
|
+
const successCount = applyResults.filter(Boolean).length;
|
|
363
|
+
const successRate = successCount / applyResults.length;
|
|
364
|
+
|
|
365
|
+
if (successRate === 1 && newText !== result) {
|
|
366
|
+
console.log(`[ApplyPatch] DMP Patch applied via fuzzy logic.`);
|
|
367
|
+
result = newText;
|
|
368
|
+
} else {
|
|
369
|
+
console.error(`[ApplyPatch] All strategies failed for block starting with: ${search.substring(0, 30)}...`);
|
|
370
|
+
// Tuỳ chọn: Throw error để báo cho LLM biết là apply thất bại
|
|
371
|
+
// throw new Error("Could not apply patch block");
|
|
372
|
+
}
|
|
373
|
+
} catch (e) {
|
|
374
|
+
console.error(`[ApplyPatch] DMP error:`, e);
|
|
204
375
|
}
|
|
205
376
|
}
|
|
377
|
+
|
|
206
378
|
return result;
|
|
207
379
|
};
|
|
208
380
|
|
|
381
|
+
|
|
209
382
|
// ==========================================
|
|
210
383
|
// 3. WEB UI SERVER (UPDATED UI)
|
|
211
384
|
// ==========================================
|
|
@@ -238,18 +411,50 @@ const startServer = async (rootDir: string) => {
|
|
|
238
411
|
|
|
239
412
|
// API 3: Apply Patch
|
|
240
413
|
app.post("/api/apply", async (req, res) => {
|
|
241
|
-
const { llmResponse, filePaths } = req.body;
|
|
414
|
+
const { llmResponse, filePaths: selectedPaths } = req.body;
|
|
415
|
+
|
|
416
|
+
// 1. Parse all patches from LLM response
|
|
417
|
+
const filePatches = parseLLMPatch(llmResponse);
|
|
418
|
+
|
|
419
|
+
// 2. Scan project files
|
|
242
420
|
const allFiles = await scanRecursively(rootDir, rootDir);
|
|
243
|
-
const selectedFiles = allFiles.filter((f) => filePaths.includes(f.path));
|
|
244
|
-
|
|
245
421
|
const modifiedFiles: string[] = [];
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
422
|
+
|
|
423
|
+
// 3. Match patches to files and apply
|
|
424
|
+
for (const file of allFiles) {
|
|
425
|
+
// Find patches meant for this specific file path
|
|
426
|
+
// Note: LLM might use different path separators, so we normalize
|
|
427
|
+
const normalizedPath = file.path.replace(/\\/g, '/');
|
|
428
|
+
const patchesForFile = Object.entries(filePatches).find(([path]) => {
|
|
429
|
+
const normalizedKey = path.replace(/\\/g, '/');
|
|
430
|
+
return normalizedKey === normalizedPath || normalizedPath.endsWith('/' + normalizedKey);
|
|
431
|
+
})?.[1];
|
|
432
|
+
|
|
433
|
+
// Only apply if the file was selected in UI AND has patches in the response
|
|
434
|
+
if (selectedPaths.includes(file.path) && patchesForFile) {
|
|
435
|
+
console.log(`[Apply] Processing ${patchesForFile.length} blocks for: ${file.path}`);
|
|
436
|
+
const newContent = applyPatch(file.content, patchesForFile);
|
|
437
|
+
|
|
438
|
+
if (newContent !== file.content) {
|
|
439
|
+
await fs.writeFile(file.fullPath, newContent, "utf-8");
|
|
440
|
+
modifiedFiles.push(file.path);
|
|
441
|
+
}
|
|
251
442
|
}
|
|
252
443
|
}
|
|
444
|
+
|
|
445
|
+
// Fallback: If no matched files but only one file was selected, try applying "unknown" patches
|
|
446
|
+
if (modifiedFiles.length === 0 && selectedPaths.length === 1 && filePatches["unknown"]) {
|
|
447
|
+
const file = allFiles.find(f => f.path === selectedPaths[0]);
|
|
448
|
+
if (file) {
|
|
449
|
+
console.log(`[Apply] Fallback: Applying unknown blocks to single selected file: ${file.path}`);
|
|
450
|
+
const newContent = applyPatch(file.content, filePatches["unknown"]);
|
|
451
|
+
if (newContent !== file.content) {
|
|
452
|
+
await fs.writeFile(file.fullPath, newContent, "utf-8");
|
|
453
|
+
modifiedFiles.push(file.path);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
253
458
|
res.json({ modified: modifiedFiles });
|
|
254
459
|
});
|
|
255
460
|
|
|
@@ -376,7 +581,8 @@ const startServer = async (rootDir: string) => {
|
|
|
376
581
|
|
|
377
582
|
<div class="flex-1 flex flex-col bg-gray-850 min-w-0">
|
|
378
583
|
|
|
379
|
-
|
|
584
|
+
<!-- 1. Instruction (Takes more space and resizable) -->
|
|
585
|
+
<div class="flex-1 p-6 flex flex-col min-h-0 border-b border-apple-border">
|
|
380
586
|
<div class="flex justify-between items-end mb-3">
|
|
381
587
|
<div>
|
|
382
588
|
<h2 class="text-sm font-semibold text-gray-200">1. Instruction</h2>
|
|
@@ -392,12 +598,12 @@ const startServer = async (rootDir: string) => {
|
|
|
392
598
|
</button>
|
|
393
599
|
</div>
|
|
394
600
|
<textarea x-model="instruction"
|
|
395
|
-
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-
|
|
396
|
-
rows="3"
|
|
601
|
+
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]"
|
|
397
602
|
placeholder="e.g. Refactor the Button component to use TypeScript interfaces..."></textarea>
|
|
398
603
|
</div>
|
|
399
604
|
|
|
400
|
-
|
|
605
|
+
<!-- 2. LLM Patch (Fixed smaller size) -->
|
|
606
|
+
<div class="h-64 p-6 flex flex-col flex-shrink-0">
|
|
401
607
|
<div class="flex justify-between items-end mb-3">
|
|
402
608
|
<div>
|
|
403
609
|
<h2 class="text-sm font-semibold text-gray-200">2. LLM Patch</h2>
|
|
@@ -410,11 +616,9 @@ const startServer = async (rootDir: string) => {
|
|
|
410
616
|
<span x-show="applying">Applying...</span>
|
|
411
617
|
</button>
|
|
412
618
|
</div>
|
|
413
|
-
<
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
placeholder="<<<<<<< SEARCH..."></textarea>
|
|
417
|
-
</div>
|
|
619
|
+
<textarea x-model="llmResponse"
|
|
620
|
+
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"
|
|
621
|
+
placeholder="<<<<<<< SEARCH..."></textarea>
|
|
418
622
|
</div>
|
|
419
623
|
|
|
420
624
|
</div>
|
|
@@ -459,7 +663,8 @@ const startServer = async (rootDir: string) => {
|
|
|
459
663
|
|
|
460
664
|
async init() {
|
|
461
665
|
try {
|
|
462
|
-
|
|
666
|
+
// Sử dụng relative URL để tránh lỗi phân giải localhost/127.0.0.1 trên Windows
|
|
667
|
+
const res = await fetch('/api/files');
|
|
463
668
|
const data = await res.json();
|
|
464
669
|
this.rawFiles = data;
|
|
465
670
|
this.buildTree();
|
|
@@ -469,98 +674,10 @@ const startServer = async (rootDir: string) => {
|
|
|
469
674
|
this.loading = false;
|
|
470
675
|
}
|
|
471
676
|
|
|
472
|
-
// Search watcher
|
|
473
677
|
this.$watch('search', () => this.buildTree());
|
|
474
|
-
|
|
475
|
-
// Auto-generate prompt
|
|
476
678
|
this.$watch('instruction', () => this.debouncedFetchPrompt());
|
|
477
679
|
this.$watch('selectedFiles', () => this.debouncedFetchPrompt());
|
|
478
680
|
},
|
|
479
|
-
|
|
480
|
-
// ===========================
|
|
481
|
-
// Tree Logic
|
|
482
|
-
// ===========================
|
|
483
|
-
|
|
484
|
-
buildTree() {
|
|
485
|
-
// 1. Convert paths to object structure
|
|
486
|
-
const root = {};
|
|
487
|
-
const searchLower = this.search.toLowerCase();
|
|
488
|
-
|
|
489
|
-
this.rawFiles.forEach(file => {
|
|
490
|
-
if (this.search && !file.path.toLowerCase().includes(searchLower)) return;
|
|
491
|
-
|
|
492
|
-
const parts = file.path.split(/[\\\\/]/);
|
|
493
|
-
let current = root;
|
|
494
|
-
|
|
495
|
-
parts.forEach((part, index) => {
|
|
496
|
-
if (!current[part]) {
|
|
497
|
-
current[part] = {
|
|
498
|
-
name: part,
|
|
499
|
-
path: parts.slice(0, index + 1).join('/'),
|
|
500
|
-
children: {},
|
|
501
|
-
type: index === parts.length - 1 ? 'file' : 'folder',
|
|
502
|
-
fullPath: file.path // Only meaningful for file
|
|
503
|
-
};
|
|
504
|
-
}
|
|
505
|
-
current = current[part].children;
|
|
506
|
-
});
|
|
507
|
-
});
|
|
508
|
-
|
|
509
|
-
// 2. Flatten object structure into renderable list with depth
|
|
510
|
-
this.treeNodes = [];
|
|
511
|
-
const traverse = (nodeMap, depth) => {
|
|
512
|
-
// Sort: Folders first, then Files. Alphabetical.
|
|
513
|
-
const keys = Object.keys(nodeMap).sort((a, b) => {
|
|
514
|
-
const nodeA = nodeMap[a];
|
|
515
|
-
const nodeB = nodeMap[b];
|
|
516
|
-
if (nodeA.type !== nodeB.type) return nodeA.type === 'folder' ? -1 : 1;
|
|
517
|
-
return a.localeCompare(b);
|
|
518
|
-
});
|
|
519
|
-
|
|
520
|
-
keys.forEach(key => {
|
|
521
|
-
const node = nodeMap[key];
|
|
522
|
-
const flatNode = {
|
|
523
|
-
id: node.path,
|
|
524
|
-
name: node.name,
|
|
525
|
-
path: node.path, // Relative path for selection
|
|
526
|
-
type: node.type,
|
|
527
|
-
depth: depth,
|
|
528
|
-
children: Object.keys(node.children).length > 0, // boolean for UI
|
|
529
|
-
expanded: this.search.length > 0 || depth < 1, // Auto expand on search or root
|
|
530
|
-
rawChildren: node.children // Keep ref for recursion
|
|
531
|
-
};
|
|
532
|
-
|
|
533
|
-
this.treeNodes.push(flatNode);
|
|
534
|
-
if (flatNode.children && flatNode.expanded) {
|
|
535
|
-
traverse(node.children, depth + 1);
|
|
536
|
-
}
|
|
537
|
-
});
|
|
538
|
-
};
|
|
539
|
-
|
|
540
|
-
traverse(root, 0);
|
|
541
|
-
},
|
|
542
|
-
|
|
543
|
-
// Re-calculate visible nodes (folding logic) without rebuilding everything
|
|
544
|
-
// But for simplicity in this SFC, we just rebuild the list when expanded changes.
|
|
545
|
-
toggleExpand(node) {
|
|
546
|
-
if (node.type !== 'folder') return;
|
|
547
|
-
|
|
548
|
-
// Find node in treeNodes and toggle
|
|
549
|
-
// Since treeNodes is flat and generated, we need to persist state or smarter logic.
|
|
550
|
-
// Simpler: Just modify the expanded state in the current flat list is tricky because children are dynamic.
|
|
551
|
-
// Better approach for this lightweight UI:
|
|
552
|
-
// We actually need a persistent "expanded" Set to survive re-renders if we want to be fancy.
|
|
553
|
-
// For now, let's just cheat: Update the boolean in the flat list works for collapsing,
|
|
554
|
-
// but expanding requires knowing the children.
|
|
555
|
-
|
|
556
|
-
// Let's reload the tree logic but using a \`expandedPaths\` set.
|
|
557
|
-
if (this.expandedPaths.has(node.path)) {
|
|
558
|
-
this.expandedPaths.delete(node.path);
|
|
559
|
-
} else {
|
|
560
|
-
this.expandedPaths.add(node.path);
|
|
561
|
-
}
|
|
562
|
-
this.refreshTreeVisibility();
|
|
563
|
-
},
|
|
564
681
|
|
|
565
682
|
// Alternative: Just use a computed property for \`visibleNodes\`.
|
|
566
683
|
// Since Alpine isn't React, we do this manually.
|
|
@@ -588,7 +705,8 @@ const startServer = async (rootDir: string) => {
|
|
|
588
705
|
|
|
589
706
|
this.rawFiles.forEach(file => {
|
|
590
707
|
if (this.search && !file.path.toLowerCase().includes(searchLower)) return;
|
|
591
|
-
|
|
708
|
+
// File path đã được chuẩn hóa '/' từ backend
|
|
709
|
+
const parts = file.path.split('/');
|
|
592
710
|
let current = this.rootObject;
|
|
593
711
|
parts.forEach((part, index) => {
|
|
594
712
|
if (!current[part]) {
|
|
@@ -689,9 +807,8 @@ const startServer = async (rootDir: string) => {
|
|
|
689
807
|
},
|
|
690
808
|
|
|
691
809
|
getAllDescendants(folderPath) {
|
|
692
|
-
// Simple filter from rawFiles
|
|
693
810
|
return this.rawFiles
|
|
694
|
-
.filter(f => f.path
|
|
811
|
+
.filter(f => f.path === folderPath || f.path.startsWith(folderPath + '/'))
|
|
695
812
|
.map(f => f.path);
|
|
696
813
|
},
|
|
697
814
|
|
|
@@ -724,7 +841,7 @@ const startServer = async (rootDir: string) => {
|
|
|
724
841
|
}
|
|
725
842
|
this.generating = true;
|
|
726
843
|
try {
|
|
727
|
-
const res = await fetch('
|
|
844
|
+
const res = await fetch('/api/prompt', {
|
|
728
845
|
method: 'POST',
|
|
729
846
|
headers: {'Content-Type': 'application/json'},
|
|
730
847
|
body: JSON.stringify({ filePaths: Array.from(this.selectedFiles), instruction: this.instruction })
|
|
@@ -751,7 +868,7 @@ const startServer = async (rootDir: string) => {
|
|
|
751
868
|
async applyPatch() {
|
|
752
869
|
this.applying = true;
|
|
753
870
|
try {
|
|
754
|
-
const res = await fetch('
|
|
871
|
+
const res = await fetch('/api/apply', {
|
|
755
872
|
method: 'POST',
|
|
756
873
|
headers: {'Content-Type': 'application/json'},
|
|
757
874
|
body: JSON.stringify({ filePaths: Array.from(this.selectedFiles), llmResponse: this.llmResponse })
|