Explorar o código

WIP: Phase 2A

Yuki Takei hai 9 meses
pai
achega
ada6ce8bdc

+ 2 - 2
apps/app/src/features/openai/client/services/editor-assistant/diff-application.ts

@@ -92,7 +92,7 @@ export class ClientDiffApplicationEngine {
         searchContext,
       );
 
-      if (!matchResult.found) {
+      if (!matchResult.success) {
         return {
           success: false,
           error: this.errorHandler.createSearchNotFoundError(
@@ -106,7 +106,7 @@ export class ClientDiffApplicationEngine {
       // Apply the replacement with indentation preservation
       const replacementResult = this.applyReplacement(
         lines,
-        matchResult,
+        { index: matchResult.index || 0, content: matchResult.content || '' },
         diff.replace,
       );
 

+ 4 - 4
apps/app/src/features/openai/client/services/editor-assistant/error-handling.ts

@@ -84,8 +84,8 @@ export class ClientErrorHandler {
       startLine?: number,
   ): DiffError {
     const lineRange = startLine ? ` (starting at line ${startLine})` : '';
-    const similarityInfo = matchResult?.score
-      ? ` (closest match: ${Math.floor(matchResult.score * 100)}%)`
+    const similarityInfo = matchResult?.similarity
+      ? ` (closest match: ${Math.floor(matchResult.similarity * 100)}%)`
       : '';
 
     const error: DiffError = {
@@ -95,7 +95,7 @@ export class ClientErrorHandler {
       details: {
         searchContent,
         bestMatch: matchResult?.content,
-        similarity: matchResult?.score,
+        similarity: matchResult?.similarity,
         suggestions: [...CLIENT_SUGGESTIONS.SEARCH_NOT_FOUND],
         lineRange: startLine ? `starting at line ${startLine}` : 'entire document',
       },
@@ -145,7 +145,7 @@ export class ClientErrorHandler {
   ): DiffError {
     const matchInfo = matches
       .slice(0, 3) // Show only first 3 matches
-      .map((match, index) => `Match ${index + 1}: line ${match.index + 1} (${Math.floor(match.score * 100)}%)`)
+      .map((match, index) => `Match ${index + 1}: line ${match.index ? match.index + 1 : 'unknown'} (${Math.floor((match.similarity || 0) * 100)}%)`)
       .join(', ');
 
     const error: DiffError = {

+ 142 - 6
apps/app/src/features/openai/client/services/editor-assistant/fuzzy-matching.ts

@@ -67,6 +67,133 @@ export class ClientFuzzyMatcher {
     this.maxSearchTime = maxSearchTimeMs;
   }
 
+  /**
+   * Try exact line match at the specified line
+   */
+  tryExactLineMatch(
+      content: string,
+      searchText: string,
+      startLine: number,
+  ): MatchResult {
+    const lines = content.split('\n');
+
+    if (startLine <= 0 || startLine > lines.length) {
+      return { success: false, similarity: 0, error: 'Invalid line number' };
+    }
+
+    // Get line range for multi-line search
+    const searchLines = searchText.split('\n');
+    const endLine = Math.min(startLine + searchLines.length - 1, lines.length);
+
+    if (endLine - startLine + 1 !== searchLines.length) {
+      return { success: false, similarity: 0, error: 'Not enough lines for search' };
+    }
+
+    // Extract content from specified lines
+    const targetContent = lines.slice(startLine - 1, endLine).join('\n');
+
+    // Check for exact match first
+    if (targetContent === searchText) {
+      const startIndex = lines.slice(0, startLine - 1).join('\n').length + (startLine > 1 ? 1 : 0);
+      const endIndex = startIndex + searchText.length;
+
+      return {
+        success: true,
+        similarity: 1.0,
+        matchedRange: {
+          startIndex,
+          endIndex,
+          startLine,
+          endLine,
+        },
+      };
+    }
+
+    // Check fuzzy match
+    const similarity = calculateSimilarity(targetContent, searchText);
+    if (similarity >= this.threshold) {
+      const startIndex = lines.slice(0, startLine - 1).join('\n').length + (startLine > 1 ? 1 : 0);
+      const endIndex = startIndex + targetContent.length;
+
+      return {
+        success: true,
+        similarity,
+        matchedRange: {
+          startIndex,
+          endIndex,
+          startLine,
+          endLine,
+        },
+      };
+    }
+
+    return { success: false, similarity, error: 'Similarity below threshold' };
+  }
+
+  /**
+   * Perform buffered search around the preferred line
+   */
+  performBufferedSearch(
+      content: string,
+      searchText: string,
+      preferredStartLine: number,
+      bufferLines = 40,
+  ): MatchResult {
+    const lines = content.split('\n');
+    const searchLines = searchText.split('\n');
+
+    // Calculate search bounds
+    const startBound = Math.max(1, preferredStartLine - bufferLines);
+    const endBound = Math.min(lines.length, preferredStartLine + bufferLines);
+
+    let bestMatch: MatchResult = { success: false, similarity: 0, error: 'No match found' };
+
+    // Search within the buffer area
+    for (let currentLine = startBound; currentLine <= endBound - searchLines.length + 1; currentLine++) {
+      const match = this.tryExactLineMatch(content, searchText, currentLine);
+
+      if (match.success && match.similarity > bestMatch.similarity) {
+        bestMatch = match;
+
+        // Early exit for exact matches
+        if (match.similarity === 1.0) {
+          break;
+        }
+      }
+    }
+
+    return bestMatch;
+  }
+
+  /**
+   * Perform full search across entire content
+   */
+  performFullSearch(
+      content: string,
+      searchText: string,
+  ): MatchResult {
+    const lines = content.split('\n');
+    const searchLines = searchText.split('\n');
+
+    let bestMatch: MatchResult = { success: false, similarity: 0, error: 'No match found' };
+
+    // Search entire content
+    for (let currentLine = 1; currentLine <= lines.length - searchLines.length + 1; currentLine++) {
+      const match = this.tryExactLineMatch(content, searchText, currentLine);
+
+      if (match.success && match.similarity > bestMatch.similarity) {
+        bestMatch = match;
+
+        // Early exit for exact matches
+        if (match.similarity === 1.0) {
+          break;
+        }
+      }
+    }
+
+    return bestMatch;
+  }
+
   /**
    * Find the best fuzzy match using middle-out search strategy
    * Optimized for browser environment with timeout protection
@@ -91,6 +218,17 @@ export class ClientFuzzyMatcher {
       return this.createNoMatchResult('Invalid search content');
     }
 
+    // 指定行から優先検索
+    if (context.preferredStartLine) {
+      const exactMatch = this.tryExactLineMatch(content, searchText, context.preferredStartLine);
+      if (exactMatch.success) {
+        return exactMatch;
+      }
+
+      // 指定行周辺でfuzzy検索
+      return this.performBufferedSearch(content, searchText, context.preferredStartLine, context.bufferLines || 40);
+    }
+
     // Calculate search bounds with buffer
     const bounds = this.calculateSearchBounds(lines.length, context);
 
@@ -174,11 +312,10 @@ export class ClientFuzzyMatcher {
     }
 
     return {
-      found: bestScore >= this.threshold,
-      score: bestScore,
+      success: bestScore >= this.threshold,
+      similarity: bestScore,
       index: bestMatchIndex,
       content: bestMatchContent,
-      threshold: this.threshold,
       searchTime: performance.now() - startTime,
     };
   }
@@ -237,11 +374,10 @@ export class ClientFuzzyMatcher {
    */
   private createNoMatchResult(reason = 'No match found'): MatchResult {
     return {
-      found: false,
-      score: 0,
+      success: false,
+      similarity: 0,
       index: -1,
       content: '',
-      threshold: this.threshold,
       searchTime: 0,
       error: reason,
     };

+ 101 - 0
apps/app/src/features/openai/client/services/editor-assistant/search-replace-engine.ts

@@ -0,0 +1,101 @@
+
+import { type Text as YText } from 'yjs';
+
+import { ClientFuzzyMatcher } from './fuzzy-matching';
+import { normalizeForBrowserFuzzyMatch } from './text-normalization';
+
+/**
+ * Perform search and replace operation on YText with fuzzy matching
+ */
+export function performSearchReplace(
+    yText: YText,
+    searchText: string,
+    replaceText: string,
+    startLine: number,
+): boolean {
+  const content = yText.toString();
+  const lines = content.split('\n');
+
+  // 1. 指定行から検索開始
+  const fuzzyMatcher = new ClientFuzzyMatcher(0.8);
+  const result = fuzzyMatcher.findBestMatch(
+    content,
+    searchText,
+    {
+      preferredStartLine: startLine,
+      bufferLines: 20, // 前後20行の範囲で検索
+    },
+  );
+
+  if (result.success && result.matchedRange) {
+    // 2. 見つかった箇所を正確に置換
+    const { startIndex, endIndex } = result.matchedRange;
+    yText.delete(startIndex, endIndex - startIndex);
+    yText.insert(startIndex, replaceText);
+    return true;
+  }
+
+  return false; // 検索失敗
+}
+
+/**
+ * Exact search without fuzzy matching for testing purposes
+ */
+export function performExactSearchReplace(
+    yText: YText,
+    searchText: string,
+    replaceText: string,
+    startLine?: number,
+): boolean {
+  const content = yText.toString();
+  const lines = content.split('\n');
+
+  // If startLine is specified, search from that line
+  let searchStartIndex = 0;
+  if (startLine != null && startLine > 0 && startLine <= lines.length) {
+    // Calculate starting position for the specified line (1-based)
+    for (let i = 0; i < startLine - 1; i++) {
+      searchStartIndex += lines[i].length + 1; // +1 for newline
+    }
+  }
+
+  // Find the search text
+  const searchIndex = content.indexOf(searchText, searchStartIndex);
+
+  if (searchIndex !== -1) {
+    // Replace the found text
+    yText.delete(searchIndex, searchText.length);
+    yText.insert(searchIndex, replaceText);
+    return true;
+  }
+
+  return false;
+}
+
+/**
+ * Helper function to get line information from content
+ */
+export function getLineFromIndex(content: string, index: number): { lineNumber: number, columnNumber: number } {
+  const lines = content.substring(0, index).split('\n');
+  const lineNumber = lines.length;
+  const columnNumber = lines[lines.length - 1].length;
+
+  return { lineNumber, columnNumber };
+}
+
+/**
+ * Helper function to get content around a specific line for debugging
+ */
+export function getContextAroundLine(content: string, lineNumber: number, contextLines = 3): string {
+  const lines = content.split('\n');
+  const startLine = Math.max(0, lineNumber - contextLines - 1);
+  const endLine = Math.min(lines.length, lineNumber + contextLines);
+
+  return lines.slice(startLine, endLine)
+    .map((line, index) => {
+      const actualLineNumber = startLine + index + 1;
+      const marker = actualLineNumber === lineNumber ? '→' : ' ';
+      return `${marker} ${actualLineNumber.toString().padStart(3)}: ${line}`;
+    })
+    .join('\n');
+}

+ 14 - 39
apps/app/src/features/openai/client/services/editor-assistant/use-editor-assistant.tsx

@@ -34,6 +34,8 @@ import { QuickMenuList } from '../../components/AiAssistant/AiAssistantSidebar/Q
 import { useAiAssistantSidebar } from '../../stores/ai-assistant';
 import { useClientEngineIntegration, shouldUseClientProcessing } from '../client-engine-integration';
 
+import { performSearchReplace } from './search-replace-engine';
+
 interface CreateThread {
   (): Promise<IThreadRelationHasId>;
 }
@@ -108,34 +110,6 @@ const appendTextLastLine = (yText: YText, textToAppend: string) => {
   yText.insert(insertPosition, `\n\n${textToAppend}`);
 };
 
-const getLineInfo = (yText: YText, lineNumber: number): { text: string, startIndex: number } | null => {
-  // Get the entire text content
-  const content = yText.toString();
-
-  // Split by newlines to get all lines
-  const lines = content.split('\n');
-
-  // Check if the requested line exists
-  if (lineNumber < 0 || lineNumber >= lines.length) {
-    return null; // Line doesn't exist
-  }
-
-  // Get the text of the specified line
-  const text = lines[lineNumber];
-
-  // Calculate the start index of the line
-  let startIndex = 0;
-  for (let i = 0; i < lineNumber; i++) {
-    startIndex += lines[i].length + 1; // +1 for the newline character
-  }
-
-  // Return comprehensive line information
-  return {
-    text,
-    startIndex,
-  };
-};
-
 export const useEditorAssistant: UseEditorAssistant = () => {
   // Refs
   const lineRef = useRef<number>(0);
@@ -314,20 +288,21 @@ export const useEditorAssistant: UseEditorAssistant = () => {
       const yText = yDocs.secondaryDoc.getText('codemirror');
       yDocs.secondaryDoc.transact(() => {
         pendingDetectedDiff.forEach((detectedDiff) => {
-          // Note: isReplaceDiff was removed, using basic check instead
           if (detectedDiff.data.diff) {
+            const { search, replace, startLine } = detectedDiff.data.diff;
 
-            if (isTextSelected) {
-              const lineInfo = getLineInfo(yText, lineRef.current);
-              if (lineInfo != null && lineInfo.text !== detectedDiff.data.diff.replace) {
-                yText.delete(lineInfo.startIndex, lineInfo.text.length);
-                insertTextAtLine(yText, lineRef.current, detectedDiff.data.diff.replace);
-              }
+            // 新しい検索・置換処理
+            const success = performSearchReplace(yText, search, replace, startLine);
 
-              lineRef.current += 1;
-            }
-            else {
-              appendTextLastLine(yText, detectedDiff.data.diff.replace);
+            if (!success) {
+              // フォールバック: 既存の動作
+              if (isTextSelected) {
+                insertTextAtLine(yText, lineRef.current, replace);
+                lineRef.current += 1;
+              }
+              else {
+                appendTextLastLine(yText, replace);
+              }
             }
           }
         });

+ 1 - 2
apps/app/src/features/openai/interfaces/editor-assistant/llm-response-schemas.ts

@@ -19,8 +19,7 @@ export const LlmEditorAssistantDiffSchema = z.object({
   startLine: z.number()
     .int()
     .positive()
-    .optional()
-    .describe('Starting line number for search (1-based, optional)'),
+    .describe('Starting line number for search (1-based, REQUIRED)'),
   endLine: z.number()
     .int()
     .positive()

+ 14 - 7
apps/app/src/features/openai/interfaces/editor-assistant/types.ts

@@ -79,15 +79,20 @@ export interface SingleDiffResult {
 
 export interface MatchResult {
   /** Whether a match was found above threshold */
-  found: boolean;
+  success: boolean;
   /** Similarity score (0.0 to 1.0) */
-  score: number;
+  similarity: number;
   /** Starting line index of the match */
-  index: number;
+  index?: number;
   /** Matched content */
-  content: string;
-  /** Threshold used for matching */
-  threshold: number;
+  content?: string;
+  /** Character range of the match */
+  matchedRange?: {
+    startIndex: number;
+    endIndex: number;
+    startLine: number;
+    endLine: number;
+  };
   /** Time taken for search in milliseconds (client-side) */
   searchTime?: number;
   /** Error message if search failed */
@@ -99,7 +104,9 @@ export interface SearchContext {
   startLine?: number;
   /** Ending line number for search (1-based) */
   endLine?: number;
-  /** Additional context lines around the search area */
+  /** Preferred starting line for search optimization (1-based) */
+  preferredStartLine?: number;
+  /** Number of buffer lines around search area (default: 40) */
   bufferLines?: number;
 }
 

+ 14 - 3
apps/app/src/features/openai/server/routes/edit/index.ts

@@ -104,13 +104,24 @@ function instruction(withMarkdown: boolean): string {
   }
 
   ## For Edit Type (explicit editing request):
+  The SEARCH field must contain exact content including whitespace and indentation.
+  The startLine field is REQUIRED and must specify the line number where search begins.
+
   Respond with a JSON object in the following format:
   {
     "contents": [
       { "message": "Your brief message about the upcoming change or proposal.\n\n" },
-      { "replace": "New text 1" },
+      {
+        "search": "exact existing content",
+        "replace": "new content",
+        "startLine": 42  // REQUIRED: line number (1-based) where search begins
+      },
       { "message": "Additional explanation if needed" },
-      { "replace": "New text 2" },
+      {
+        "search": "another exact content",
+        "replace": "replacement content",
+        "startLine": 58  // REQUIRED
+      },
       ...more items if needed
       { "message": "Your friendly message explaining what changes were made or suggested." }
     ]
@@ -119,7 +130,7 @@ function instruction(withMarkdown: boolean): string {
   The array should contain:
   - [At the beginning of the list] A "message" object that has your brief message about the upcoming change or proposal. Be sure to add two consecutive line feeds ('\n\n') at the end.
   - Objects with a "message" key for explanatory text to the user if needed.
-  - Edit markdown according to user instructions and include it line by line in the 'replace' object. ${withMarkdown ? 'Return original text for lines that do not need editing.' : ''}
+  - Edit objects with "search" (exact existing content), "replace" (new content), and "startLine" (1-based line number, REQUIRED) fields.
   - [At the end of the list] A "message" object that contains your friendly message explaining that the operation was completed and what changes were made.
 
   ${withMarkdown ? withMarkdownCaution : ''}