@hapico/cli 0.0.29 → 0.0.31
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 +382 -490
- package/bin/tools/nativewind/index.js +80 -0
- package/bin/tools/vibe/index.js +88 -108
- package/bin/tools/vibe/patch.utils.js +243 -0
- package/bun.lock +874 -0
- package/dist/index.js +382 -490
- package/dist/tools/nativewind/index.js +80 -0
- package/dist/tools/vibe/index.js +88 -108
- package/dist/tools/vibe/patch.utils.js +243 -0
- package/index.ts +475 -787
- package/package.json +5 -1
- package/tools/nativewind/index.ts +87 -0
- package/tools/vibe/index.ts +91 -108
- package/tools/vibe/patch.utils.ts +273 -0
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.generateNativeWindStyleMap = generateNativeWindStyleMap;
|
|
7
|
+
exports.compileAndSaveToFolder = compileAndSaveToFolder;
|
|
8
|
+
const postcss_1 = __importDefault(require("postcss"));
|
|
9
|
+
const tailwindcss_1 = __importDefault(require("tailwindcss"));
|
|
10
|
+
const fs_1 = __importDefault(require("fs"));
|
|
11
|
+
const path_1 = __importDefault(require("path"));
|
|
12
|
+
// @ts-ignore
|
|
13
|
+
const preset_1 = __importDefault(require("nativewind/preset"));
|
|
14
|
+
// ==========================================
|
|
15
|
+
// FIX LỖI IMPORT REACT-NATIVE-CSS-INTEROP
|
|
16
|
+
// ==========================================
|
|
17
|
+
let compileToNative;
|
|
18
|
+
try {
|
|
19
|
+
const compiler = require("react-native-css-interop/dist/compiler");
|
|
20
|
+
compileToNative = compiler.compile || compiler.default;
|
|
21
|
+
}
|
|
22
|
+
catch (e) {
|
|
23
|
+
try {
|
|
24
|
+
const cssToRn = require("react-native-css-interop/dist/css-to-rn");
|
|
25
|
+
compileToNative = cssToRn.cssToReactNativeRuntime || cssToRn.default;
|
|
26
|
+
}
|
|
27
|
+
catch (err) {
|
|
28
|
+
throw new Error("Không thể tìm thấy hàm compile. Kiểm tra lại node_modules.");
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Hàm biên dịch Tailwind Class sang Compiled Object
|
|
33
|
+
*/
|
|
34
|
+
async function generateNativeWindStyleMap(contentPaths, rawCss = "@tailwind base;\n@tailwind components;\n@tailwind utilities;") {
|
|
35
|
+
try {
|
|
36
|
+
const tailwindConfig = {
|
|
37
|
+
content: contentPaths,
|
|
38
|
+
presets: [preset_1.default],
|
|
39
|
+
theme: { extend: {} },
|
|
40
|
+
};
|
|
41
|
+
const processor = (0, postcss_1.default)([(0, tailwindcss_1.default)(tailwindConfig)]);
|
|
42
|
+
const result = await processor.process(rawCss, {
|
|
43
|
+
from: "tailwind.css",
|
|
44
|
+
to: "output.css",
|
|
45
|
+
});
|
|
46
|
+
return compileToNative(result.css, { native: true });
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
console.error("❌ Compile Style Map Error:", error);
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Hàm hỗ trợ: Quét 1 thư mục và Lưu kết quả vào 1 thư mục khác
|
|
55
|
+
*/
|
|
56
|
+
async function compileAndSaveToFolder(folderToScan, // Thư mục chứa code cần quét (VD: "./src/components")
|
|
57
|
+
outputFilePath // Nơi lưu kết quả (VD: "./dist/styles.json")
|
|
58
|
+
) {
|
|
59
|
+
try {
|
|
60
|
+
// 1. Tạo đường dẫn quét toàn bộ file js, jsx, ts, tsx trong thư mục
|
|
61
|
+
// path.resolve(process.cwd(), ...) giúp lấy đúng đường dẫn gốc của project
|
|
62
|
+
const scanPattern = path_1.default.resolve(process.cwd(), folderToScan, "**/*.{js,jsx,ts,tsx}");
|
|
63
|
+
// console.log(`🔍 Scanning files in ${scanPattern}`);
|
|
64
|
+
// 2. Chạy hàm biên dịch
|
|
65
|
+
const compiledObject = await generateNativeWindStyleMap([scanPattern]);
|
|
66
|
+
// 3. Xử lý thư mục lưu file
|
|
67
|
+
const absoluteOutputPath = path_1.default.resolve(process.cwd(), outputFilePath);
|
|
68
|
+
const outputDir = path_1.default.dirname(absoluteOutputPath); // Lấy tên thư mục chứa file đầu ra
|
|
69
|
+
// Nếu thư mục đầu ra chưa tồn tại, Node.js sẽ tự động tạo thư mục đó (recursive: true)
|
|
70
|
+
if (!fs_1.default.existsSync(outputDir)) {
|
|
71
|
+
fs_1.default.mkdirSync(outputDir, { recursive: true });
|
|
72
|
+
}
|
|
73
|
+
// 4. Ghi Object ra file JSON
|
|
74
|
+
fs_1.default.writeFileSync(absoluteOutputPath, JSON.stringify(compiledObject, null, 2), "utf-8");
|
|
75
|
+
// console.log(`✅ Sync CSS ${absoluteOutputPath}`);
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
console.error("❌ Lỗi khi xuất file:", error);
|
|
79
|
+
}
|
|
80
|
+
}
|
package/bin/tools/vibe/index.js
CHANGED
|
@@ -17,7 +17,8 @@ const scanRecursively = async (dir, baseDir) => {
|
|
|
17
17
|
const list = await promises_1.default.readdir(dir, { withFileTypes: true });
|
|
18
18
|
for (const entry of list) {
|
|
19
19
|
const fullPath = node_path_1.default.join(dir, entry.name);
|
|
20
|
-
|
|
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('/');
|
|
21
22
|
if (shouldIgnorePath(relativePath))
|
|
22
23
|
continue;
|
|
23
24
|
if (entry.isDirectory()) {
|
|
@@ -212,6 +213,10 @@ const parseLLMPatch = (patchContent) => {
|
|
|
212
213
|
let inReplace = false;
|
|
213
214
|
let searchLines = [];
|
|
214
215
|
let replaceLines = [];
|
|
216
|
+
// State for full file replacement detection
|
|
217
|
+
let inCodeBlock = false;
|
|
218
|
+
let codeBlockLines = [];
|
|
219
|
+
let hasSeenMarkersInBlock = false;
|
|
215
220
|
const isFileMarker = (line) => {
|
|
216
221
|
const match = line.match(/^### File:\s*[`']?([^`'\s]+)[`']?/i);
|
|
217
222
|
return match ? match[1] : null;
|
|
@@ -223,11 +228,16 @@ const parseLLMPatch = (patchContent) => {
|
|
|
223
228
|
const fileName = isFileMarker(line);
|
|
224
229
|
if (fileName) {
|
|
225
230
|
currentFile = fileName;
|
|
231
|
+
// Reset tracking when switching files
|
|
232
|
+
inCodeBlock = false;
|
|
233
|
+
codeBlockLines = [];
|
|
234
|
+
hasSeenMarkersInBlock = false;
|
|
226
235
|
continue;
|
|
227
236
|
}
|
|
228
237
|
if (isSearchMarker(line)) {
|
|
229
238
|
inSearch = true;
|
|
230
239
|
inReplace = false;
|
|
240
|
+
hasSeenMarkersInBlock = true;
|
|
231
241
|
searchLines = [];
|
|
232
242
|
replaceLines = [];
|
|
233
243
|
continue;
|
|
@@ -237,9 +247,11 @@ const parseLLMPatch = (patchContent) => {
|
|
|
237
247
|
inSearch = false;
|
|
238
248
|
inReplace = true;
|
|
239
249
|
}
|
|
250
|
+
hasSeenMarkersInBlock = true;
|
|
240
251
|
continue;
|
|
241
252
|
}
|
|
242
253
|
if (isEndMarker(line)) {
|
|
254
|
+
hasSeenMarkersInBlock = true;
|
|
243
255
|
if (inReplace || inSearch) {
|
|
244
256
|
if (searchLines.length > 0 || replaceLines.length > 0) {
|
|
245
257
|
if (!filePatches[currentFile])
|
|
@@ -256,10 +268,33 @@ const parseLLMPatch = (patchContent) => {
|
|
|
256
268
|
replaceLines = [];
|
|
257
269
|
continue;
|
|
258
270
|
}
|
|
271
|
+
// Detect triple backticks to identify potential full-file replacements
|
|
272
|
+
if (line.trim().startsWith("```")) {
|
|
273
|
+
if (!inCodeBlock) {
|
|
274
|
+
inCodeBlock = true;
|
|
275
|
+
codeBlockLines = [];
|
|
276
|
+
hasSeenMarkersInBlock = false;
|
|
277
|
+
}
|
|
278
|
+
else {
|
|
279
|
+
inCodeBlock = false;
|
|
280
|
+
// If the code block ended without any SEARCH/REPLACE markers, treat it as full-file code
|
|
281
|
+
if (!hasSeenMarkersInBlock && codeBlockLines.length > 0 && currentFile !== "unknown") {
|
|
282
|
+
if (!filePatches[currentFile])
|
|
283
|
+
filePatches[currentFile] = [];
|
|
284
|
+
filePatches[currentFile].push({
|
|
285
|
+
search: "__FULL_FILE_REPLACEMENT__",
|
|
286
|
+
replace: codeBlockLines.join("\n")
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
259
292
|
if (inSearch)
|
|
260
293
|
searchLines.push(line);
|
|
261
294
|
else if (inReplace)
|
|
262
295
|
replaceLines.push(line);
|
|
296
|
+
else if (inCodeBlock)
|
|
297
|
+
codeBlockLines.push(line);
|
|
263
298
|
}
|
|
264
299
|
return filePatches;
|
|
265
300
|
};
|
|
@@ -274,6 +309,12 @@ const applyPatch = (originalContent, patches) => {
|
|
|
274
309
|
let result = originalContent;
|
|
275
310
|
for (const patch of patches) {
|
|
276
311
|
const { search, replace } = patch;
|
|
312
|
+
// Strategy 0: Full File Replacement (Overwrite)
|
|
313
|
+
if (search === "__FULL_FILE_REPLACEMENT__") {
|
|
314
|
+
console.log(`[ApplyPatch] Overwriting file with full content.`);
|
|
315
|
+
result = replace;
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
277
318
|
// NẾU SEARCH RỖNG (INSERT/APPEND)
|
|
278
319
|
if (search.trim() === "") {
|
|
279
320
|
console.log(`[ApplyPatch] Inserting/appending block.`);
|
|
@@ -359,30 +400,56 @@ const startServer = async (rootDir) => {
|
|
|
359
400
|
});
|
|
360
401
|
// API 3: Apply Patch
|
|
361
402
|
app.post("/api/apply", async (req, res) => {
|
|
362
|
-
var _a;
|
|
363
403
|
const { llmResponse, filePaths: selectedPaths } = req.body;
|
|
364
404
|
// 1. Parse all patches from LLM response
|
|
365
405
|
const filePatches = parseLLMPatch(llmResponse);
|
|
366
406
|
// 2. Scan project files
|
|
367
407
|
const allFiles = await scanRecursively(rootDir, rootDir);
|
|
368
408
|
const modifiedFiles = [];
|
|
369
|
-
|
|
409
|
+
const appliedNormalizedPaths = new Set();
|
|
410
|
+
// 3. Match patches to existing files and apply
|
|
370
411
|
for (const file of allFiles) {
|
|
371
|
-
// Find patches meant for this specific file path
|
|
372
|
-
// Note: LLM might use different path separators, so we normalize
|
|
373
412
|
const normalizedPath = file.path.replace(/\\/g, '/');
|
|
374
|
-
const
|
|
413
|
+
const patchesEntry = Object.entries(filePatches).find(([path]) => {
|
|
375
414
|
const normalizedKey = path.replace(/\\/g, '/');
|
|
376
415
|
return normalizedKey === normalizedPath || normalizedPath.endsWith('/' + normalizedKey);
|
|
377
|
-
})
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
416
|
+
});
|
|
417
|
+
if (patchesEntry) {
|
|
418
|
+
const [rawKey, patchesForFile] = patchesEntry;
|
|
419
|
+
// Only apply if the file was selected in UI
|
|
420
|
+
if (selectedPaths.includes(file.path)) {
|
|
421
|
+
console.log(`[Apply] Processing ${patchesForFile.length} blocks for: ${file.path}`);
|
|
422
|
+
const newContent = (0, exports.applyPatch)(file.content, patchesForFile);
|
|
423
|
+
if (newContent !== file.content) {
|
|
424
|
+
await promises_1.default.writeFile(file.fullPath, newContent, "utf-8");
|
|
425
|
+
modifiedFiles.push(file.path);
|
|
426
|
+
}
|
|
385
427
|
}
|
|
428
|
+
appliedNormalizedPaths.add(rawKey.replace(/\\/g, '/'));
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
// 4. Handle New Files (patches for paths that don't exist yet)
|
|
432
|
+
for (const [rawPath, patches] of Object.entries(filePatches)) {
|
|
433
|
+
const normalizedKey = rawPath.replace(/\\/g, '/');
|
|
434
|
+
if (normalizedKey === "unknown" || appliedNormalizedPaths.has(normalizedKey))
|
|
435
|
+
continue;
|
|
436
|
+
// Double check it doesn't exist (safety)
|
|
437
|
+
const alreadyExists = allFiles.some(f => {
|
|
438
|
+
const np = f.path.replace(/\\/g, '/');
|
|
439
|
+
return np === normalizedKey || np.endsWith('/' + normalizedKey);
|
|
440
|
+
});
|
|
441
|
+
if (alreadyExists)
|
|
442
|
+
continue;
|
|
443
|
+
console.log(`[Apply] Creating new file: ${rawPath}`);
|
|
444
|
+
const newContent = (0, exports.applyPatch)("", patches);
|
|
445
|
+
const fullPath = node_path_1.default.resolve(rootDir, rawPath);
|
|
446
|
+
try {
|
|
447
|
+
await promises_1.default.mkdir(node_path_1.default.dirname(fullPath), { recursive: true });
|
|
448
|
+
await promises_1.default.writeFile(fullPath, newContent, "utf-8");
|
|
449
|
+
modifiedFiles.push(rawPath);
|
|
450
|
+
}
|
|
451
|
+
catch (err) {
|
|
452
|
+
console.error(`[Apply] Failed to create file ${rawPath}:`, err);
|
|
386
453
|
}
|
|
387
454
|
}
|
|
388
455
|
// Fallback: If no matched files but only one file was selected, try applying "unknown" patches
|
|
@@ -604,7 +671,8 @@ const startServer = async (rootDir) => {
|
|
|
604
671
|
|
|
605
672
|
async init() {
|
|
606
673
|
try {
|
|
607
|
-
|
|
674
|
+
// Sử dụng relative URL để tránh lỗi phân giải localhost/127.0.0.1 trên Windows
|
|
675
|
+
const res = await fetch('/api/files');
|
|
608
676
|
const data = await res.json();
|
|
609
677
|
this.rawFiles = data;
|
|
610
678
|
this.buildTree();
|
|
@@ -614,98 +682,10 @@ const startServer = async (rootDir) => {
|
|
|
614
682
|
this.loading = false;
|
|
615
683
|
}
|
|
616
684
|
|
|
617
|
-
// Search watcher
|
|
618
685
|
this.$watch('search', () => this.buildTree());
|
|
619
|
-
|
|
620
|
-
// Auto-generate prompt
|
|
621
686
|
this.$watch('instruction', () => this.debouncedFetchPrompt());
|
|
622
687
|
this.$watch('selectedFiles', () => this.debouncedFetchPrompt());
|
|
623
688
|
},
|
|
624
|
-
|
|
625
|
-
// ===========================
|
|
626
|
-
// Tree Logic
|
|
627
|
-
// ===========================
|
|
628
|
-
|
|
629
|
-
buildTree() {
|
|
630
|
-
// 1. Convert paths to object structure
|
|
631
|
-
const root = {};
|
|
632
|
-
const searchLower = this.search.toLowerCase();
|
|
633
|
-
|
|
634
|
-
this.rawFiles.forEach(file => {
|
|
635
|
-
if (this.search && !file.path.toLowerCase().includes(searchLower)) return;
|
|
636
|
-
|
|
637
|
-
const parts = file.path.split(/[\\\\/]/);
|
|
638
|
-
let current = root;
|
|
639
|
-
|
|
640
|
-
parts.forEach((part, index) => {
|
|
641
|
-
if (!current[part]) {
|
|
642
|
-
current[part] = {
|
|
643
|
-
name: part,
|
|
644
|
-
path: parts.slice(0, index + 1).join('/'),
|
|
645
|
-
children: {},
|
|
646
|
-
type: index === parts.length - 1 ? 'file' : 'folder',
|
|
647
|
-
fullPath: file.path // Only meaningful for file
|
|
648
|
-
};
|
|
649
|
-
}
|
|
650
|
-
current = current[part].children;
|
|
651
|
-
});
|
|
652
|
-
});
|
|
653
|
-
|
|
654
|
-
// 2. Flatten object structure into renderable list with depth
|
|
655
|
-
this.treeNodes = [];
|
|
656
|
-
const traverse = (nodeMap, depth) => {
|
|
657
|
-
// Sort: Folders first, then Files. Alphabetical.
|
|
658
|
-
const keys = Object.keys(nodeMap).sort((a, b) => {
|
|
659
|
-
const nodeA = nodeMap[a];
|
|
660
|
-
const nodeB = nodeMap[b];
|
|
661
|
-
if (nodeA.type !== nodeB.type) return nodeA.type === 'folder' ? -1 : 1;
|
|
662
|
-
return a.localeCompare(b);
|
|
663
|
-
});
|
|
664
|
-
|
|
665
|
-
keys.forEach(key => {
|
|
666
|
-
const node = nodeMap[key];
|
|
667
|
-
const flatNode = {
|
|
668
|
-
id: node.path,
|
|
669
|
-
name: node.name,
|
|
670
|
-
path: node.path, // Relative path for selection
|
|
671
|
-
type: node.type,
|
|
672
|
-
depth: depth,
|
|
673
|
-
children: Object.keys(node.children).length > 0, // boolean for UI
|
|
674
|
-
expanded: this.search.length > 0 || depth < 1, // Auto expand on search or root
|
|
675
|
-
rawChildren: node.children // Keep ref for recursion
|
|
676
|
-
};
|
|
677
|
-
|
|
678
|
-
this.treeNodes.push(flatNode);
|
|
679
|
-
if (flatNode.children && flatNode.expanded) {
|
|
680
|
-
traverse(node.children, depth + 1);
|
|
681
|
-
}
|
|
682
|
-
});
|
|
683
|
-
};
|
|
684
|
-
|
|
685
|
-
traverse(root, 0);
|
|
686
|
-
},
|
|
687
|
-
|
|
688
|
-
// Re-calculate visible nodes (folding logic) without rebuilding everything
|
|
689
|
-
// But for simplicity in this SFC, we just rebuild the list when expanded changes.
|
|
690
|
-
toggleExpand(node) {
|
|
691
|
-
if (node.type !== 'folder') return;
|
|
692
|
-
|
|
693
|
-
// Find node in treeNodes and toggle
|
|
694
|
-
// Since treeNodes is flat and generated, we need to persist state or smarter logic.
|
|
695
|
-
// Simpler: Just modify the expanded state in the current flat list is tricky because children are dynamic.
|
|
696
|
-
// Better approach for this lightweight UI:
|
|
697
|
-
// We actually need a persistent "expanded" Set to survive re-renders if we want to be fancy.
|
|
698
|
-
// For now, let's just cheat: Update the boolean in the flat list works for collapsing,
|
|
699
|
-
// but expanding requires knowing the children.
|
|
700
|
-
|
|
701
|
-
// Let's reload the tree logic but using a \`expandedPaths\` set.
|
|
702
|
-
if (this.expandedPaths.has(node.path)) {
|
|
703
|
-
this.expandedPaths.delete(node.path);
|
|
704
|
-
} else {
|
|
705
|
-
this.expandedPaths.add(node.path);
|
|
706
|
-
}
|
|
707
|
-
this.refreshTreeVisibility();
|
|
708
|
-
},
|
|
709
689
|
|
|
710
690
|
// Alternative: Just use a computed property for \`visibleNodes\`.
|
|
711
691
|
// Since Alpine isn't React, we do this manually.
|
|
@@ -733,7 +713,8 @@ const startServer = async (rootDir) => {
|
|
|
733
713
|
|
|
734
714
|
this.rawFiles.forEach(file => {
|
|
735
715
|
if (this.search && !file.path.toLowerCase().includes(searchLower)) return;
|
|
736
|
-
|
|
716
|
+
// File path đã được chuẩn hóa '/' từ backend
|
|
717
|
+
const parts = file.path.split('/');
|
|
737
718
|
let current = this.rootObject;
|
|
738
719
|
parts.forEach((part, index) => {
|
|
739
720
|
if (!current[part]) {
|
|
@@ -834,9 +815,8 @@ const startServer = async (rootDir) => {
|
|
|
834
815
|
},
|
|
835
816
|
|
|
836
817
|
getAllDescendants(folderPath) {
|
|
837
|
-
// Simple filter from rawFiles
|
|
838
818
|
return this.rawFiles
|
|
839
|
-
.filter(f => f.path
|
|
819
|
+
.filter(f => f.path === folderPath || f.path.startsWith(folderPath + '/'))
|
|
840
820
|
.map(f => f.path);
|
|
841
821
|
},
|
|
842
822
|
|
|
@@ -869,7 +849,7 @@ const startServer = async (rootDir) => {
|
|
|
869
849
|
}
|
|
870
850
|
this.generating = true;
|
|
871
851
|
try {
|
|
872
|
-
const res = await fetch('
|
|
852
|
+
const res = await fetch('/api/prompt', {
|
|
873
853
|
method: 'POST',
|
|
874
854
|
headers: {'Content-Type': 'application/json'},
|
|
875
855
|
body: JSON.stringify({ filePaths: Array.from(this.selectedFiles), instruction: this.instruction })
|
|
@@ -896,7 +876,7 @@ const startServer = async (rootDir) => {
|
|
|
896
876
|
async applyPatch() {
|
|
897
877
|
this.applying = true;
|
|
898
878
|
try {
|
|
899
|
-
const res = await fetch('
|
|
879
|
+
const res = await fetch('/api/apply', {
|
|
900
880
|
method: 'POST',
|
|
901
881
|
headers: {'Content-Type': 'application/json'},
|
|
902
882
|
body: JSON.stringify({ filePaths: Array.from(this.selectedFiles), llmResponse: this.llmResponse })
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.applyPatch = exports.parseLLMPatch = void 0;
|
|
4
|
+
const diff_match_patch_1 = require("diff-match-patch");
|
|
5
|
+
/**
|
|
6
|
+
* ------------------------------------------------------------------
|
|
7
|
+
* HELPER: STRING SIMILARITY (Levenshtein based)
|
|
8
|
+
* Tính độ giống nhau giữa 2 chuỗi (0 -> 1)
|
|
9
|
+
* ------------------------------------------------------------------
|
|
10
|
+
*/
|
|
11
|
+
const calculateSimilarity = (s1, s2) => {
|
|
12
|
+
const longer = s1.length > s2.length ? s1 : s2;
|
|
13
|
+
const shorter = s1.length > s2.length ? s2 : s1;
|
|
14
|
+
const longerLength = longer.length;
|
|
15
|
+
if (longerLength === 0) {
|
|
16
|
+
return 1.0;
|
|
17
|
+
}
|
|
18
|
+
// Sử dụng dmp để tính Levenshtein distance cho chính xác và nhanh
|
|
19
|
+
const dmp = new diff_match_patch_1.diff_match_patch();
|
|
20
|
+
const diffs = dmp.diff_main(longer, shorter);
|
|
21
|
+
const levenshtein = dmp.diff_levenshtein(diffs);
|
|
22
|
+
return (longerLength - levenshtein) / longerLength;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* ------------------------------------------------------------------
|
|
26
|
+
* HELPER: FUZZY LINE MATCHING
|
|
27
|
+
* Tìm đoạn code khớp nhất trong file gốc dựa trên điểm số (Confidence)
|
|
28
|
+
* ------------------------------------------------------------------
|
|
29
|
+
*/
|
|
30
|
+
const applyFuzzyLineMatch = (original, search, replace, threshold = 0.85 // Ngưỡng chấp nhận (85% giống nhau)
|
|
31
|
+
) => {
|
|
32
|
+
const originalLines = original.split(/\r?\n/);
|
|
33
|
+
const searchLines = search.split(/\r?\n/);
|
|
34
|
+
// Lọc bỏ dòng trống ở đầu/cuối search block để match chính xác hơn
|
|
35
|
+
while (searchLines.length > 0 && searchLines[0].trim() === "")
|
|
36
|
+
searchLines.shift();
|
|
37
|
+
while (searchLines.length > 0 && searchLines[searchLines.length - 1].trim() === "")
|
|
38
|
+
searchLines.pop();
|
|
39
|
+
if (searchLines.length === 0)
|
|
40
|
+
return { result: null, confidence: 0 };
|
|
41
|
+
let bestMatchIndex = -1;
|
|
42
|
+
let bestMatchScore = 0;
|
|
43
|
+
// Duyệt qua file gốc (Sliding Window)
|
|
44
|
+
// Chỉ cần duyệt đến vị trí mà số dòng còn lại đủ để chứa searchLines
|
|
45
|
+
for (let i = 0; i <= originalLines.length - searchLines.length; i++) {
|
|
46
|
+
let currentScoreTotal = 0;
|
|
47
|
+
let possible = true;
|
|
48
|
+
for (let j = 0; j < searchLines.length; j++) {
|
|
49
|
+
const originalLine = originalLines[i + j].trim();
|
|
50
|
+
const searchLine = searchLines[j].trim();
|
|
51
|
+
// So sánh nhanh: Nếu giống hệt nhau
|
|
52
|
+
if (originalLine === searchLine) {
|
|
53
|
+
currentScoreTotal += 1;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
// So sánh chậm: Tính độ tương đồng
|
|
57
|
+
const similarity = calculateSimilarity(originalLine, searchLine);
|
|
58
|
+
// Nếu một dòng quá khác biệt (< 60%), coi như block này không khớp
|
|
59
|
+
// (Giúp tối ưu hiệu năng, cắt nhánh sớm)
|
|
60
|
+
if (similarity < 0.6) {
|
|
61
|
+
possible = false;
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
currentScoreTotal += similarity;
|
|
65
|
+
}
|
|
66
|
+
if (possible) {
|
|
67
|
+
const avgScore = currentScoreTotal / searchLines.length;
|
|
68
|
+
if (avgScore > bestMatchScore) {
|
|
69
|
+
bestMatchScore = avgScore;
|
|
70
|
+
bestMatchIndex = i;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// LOG CONFIDENCE ĐỂ DEBUG
|
|
75
|
+
if (bestMatchScore > 0) {
|
|
76
|
+
console.log(`[FuzzyMatch] Best match score: ${(bestMatchScore * 100).toFixed(2)}% at line ${bestMatchIndex + 1}`);
|
|
77
|
+
}
|
|
78
|
+
// Nếu tìm thấy vị trí có độ tin cậy cao hơn ngưỡng
|
|
79
|
+
if (bestMatchIndex !== -1 && bestMatchScore >= threshold) {
|
|
80
|
+
const before = originalLines.slice(0, bestMatchIndex);
|
|
81
|
+
// Lưu ý: Đoạn replace thay thế đúng số lượng dòng của searchLines
|
|
82
|
+
// (Giả định search và match trong original có cùng số dòng hiển thị)
|
|
83
|
+
const after = originalLines.slice(bestMatchIndex + searchLines.length);
|
|
84
|
+
return {
|
|
85
|
+
result: [...before, replace, ...after].join("\n"),
|
|
86
|
+
confidence: bestMatchScore
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
return { result: null, confidence: bestMatchScore };
|
|
90
|
+
};
|
|
91
|
+
/**
|
|
92
|
+
* ------------------------------------------------------------------
|
|
93
|
+
* 1. STATE MACHINE PARSER (GIỮ NGUYÊN)
|
|
94
|
+
* ------------------------------------------------------------------
|
|
95
|
+
*/
|
|
96
|
+
const parseLLMPatch = (patchContent) => {
|
|
97
|
+
// ... (Code parse giữ nguyên như cũ của bạn)
|
|
98
|
+
const lines = patchContent.split(/\r?\n/);
|
|
99
|
+
const patches = [];
|
|
100
|
+
let inSearch = false;
|
|
101
|
+
let inReplace = false;
|
|
102
|
+
let searchLines = [];
|
|
103
|
+
let replaceLines = [];
|
|
104
|
+
const isSearchMarker = (line) => /^\s*<{7,}\s*SEARCH\s*$/.test(line);
|
|
105
|
+
const isDividerMarker = (line) => /^\s*={7,}\s*$/.test(line);
|
|
106
|
+
const isEndMarker = (line) => /^\s*>{7,}\s*REPLACE\s*$/.test(line);
|
|
107
|
+
for (const line of lines) {
|
|
108
|
+
if (isSearchMarker(line)) {
|
|
109
|
+
inSearch = true;
|
|
110
|
+
inReplace = false;
|
|
111
|
+
searchLines = [];
|
|
112
|
+
replaceLines = [];
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (isDividerMarker(line)) {
|
|
116
|
+
if (inSearch) {
|
|
117
|
+
inSearch = false;
|
|
118
|
+
inReplace = true;
|
|
119
|
+
}
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (isEndMarker(line)) {
|
|
123
|
+
if (inReplace || inSearch) {
|
|
124
|
+
// CẬP NHẬT: Cho phép lưu patch nếu có search HOẶC có replace
|
|
125
|
+
// (Trường hợp Search rỗng nhưng Replace có nội dung = Insert/Append)
|
|
126
|
+
if (searchLines.length > 0 || replaceLines.length > 0) {
|
|
127
|
+
patches.push({ search: searchLines.join("\n"), replace: replaceLines.join("\n") });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
inSearch = false;
|
|
131
|
+
inReplace = false;
|
|
132
|
+
searchLines = [];
|
|
133
|
+
replaceLines = [];
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (inSearch)
|
|
137
|
+
searchLines.push(line);
|
|
138
|
+
else if (inReplace)
|
|
139
|
+
replaceLines.push(line);
|
|
140
|
+
}
|
|
141
|
+
return patches;
|
|
142
|
+
};
|
|
143
|
+
exports.parseLLMPatch = parseLLMPatch;
|
|
144
|
+
/**
|
|
145
|
+
* ------------------------------------------------------------------
|
|
146
|
+
* 2. APPLY PATCH (UPDATED)
|
|
147
|
+
* ------------------------------------------------------------------
|
|
148
|
+
*/
|
|
149
|
+
const applyPatch = (originalContent, patchContent) => {
|
|
150
|
+
let patches;
|
|
151
|
+
if (typeof patchContent === 'string') {
|
|
152
|
+
if (!patchContent || !patchContent.includes("<<<<<<< SEARCH")) {
|
|
153
|
+
return patchContent || originalContent;
|
|
154
|
+
}
|
|
155
|
+
patches = (0, exports.parseLLMPatch)(patchContent);
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
patches = patchContent;
|
|
159
|
+
}
|
|
160
|
+
if (!patches || patches.length === 0)
|
|
161
|
+
return originalContent;
|
|
162
|
+
let result = originalContent;
|
|
163
|
+
for (const patch of patches) {
|
|
164
|
+
const { search, replace } = patch;
|
|
165
|
+
// Strategy 0: Full File Replacement (Overwrite)
|
|
166
|
+
if (search === "__FULL_FILE_REPLACEMENT__") {
|
|
167
|
+
console.log(`[ApplyPatch] Overwriting file with full content.`);
|
|
168
|
+
result = replace;
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
// Nếu search và replace giống hệt nhau, bỏ qua
|
|
172
|
+
if (search === replace) {
|
|
173
|
+
console.log(`[ApplyPatch] Search and replace are identical. Skipping.`);
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
// NẾU SEARCH RỖNG (INSERT/APPEND hoặc TẠO FILE MỚI)
|
|
177
|
+
if (search.trim() === "") {
|
|
178
|
+
console.log(`[ApplyPatch] Inserting/appending block.`);
|
|
179
|
+
if (result.trim() === "") {
|
|
180
|
+
// Nếu file hiện tại rỗng hoặc chỉ có khoảng trắng, gán luôn bằng nội dung replace (Hỗ trợ tạo file mới)
|
|
181
|
+
result = replace;
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
// Nếu file đã có nội dung, append vào cuối. Đảm bảo có ít nhất một dấu xuống dòng phân tách.
|
|
185
|
+
const separator = result.endsWith("\n") ? "" : "\n";
|
|
186
|
+
result += separator + replace;
|
|
187
|
+
}
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
// --- STRATEGY 1: EXACT MATCH ---
|
|
191
|
+
if (result.includes(search)) {
|
|
192
|
+
console.log(`[ApplyPatch] Exact match found.`);
|
|
193
|
+
result = result.replace(search, replace);
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
// --- STRATEGY 2: NORMALIZED MATCH (Trim) ---
|
|
197
|
+
const trimmedSearch = search.trim();
|
|
198
|
+
if (result.includes(trimmedSearch)) {
|
|
199
|
+
console.log(`[ApplyPatch] Trimmed match found.`);
|
|
200
|
+
result = result.replace(trimmedSearch, replace.trim());
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
// --- STRATEGY 3: FUZZY LINE MATCH (BEST FOR CODE) ---
|
|
204
|
+
// Tìm vị trí tốt nhất dựa trên similarity score của từng dòng
|
|
205
|
+
const fuzzyMatch = applyFuzzyLineMatch(result, search, replace, 0.80); // 80% confidence
|
|
206
|
+
if (fuzzyMatch.result !== null) {
|
|
207
|
+
console.log(`[ApplyPatch] Fuzzy line match applied (Confidence: ${(fuzzyMatch.confidence * 100).toFixed(0)}%)`);
|
|
208
|
+
result = fuzzyMatch.result;
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
else {
|
|
212
|
+
console.warn(`[ApplyPatch] Failed to match block. Max confidence was: ${(fuzzyMatch.confidence * 100).toFixed(0)}%`);
|
|
213
|
+
}
|
|
214
|
+
// --- STRATEGY 4: DMP FALLBACK (LAST RESORT) ---
|
|
215
|
+
// 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)
|
|
216
|
+
// thì mới dùng DMP patch_apply.
|
|
217
|
+
try {
|
|
218
|
+
const dmp = new diff_match_patch_1.diff_match_patch();
|
|
219
|
+
dmp.Match_Threshold = 0.5;
|
|
220
|
+
dmp.Match_Distance = 1000;
|
|
221
|
+
const dmpPatches = dmp.patch_make(search, replace);
|
|
222
|
+
const [newText, applyResults] = dmp.patch_apply(dmpPatches, result);
|
|
223
|
+
// Kiểm tra xem có apply được hết các hunk không
|
|
224
|
+
const successCount = applyResults.filter(Boolean).length;
|
|
225
|
+
const successRate = successCount / applyResults.length;
|
|
226
|
+
if (successRate === 1 && newText !== result) {
|
|
227
|
+
console.log(`[ApplyPatch] DMP Patch applied via fuzzy logic.`);
|
|
228
|
+
result = newText;
|
|
229
|
+
}
|
|
230
|
+
else {
|
|
231
|
+
console.error(`[ApplyPatch] All strategies failed for block starting with: ${search.substring(0, 30)}...`);
|
|
232
|
+
console.log(`[ApplyPatch DATA]`, { search, replace, result });
|
|
233
|
+
// Tuỳ chọn: Throw error để báo cho LLM biết là apply thất bại
|
|
234
|
+
// throw new Error("Could not apply patch block");
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
catch (e) {
|
|
238
|
+
console.error(`[ApplyPatch] DMP error:`, e);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return result;
|
|
242
|
+
};
|
|
243
|
+
exports.applyPatch = applyPatch;
|