فهرست منبع

update schema

Yuki Takei 1 سال پیش
والد
کامیت
909680bd80

+ 136 - 190
apps/app/src/features/openai/server/routes/edit/editor-stream-processor.ts

@@ -1,8 +1,8 @@
+import { jsonrepair } from 'jsonrepair';
 import type { z } from 'zod';
 
 import loggerFactory from '~/utils/logger';
 
-import { JsonStreamProcessor } from '../utils/json-stream-processor';
 import type { SseHelper } from '../utils/sse-helper';
 
 import type { EditorAssistantMessageSchema } from './schema';
@@ -14,10 +14,6 @@ const logger = loggerFactory('growi:routes:apiv3:openai:edit:editor-stream-proce
 type EditorAssistantMessage = z.infer<typeof EditorAssistantMessageSchema>;
 type EditorAssistantDiff = z.infer<typeof EditorAssistantDiffSchema>;
 
-// -----------------------------------------------------------------------------
-// ユーティリティ関数 (外形のみ)
-// -----------------------------------------------------------------------------
-
 /**
  * 型ガード: メッセージ型かどうかを判定する
  */
@@ -30,187 +26,128 @@ const isMessageItem = (item: unknown): item is EditorAssistantMessage => {
  */
 const isDiffItem = (item: unknown): item is EditorAssistantDiff => {
   return typeof item === 'object' && item !== null
-    && 'start' in item && 'end' in item && 'text' in item;
+    && ('insert' in item || 'delete' in item || 'retain' in item);
 };
 
-/**
- * コンテンツからメッセージと差分を抽出する
- */
-const extractContentItems = (contents: unknown[]) => {
-  const messages: string[] = [];
-  const replacements: EditorAssistantDiff[] = [];
-
-  contents.forEach((item) => {
-    if (isMessageItem(item)) {
-      messages.push(item.message);
-    }
-    else if (isDiffItem(item)) {
-      const validDiff = EditorAssistantDiffSchema.safeParse(item);
-      if (validDiff.success) {
-        replacements.push(validDiff.data);
-      }
-    }
-  });
-
-  return { messages, replacements };
-};
-
-/**
- * エディターアシスタントのレスポンスデータを作成する
- */
-const createEditorResponse = (messages: string[], replacements: EditorAssistantDiff[], isDone = false) => ({
-  editorResponse: {
-    message: messages.length > 0 ? messages[messages.length - 1] : '',
-    replacements,
-  },
-  ...(isDone ? { isDone: true } : {}),
-});
-
-// -----------------------------------------------------------------------------
-// エディターデータプロセッサ実装 (外形のみ)
-// -----------------------------------------------------------------------------
-
 /**
  * EditorAssistant用のストリームデータプロセッサ
  * JSONストリームから編集用のメッセージと差分を抽出する
  */
 export class EditorStreamProcessor {
 
-  // JSONプロセッサ
-  private jsonProcessor: JsonStreamProcessor;
-
-  // 確認済みのデータ
-  private messages: string[] = [];
+  // 最終応答データ
+  private message: string | null = null;
 
   private replacements: EditorAssistantDiff[] = [];
 
-  // ハンドラーID
-  private handlerIds: string[] = [];
+  // 前回のコンテンツの最終要素のインデックス
+  private lastContentIndex = -1;
 
-  constructor(private sseHelper: SseHelper) {
-    // JsonStreamProcessorの初期化
-    this.jsonProcessor = new JsonStreamProcessor();
-    this.setupHandlers();
-  }
+  // 最後に送信した差分インデックス
+  private lastSentDiffIndex = -1;
 
-  /**
-   * 処理ハンドラーを設定
-   */
-  private setupHandlers(): void {
-    // コンテンツ配列ハンドラー
-    this.handlerIds.push(
-      this.jsonProcessor.registerHandler(
-        (value): value is { contents: unknown[] } => {
-          return typeof value === 'object' && value !== null && 'contents' in value && Array.isArray(value.contents);
-        },
-        data => this.handleContents(data.contents),
-      ),
-    );
-
-    // 単一メッセージハンドラー
-    this.handlerIds.push(
-      this.jsonProcessor.registerHandler(
-        isMessageItem,
-        message => this.handleMessage(message.message),
-      ),
-    );
-
-    // 単一差分ハンドラー
-    this.handlerIds.push(
-      this.jsonProcessor.registerHandler(
-        isDiffItem,
-        diff => this.handleDiff(diff),
-      ),
-    );
-  }
+  // 送信済みの差分キー
+  private sentDiffKeys = new Set<string>();
 
-  /**
-   * JSON文字列を処理
-   */
-  process(jsonString: string): void {
-    this.jsonProcessor.process(jsonString);
-  }
+  constructor(private sseHelper: SseHelper) {}
 
   /**
-   * コンテンツ配列を処理
+   * JSONデータを処理する
+   * @param jsonString JSON文字列
    */
-  private handleContents(contents: unknown[]): void {
-    const extracted = extractContentItems(contents);
-
-    let hasUpdates = false;
-
-    // メッセージ処理
-    extracted.messages.forEach((msg) => {
-      if (!this.messages.includes(msg)) {
-        this.messages.push(msg);
-        hasUpdates = true;
-      }
-    });
+  process(jsonString: string): void {
+    try {
+      const repairedJson = jsonrepair(jsonString);
+      const parsedJson = JSON.parse(repairedJson);
 
-    // 差分処理
-    extracted.replacements.forEach((diff) => {
-      const existingIndex = this.replacements.findIndex(r => r.start === diff.start && r.end === diff.end);
-      if (existingIndex >= 0) {
-        this.replacements[existingIndex] = diff;
-      }
-      else {
-        this.replacements.push(diff);
+      if (parsedJson?.contents && Array.isArray(parsedJson.contents)) {
+        const contents = parsedJson.contents;
+
+        // 現在のコンテンツの最終要素のインデックス
+        const currentContentIndex = contents.length - 1;
+
+        // メッセージの処理
+        let messageUpdated = false;
+        for (let i = contents.length - 1; i >= 0; i--) {
+          const item = contents[i];
+          if (isMessageItem(item)) {
+            if (this.message !== item.message) {
+              this.message = item.message;
+              messageUpdated = true;
+            }
+            break;
+          }
+        }
+
+        // 差分の処理
+        let diffUpdated = false;
+        let processedDiffIndex = -1;
+
+        // 差分が含まれているか確認
+        for (let i = 0; i < contents.length; i++) {
+          const item = contents[i];
+          if (!isDiffItem(item)) continue;
+
+          const validDiff = EditorAssistantDiffSchema.safeParse(item);
+          if (!validDiff.success) continue;
+
+          const diff = validDiff.data;
+          const key = this.getDiffKey(diff);
+
+          // この差分がすでに送信済みかチェック
+          if (this.sentDiffKeys.has(key)) continue;
+
+          // 最終要素が変わった場合、または最後から2番目以前の要素の場合
+          // → 差分が完成したと判断
+          if (i < currentContentIndex || currentContentIndex > this.lastContentIndex) {
+            this.replacements.push(diff);
+            this.sentDiffKeys.add(key);
+            diffUpdated = true;
+            processedDiffIndex = Math.max(processedDiffIndex, i);
+          }
+        }
+
+        // 最終インデックスを更新
+        this.lastContentIndex = currentContentIndex;
+
+        // 更新通知
+        if (messageUpdated) {
+          // メッセージは更新されたらすぐに通知
+          this.notifyClient();
+        }
+        else if (diffUpdated && processedDiffIndex > this.lastSentDiffIndex) {
+          // 差分は新しいインデックスの差分が確定した場合のみ通知
+          this.lastSentDiffIndex = processedDiffIndex;
+          this.notifyClient();
+        }
       }
-      hasUpdates = true;
-    });
-
-    // 更新があればクライアントに通知
-    if (hasUpdates) {
-      this.notifyClient();
     }
-  }
-
-  /**
-   * 単一メッセージを処理
-   */
-  private handleMessage(message: string): void {
-    if (!this.messages.includes(message)) {
-      this.messages.push(message);
-      this.notifyClient();
+    catch (e) {
+      // パースエラーは無視(不完全なJSONなので)
+      logger.debug('JSON parsing error (expected for partial data):', e);
     }
   }
 
   /**
-   * 単一差分を処理
+   * 差分の一意キーを生成
    */
-  private handleDiff(diff: EditorAssistantDiff): void {
-    const validDiff = EditorAssistantDiffSchema.safeParse(diff);
-    if (!validDiff.success) return;
-
-    const existingIndex = this.replacements.findIndex(r => r.start === diff.start && r.end === diff.end);
-    if (existingIndex >= 0) {
-      this.replacements[existingIndex] = validDiff.data;
-    }
-    else {
-      this.replacements.push(validDiff.data);
-    }
-
-    this.notifyClient();
+  private getDiffKey(diff: EditorAssistantDiff): string {
+    if ('insert' in diff) return `insert-${diff.insert}`;
+    if ('delete' in diff) return `delete-${diff.delete}`;
+    if ('retain' in diff) return `retain-${diff.retain}`;
+    return '';
   }
 
   /**
    * クライアントに通知
    */
   private notifyClient(): void {
-    this.sseHelper.writeData(createEditorResponse(this.messages, this.replacements));
-  }
-
-  /**
-   * リソースを解放
-   */
-  destroy(): void {
-    // ハンドラー登録解除
-    this.handlerIds.forEach((id) => {
-      this.jsonProcessor.unregisterHandler(id);
+    this.sseHelper.writeData({
+      editorResponse: {
+        message: this.message || '',
+        replacements: this.replacements,
+      },
     });
-
-    // プロセッサ解放
-    this.jsonProcessor.destroy();
   }
 
   /**
@@ -218,54 +155,63 @@ export class EditorStreamProcessor {
    */
   sendFinalResult(rawBuffer: string): void {
     try {
-      // 完全なJSONをパース
-      const parsedJson = JSON.parse(rawBuffer);
+      const repairedJson = jsonrepair(rawBuffer);
+      const parsedJson = JSON.parse(repairedJson);
 
+      // 最後のデータから全ての差分を取得
       if (parsedJson?.contents && Array.isArray(parsedJson.contents)) {
-        // 最終的なコンテンツを抽出
-        const extracted = extractContentItems(parsedJson.contents);
-
-        // 最終データを送信(完全なデータがあればそちらを優先)
-        this.sseHelper.writeData(createEditorResponse(
-          extracted.messages.length > 0 ? extracted.messages : this.messages,
-          extracted.replacements.length > 0 ? extracted.replacements : this.replacements,
-          true, // 完了フラグ
-        ));
-      }
-      else if (this.messages.length > 0 || this.replacements.length > 0) {
-        // パース結果が期待形式でなくても蓄積データがあれば送信
-        this.sseHelper.writeData(createEditorResponse(this.messages, this.replacements, true));
-      }
-      else {
-        // データがない場合はエラー
-        this.sseHelper.writeError('Failed to parse assistant response as JSON', 'INVALID_RESPONSE_FORMAT');
+        const contents = parsedJson.contents;
+
+        // 未送信の差分があれば追加
+        for (const item of contents) {
+          if (!isDiffItem(item)) continue;
+
+          const validDiff = EditorAssistantDiffSchema.safeParse(item);
+          if (!validDiff.success) continue;
+
+          const diff = validDiff.data;
+          const key = this.getDiffKey(diff);
+
+          // まだ送信していない差分を追加
+          if (!this.sentDiffKeys.has(key)) {
+            this.replacements.push(diff);
+            this.sentDiffKeys.add(key);
+          }
+        }
       }
+
+      // 最終通知(isDoneフラグ付き)
+      this.sseHelper.writeData({
+        editorResponse: {
+          message: this.message || '',
+          replacements: this.replacements,
+        },
+        isDone: true,
+      });
     }
     catch (e) {
       logger.debug('Failed to parse final JSON response:', e);
 
-      // パースエラー時も蓄積データがあれば送信
-      if (this.messages.length > 0 || this.replacements.length > 0) {
-        this.sseHelper.writeData(createEditorResponse(this.messages, this.replacements, true));
-      }
-      else {
-        this.sseHelper.writeError('Failed to parse assistant response as JSON', 'INVALID_RESPONSE_FORMAT');
-      }
+      // エラー時も最終通知
+      this.sseHelper.writeData({
+        editorResponse: {
+          message: this.message || '',
+          replacements: this.replacements,
+        },
+        isDone: true,
+      });
     }
   }
 
   /**
-   * 現在のメッセージリスト
-   */
-  get messagesList(): string[] {
-    return this.messages;
-  }
-
-  /**
-   * 現在の差分リスト
+   * リソースを解放
    */
-  get replacementsList(): EditorAssistantDiff[] {
-    return this.replacements;
+  destroy(): void {
+    this.message = null;
+    this.replacements = [];
+    this.sentDiffKeys.clear();
+    this.lastContentIndex = -1;
+    this.lastSentDiffIndex = -1;
   }
 
 }

+ 7 - 4
apps/app/src/features/openai/server/routes/edit/index.ts

@@ -120,20 +120,23 @@ export const postMessageToEditHandlersFactory: PostMessageHandlersFactory = (cro
               Your task is to help users edit their markdown content based on their requests.
 
               RESPONSE FORMAT:
-              You must respond with a JSON object in the following format:
+              You must respond with a JSON object in the following format example:
               {
                 "contents": [
                   { "message": "Your friendly message explaining what changes were made or suggested" },
-                  { "start": 0, "end": 10, "text": "New text 1" },
+                  { "retain": 10 },
+                  { "insert": "New text 1" },
                   { "message": "Additional explanation if needed" },
-                  { "start": 20, "end": 30, "text": "New text 2" },
+                  { "retain": 100 },
+                  { "delete": 15 },
+                  { "insert": "New text 2" },
                   ...more items if needed
                 ]
               }
 
               The array should contain:
               - Objects with a "message" key for explanatory text to the user
-              - Objects with "start", "end", and "text" keys for replacements
+              - Objects with "insert", "delete", and "retain" keys for replacements (Delta format by Quill Rich Text Editor)
 
               If no changes are needed, include only message objects with explanations.
               Always provide messages in the same language as the user's request.`,

+ 14 - 5
apps/app/src/features/openai/server/routes/edit/schema.ts

@@ -9,8 +9,17 @@ export const EditorAssistantMessageSchema = z.object({
   message: z.string().describe('A friendly message explaining what changes were made or suggested'),
 });
 
-export const EditorAssistantDiffSchema = z.object({
-  start: z.number().int().describe('Zero-based index where replacement should begin'),
-  end: z.number().int().describe('Zero-based index where replacement should end'),
-  text: z.string().describe('The text that should replace the content between start and end positions'),
-});
+export const EditorAssistantDiffSchema = z
+  .object({
+    insert: z.string().describe('The text that should insert the content in the current position'),
+  })
+  .or(
+    z.object({
+      delete: z.number().int().describe('The number of characters that should be deleted from the current position'),
+    }),
+  )
+  .or(
+    z.object({
+      retain: z.number().int().describe('The number of characters that should be retained in the current position'),
+    }),
+  );