mirror of
https://github.com/enricoros/big-AGI.git
synced 2026-05-10 21:50:14 -07:00
Aider glue.
This commit is contained in:
+484
@@ -0,0 +1,484 @@
|
||||
/*
|
||||
* This file includes code derived from Aider (https://github.com/paul-gauthier/aider)
|
||||
* Originally licensed under the Apache License, Version 2.0
|
||||
* Modifications and translations to JavaScript made by Enrico Ros
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
interface Edit {
|
||||
path: string;
|
||||
original: string;
|
||||
updated: string;
|
||||
}
|
||||
|
||||
class EditBlockCoder {
|
||||
private partialResponseContent: string;
|
||||
private fence: [string, string];
|
||||
private filenames: string[]; // List of valid filenames
|
||||
private io: IO;
|
||||
|
||||
constructor(
|
||||
partialResponseContent: string,
|
||||
fence: [string, string],
|
||||
filenames: string[], // Pass in your list of filenames
|
||||
io: IO,
|
||||
) {
|
||||
this.partialResponseContent = partialResponseContent;
|
||||
this.fence = fence;
|
||||
this.filenames = filenames;
|
||||
this.io = io;
|
||||
}
|
||||
|
||||
/**
|
||||
* Identifies all edits from the content.
|
||||
*/
|
||||
public getEdits(): Edit[] {
|
||||
const content = this.partialResponseContent;
|
||||
// Extract edits from the content
|
||||
const edits = findOriginalUpdateBlocks(
|
||||
content,
|
||||
this.fence,
|
||||
this.filenames,
|
||||
);
|
||||
|
||||
return edits;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies all edits to the respective files.
|
||||
*/
|
||||
public applyEdits(edits: Edit[]): void {
|
||||
const failed: Edit[] = [];
|
||||
const passed: Edit[] = [];
|
||||
|
||||
for (const edit of edits) {
|
||||
const { path: relativePath, original, updated } = edit;
|
||||
let fullPath = this.absRootPath(relativePath);
|
||||
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
console.warn(`File ${relativePath} does not exist. Skipping edit.`);
|
||||
failed.push(edit);
|
||||
continue;
|
||||
}
|
||||
|
||||
let content = this.io.readText(fullPath);
|
||||
let newContent = doReplace(fullPath, content, original, updated, this.fence);
|
||||
|
||||
if (newContent) {
|
||||
this.io.writeText(fullPath, newContent);
|
||||
passed.push(edit);
|
||||
} else {
|
||||
failed.push(edit);
|
||||
}
|
||||
}
|
||||
|
||||
if (failed.length > 0) {
|
||||
this.handleFailedEdits(failed, passed);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles edits that failed to apply.
|
||||
*/
|
||||
private handleFailedEdits(failed: Edit[], passed: Edit[]): void {
|
||||
const blocks = failed.length === 1 ? 'block' : 'blocks';
|
||||
let message = `# ${failed.length} SEARCH/REPLACE ${blocks} failed to match!\n`;
|
||||
|
||||
for (const edit of failed) {
|
||||
const { path, original, updated } = edit;
|
||||
|
||||
const fullPath = this.absRootPath(path);
|
||||
const content = this.io.readText(fullPath);
|
||||
|
||||
message += `
|
||||
## SearchReplaceNoExactMatch: This SEARCH block failed to exactly match lines in ${path}
|
||||
<<<<<<< SEARCH
|
||||
${original}=======
|
||||
${updated}>>>>>>> REPLACE
|
||||
|
||||
`;
|
||||
const suggestion = findSimilarLines(original, content);
|
||||
if (suggestion) {
|
||||
message += `Did you mean to match some of these actual lines from ${path}?
|
||||
|
||||
${this.fence[0]}
|
||||
${suggestion}
|
||||
${this.fence[1]}
|
||||
|
||||
`;
|
||||
}
|
||||
|
||||
if (updated && content.includes(updated)) {
|
||||
message += `Are you sure you need this SEARCH/REPLACE block?
|
||||
The REPLACE lines are already in ${path}!
|
||||
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
message += `The SEARCH section must exactly match an existing block of lines including all whitespace, comments, indentation, docstrings, etc.\n`;
|
||||
|
||||
if (passed.length > 0) {
|
||||
const pblocks = passed.length === 1 ? 'block' : 'blocks';
|
||||
message += `
|
||||
# The other ${passed.length} SEARCH/REPLACE ${pblocks} were applied successfully.
|
||||
Don't re-send them.
|
||||
Just reply with fixed versions of the ${blocks} above that failed to match.
|
||||
`;
|
||||
}
|
||||
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a relative path to an absolute path.
|
||||
*/
|
||||
private absRootPath(relPath: string): string {
|
||||
// Replace with your application's root directory logic if needed
|
||||
return path.resolve(process.cwd(), relPath);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper classes and functions
|
||||
|
||||
/**
|
||||
* IO class for reading and writing files.
|
||||
*/
|
||||
class IO {
|
||||
public readText(filePath: string): string {
|
||||
return fs.readFileSync(filePath, 'utf8');
|
||||
}
|
||||
|
||||
public writeText(filePath: string, content: string): void {
|
||||
fs.writeFileSync(filePath, content, 'utf8');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the content to find original and updated blocks.
|
||||
*/
|
||||
function findOriginalUpdateBlocks(
|
||||
content: string,
|
||||
fence: [string, string],
|
||||
validFnames: string[] = [],
|
||||
): Edit[] {
|
||||
const edits: Edit[] = [];
|
||||
const lines = content.split('\n');
|
||||
let i = 0;
|
||||
let currentFilename: string | null = null;
|
||||
|
||||
const headPattern = /^<{5,9} SEARCH/;
|
||||
const dividerPattern = /^={5,9}$/;
|
||||
const updatedPattern = /^>{5,9} REPLACE/;
|
||||
|
||||
while (i < lines.length) {
|
||||
let line = lines[i];
|
||||
|
||||
// Handle SEARCH/REPLACE blocks
|
||||
if (headPattern.test(line.trim())) {
|
||||
let filename = findFilename(lines.slice(Math.max(0, i - 3), i), fence, validFnames);
|
||||
filename = filename || currentFilename;
|
||||
if (!filename) {
|
||||
throw new Error(`Bad/missing filename before the fence ${fence[0]}`);
|
||||
}
|
||||
currentFilename = filename;
|
||||
|
||||
const originalText: string[] = [];
|
||||
i += 1;
|
||||
while (i < lines.length && !dividerPattern.test(lines[i].trim())) {
|
||||
originalText.push(lines[i]);
|
||||
i += 1;
|
||||
}
|
||||
if (i >= lines.length || !dividerPattern.test(lines[i].trim())) {
|
||||
throw new Error(`Expected '======='`);
|
||||
}
|
||||
|
||||
const updatedText: string[] = [];
|
||||
i += 1;
|
||||
while (i < lines.length && !updatedPattern.test(lines[i].trim()) && !dividerPattern.test(lines[i].trim())) {
|
||||
updatedText.push(lines[i]);
|
||||
i += 1;
|
||||
}
|
||||
if (i >= lines.length || (!updatedPattern.test(lines[i].trim()) && !dividerPattern.test(lines[i].trim()))) {
|
||||
throw new Error(`Expected '>>>>>>> REPLACE' or '======='`);
|
||||
}
|
||||
|
||||
edits.push({
|
||||
path: filename,
|
||||
original: originalText.join('\n'),
|
||||
updated: updatedText.join('\n'),
|
||||
});
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
return edits;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to find the filename from previous lines.
|
||||
*/
|
||||
function findFilename(
|
||||
lines: string[],
|
||||
fence: [string, string],
|
||||
validFnames: string[],
|
||||
): string | null {
|
||||
const reversedLines = [...lines].reverse();
|
||||
const filenames: string[] = [];
|
||||
|
||||
for (const line of reversedLines.slice(0, 3)) {
|
||||
const filename = stripFilename(line, fence);
|
||||
if (filename) filenames.push(filename);
|
||||
if (!line.startsWith(fence[0])) break;
|
||||
}
|
||||
|
||||
if (filenames.length === 0) return null;
|
||||
|
||||
// Check for exact match
|
||||
for (const fname of filenames) {
|
||||
if (validFnames.includes(fname)) return fname;
|
||||
}
|
||||
|
||||
// Check for basename match
|
||||
for (const fname of filenames) {
|
||||
for (const vfname of validFnames) {
|
||||
if (fname === path.basename(vfname)) return vfname;
|
||||
}
|
||||
}
|
||||
|
||||
// Return the first filename with an extension
|
||||
return filenames.find(fname => fname.includes('.')) || filenames[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips wrapping characters from the filename.
|
||||
*/
|
||||
function stripFilename(filename: string, fence: [string, string]): string | null {
|
||||
filename = filename.trim();
|
||||
|
||||
if (filename === '...') return null;
|
||||
|
||||
if (filename.startsWith(fence[0])) return null;
|
||||
|
||||
filename = filename.replace(/[:#`*]/g, '').trim();
|
||||
return filename || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs the replacement in the file content.
|
||||
*/
|
||||
function doReplace(
|
||||
fname: string,
|
||||
content: string,
|
||||
beforeText: string,
|
||||
afterText: string,
|
||||
fence: [string, string],
|
||||
): string | null {
|
||||
beforeText = stripQuotedWrapping(beforeText, fname, fence);
|
||||
afterText = stripQuotedWrapping(afterText, fname, fence);
|
||||
|
||||
// Handle new file creation
|
||||
if (!fs.existsSync(fname) && !beforeText.trim()) {
|
||||
fs.writeFileSync(fname, '', 'utf8');
|
||||
content = '';
|
||||
}
|
||||
|
||||
if (!content) return null;
|
||||
|
||||
if (!beforeText.trim()) {
|
||||
// Append to existing file
|
||||
return content + afterText;
|
||||
} else {
|
||||
const newContent = replaceMostSimilarChunk(content, beforeText, afterText);
|
||||
return newContent || null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips quoted wrapping from the text.
|
||||
*/
|
||||
function stripQuotedWrapping(
|
||||
text: string,
|
||||
fname: string | undefined,
|
||||
fence: [string, string],
|
||||
): string {
|
||||
if (!text) return text;
|
||||
|
||||
let lines = text.split('\n');
|
||||
|
||||
if (fname && lines[0].trim().endsWith(path.basename(fname))) {
|
||||
lines.shift();
|
||||
}
|
||||
|
||||
if (lines[0].startsWith(fence[0]) && lines[lines.length - 1].startsWith(fence[1])) {
|
||||
lines = lines.slice(1, -1);
|
||||
}
|
||||
|
||||
let result = lines.join('\n');
|
||||
if (result && !result.endsWith('\n')) result += '\n';
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to replace the most similar chunk in the content.
|
||||
*/
|
||||
function replaceMostSimilarChunk(
|
||||
whole: string,
|
||||
part: string,
|
||||
replace: string,
|
||||
): string | null {
|
||||
const wholeLines = whole.endsWith('\n') ? whole : whole + '\n';
|
||||
const partLines = part.endsWith('\n') ? part : part + '\n';
|
||||
const replaceLines = replace.endsWith('\n') ? replace : replace + '\n';
|
||||
|
||||
const wholeArray = wholeLines.split('\n');
|
||||
const partArray = partLines.split('\n');
|
||||
const replaceArray = replaceLines.split('\n');
|
||||
|
||||
// Try for an exact match
|
||||
const result = perfectReplace(wholeArray, partArray, replaceArray);
|
||||
|
||||
if (result) return result.join('\n');
|
||||
|
||||
// Try matching while ignoring leading whitespace
|
||||
const whitespaceResult = replaceIgnoringLeadingWhitespace(wholeArray, partArray, replaceArray);
|
||||
|
||||
if (whitespaceResult) return whitespaceResult.join('\n');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs a perfect replacement if an exact match is found.
|
||||
*/
|
||||
function perfectReplace(
|
||||
whole: string[],
|
||||
part: string[],
|
||||
replace: string[],
|
||||
): string[] | null {
|
||||
const partStr = part.join('\n');
|
||||
for (let i = 0; i <= whole.length - part.length; i++) {
|
||||
const slice = whole.slice(i, i + part.length).join('\n');
|
||||
if (slice === partStr) {
|
||||
return [...whole.slice(0, i), ...replace, ...whole.slice(i + part.length)];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces content while ignoring leading whitespace differences.
|
||||
*/
|
||||
function replaceIgnoringLeadingWhitespace(
|
||||
whole: string[],
|
||||
part: string[],
|
||||
replace: string[],
|
||||
): string[] | null {
|
||||
for (let i = 0; i <= whole.length - part.length; i++) {
|
||||
const slice = whole.slice(i, i + part.length);
|
||||
const match = slice.every((line, idx) => line.trimStart() === part[idx].trimStart());
|
||||
if (match) {
|
||||
// Adjust leading whitespace based on the existing content
|
||||
const leadingWhitespace = slice[0].match(/^\s*/)?.[0] || '';
|
||||
const adjustedReplace = replace.map(line => leadingWhitespace + line.trimStart());
|
||||
return [...whole.slice(0, i), ...adjustedReplace, ...whole.slice(i + part.length)];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds similar lines in the content to provide suggestions.
|
||||
*/
|
||||
function findSimilarLines(
|
||||
searchLines: string,
|
||||
contentLines: string,
|
||||
threshold: number = 0.6,
|
||||
): string {
|
||||
const searchArray = searchLines.split('\n');
|
||||
const contentArray = contentLines.split('\n');
|
||||
|
||||
let bestRatio = 0;
|
||||
let bestMatch: string[] | null = null;
|
||||
let bestMatchIndex = -1;
|
||||
|
||||
for (let i = 0; i <= contentArray.length - searchArray.length; i++) {
|
||||
const chunk = contentArray.slice(i, i + searchArray.length);
|
||||
const ratio = stringSimilarity(searchArray.join('\n'), chunk.join('\n'));
|
||||
if (ratio > bestRatio) {
|
||||
bestRatio = ratio;
|
||||
bestMatch = chunk;
|
||||
bestMatchIndex = i;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestRatio < threshold || !bestMatch) return '';
|
||||
|
||||
// Show context around the best match
|
||||
const N = 5;
|
||||
const start = Math.max(0, bestMatchIndex - N);
|
||||
const end = Math.min(contentArray.length, bestMatchIndex + searchArray.length + N);
|
||||
|
||||
return contentArray.slice(start, end).join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the similarity between two strings.
|
||||
*/
|
||||
function stringSimilarity(str1: string, str2: string): number {
|
||||
let longer = str1;
|
||||
let shorter = str2;
|
||||
if (str1.length < str2.length) {
|
||||
longer = str2;
|
||||
shorter = str1;
|
||||
}
|
||||
const longerLength = longer.length;
|
||||
if (longerLength === 0) {
|
||||
return 1.0;
|
||||
}
|
||||
return (longerLength - editDistance(longer, shorter)) / longerLength;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the edit distance between two strings.
|
||||
*/
|
||||
function editDistance(s1: string, s2: string): number {
|
||||
s1 = s1.toLowerCase();
|
||||
s2 = s2.toLowerCase();
|
||||
|
||||
const costs: number[] = [];
|
||||
for (let i = 0; i <= s1.length; i++) {
|
||||
let lastValue = i;
|
||||
for (let j = 0; j <= s2.length; j++) {
|
||||
if (i === 0)
|
||||
costs[j] = j;
|
||||
else {
|
||||
if (j > 0) {
|
||||
let newValue = costs[j - 1];
|
||||
if (s1.charAt(i - 1) !== s2.charAt(j - 1))
|
||||
newValue = Math.min(Math.min(newValue, lastValue), costs[j]) + 1;
|
||||
costs[j - 1] = lastValue;
|
||||
lastValue = newValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (i > 0)
|
||||
costs[s2.length] = lastValue;
|
||||
}
|
||||
return costs[s2.length];
|
||||
}
|
||||
+6
-1
@@ -76,10 +76,15 @@ Examples of when to suggest shell commands:
|
||||
}, 'shellCmdPrompt');
|
||||
}
|
||||
|
||||
export const noShellCmdPrompt = `
|
||||
export function getNoShellCmdPrompt(platform: string) {
|
||||
const template = `
|
||||
Keep in mind these details about the user's platform and environment:
|
||||
{{platform}}
|
||||
`;
|
||||
return processPromptTemplate(template, {
|
||||
platform,
|
||||
}, 'noShellCmdPrompt');
|
||||
}
|
||||
|
||||
export const exampleMessages = [
|
||||
{
|
||||
|
||||
Vendored
+78
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* This file includes code derived from Aider (https://github.com/paul-gauthier/aider)
|
||||
* Originally licensed under the Apache License, Version 2.0
|
||||
* Modifications and translations to JavaScript made by Enrico Ros
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { aiderCoderPrompts } from './coderPrompts';
|
||||
import { exampleMessages, getMainSystemPrompt, getNoShellCmdPrompt, getSystemReminder } from './editBlockPrompts';
|
||||
|
||||
type ChatMessage = {
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a EditBlock-style prompt.
|
||||
*
|
||||
* @param userMessage The request from the user.
|
||||
* @param platformMessage example: 'The user will apply the edits automatically without further review.'
|
||||
* @param isModelLazy False for powerful models.
|
||||
* @param useSystemPrompt True to put the main system prompt as a system message.
|
||||
*/
|
||||
export function getEditBlockDiffPrompt(userMessage: string, platformMessage: string, isModelLazy: boolean, useSystemPrompt: boolean): ChatMessage[] {
|
||||
const history: ChatMessage[] = [];
|
||||
|
||||
// 1. main system prompt
|
||||
const lazyPrompt = isModelLazy ? aiderCoderPrompts.lazyPrompt : '';
|
||||
const noShellPrompt = getNoShellCmdPrompt(platformMessage);
|
||||
let mainSysPrompt = getMainSystemPrompt(lazyPrompt, noShellPrompt);
|
||||
if (exampleMessages.length > 0) {
|
||||
mainSysPrompt += '\n# Example conversations:\n\n';
|
||||
for (const example of exampleMessages)
|
||||
mainSysPrompt += `## ${example.role.toUpperCase()}: ${example.content}\n\n`;
|
||||
}
|
||||
// mainSysPrompt = mainSysPrompt.trim();
|
||||
const mainSysReminder = getSystemReminder(lazyPrompt, '');
|
||||
mainSysPrompt += '\n' + mainSysReminder;
|
||||
|
||||
if (useSystemPrompt)
|
||||
history.push({
|
||||
role: 'system', text: mainSysPrompt,
|
||||
});
|
||||
else
|
||||
history.push({
|
||||
role: 'user', text: mainSysPrompt,
|
||||
}, {
|
||||
role: 'assistant', text: 'Ok.',
|
||||
});
|
||||
|
||||
// [...] examples, readonly_files, repo, done, **chat_files**, **cur**, **reminder**
|
||||
|
||||
// 6. chat_files
|
||||
history.push({
|
||||
role: 'user', text: aiderCoderPrompts.filesNoFullFilesWithRepoMap,
|
||||
}, {
|
||||
role: 'assistant', text: aiderCoderPrompts.filesNoFullFilesWithRepoMapReply,
|
||||
});
|
||||
|
||||
// 7. cur + reminder
|
||||
userMessage = userMessage + '\n' + getSystemReminder(lazyPrompt, '');
|
||||
history.push({
|
||||
role: 'user', text: userMessage,
|
||||
});
|
||||
|
||||
return history;
|
||||
}
|
||||
Reference in New Issue
Block a user