Yuki Takei 1 год назад
Родитель
Сommit
2d4e82be42

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

@@ -0,0 +1,271 @@
+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';
+import { EditorAssistantDiffSchema } from './schema';
+
+const logger = loggerFactory('growi:routes:apiv3:openai:edit:editor-stream-processor');
+
+// 型定義
+type EditorAssistantMessage = z.infer<typeof EditorAssistantMessageSchema>;
+type EditorAssistantDiff = z.infer<typeof EditorAssistantDiffSchema>;
+
+// -----------------------------------------------------------------------------
+// ユーティリティ関数 (外形のみ)
+// -----------------------------------------------------------------------------
+
+/**
+ * 型ガード: メッセージ型かどうかを判定する
+ */
+const isMessageItem = (item: unknown): item is EditorAssistantMessage => {
+  return typeof item === 'object' && item !== null && 'message' in item;
+};
+
+/**
+ * 型ガード: 差分型かどうかを判定する
+ */
+const isDiffItem = (item: unknown): item is EditorAssistantDiff => {
+  return typeof item === 'object' && item !== null
+    && 'start' in item && 'end' in item && 'text' 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 replacements: EditorAssistantDiff[] = [];
+
+  // ハンドラーID
+  private handlerIds: string[] = [];
+
+  constructor(private sseHelper: SseHelper) {
+    // JsonStreamProcessorの初期化
+    this.jsonProcessor = new JsonStreamProcessor();
+    this.setupHandlers();
+  }
+
+  /**
+   * 処理ハンドラーを設定
+   */
+  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),
+      ),
+    );
+  }
+
+  /**
+   * JSON文字列を処理
+   */
+  process(jsonString: string): void {
+    this.jsonProcessor.process(jsonString);
+  }
+
+  /**
+   * コンテンツ配列を処理
+   */
+  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;
+      }
+    });
+
+    // 差分処理
+    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);
+      }
+      hasUpdates = true;
+    });
+
+    // 更新があればクライアントに通知
+    if (hasUpdates) {
+      this.notifyClient();
+    }
+  }
+
+  /**
+   * 単一メッセージを処理
+   */
+  private handleMessage(message: string): void {
+    if (!this.messages.includes(message)) {
+      this.messages.push(message);
+      this.notifyClient();
+    }
+  }
+
+  /**
+   * 単一差分を処理
+   */
+  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 notifyClient(): void {
+    this.sseHelper.writeData(createEditorResponse(this.messages, this.replacements));
+  }
+
+  /**
+   * リソースを解放
+   */
+  destroy(): void {
+    // ハンドラー登録解除
+    this.handlerIds.forEach((id) => {
+      this.jsonProcessor.unregisterHandler(id);
+    });
+
+    // プロセッサ解放
+    this.jsonProcessor.destroy();
+  }
+
+  /**
+   * 最終結果を送信
+   */
+  sendFinalResult(rawBuffer: string): void {
+    try {
+      // 完全なJSONをパース
+      const parsedJson = JSON.parse(rawBuffer);
+
+      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');
+      }
+    }
+    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');
+      }
+    }
+  }
+
+  /**
+   * 現在のメッセージリスト
+   */
+  get messagesList(): string[] {
+    return this.messages;
+  }
+
+  /**
+   * 現在の差分リスト
+   */
+  get replacementsList(): EditorAssistantDiff[] {
+    return this.replacements;
+  }
+
+}

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

@@ -1,12 +1,9 @@
-import { Readable } from 'stream';
-
 import type { IUserHasId } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import type { Request, RequestHandler, Response } from 'express';
 import type { ValidationChain } from 'express-validator';
 import { body } from 'express-validator';
 import { zodResponseFormat } from 'openai/helpers/zod';
-import type { AssistantStream } from 'openai/lib/AssistantStream';
 import type { MessageDelta } from 'openai/resources/beta/threads/messages.mjs';
 import { z } from 'zod';
 
@@ -24,33 +21,21 @@ import { getStreamErrorCode } from '../../services/getStreamErrorCode';
 import { getOpenaiService } from '../../services/openai';
 import { replaceAnnotationWithPageLink } from '../../services/replace-annotation-with-page-link';
 import { certifyAiService } from '../middlewares/certify-ai-service';
-import { JsonStreamProcessor } from '../utils/json-stream-processor';
 import { SseHelper } from '../utils/sse-helper';
 
+import { EditorStreamProcessor } from './editor-stream-processor';
+import { EditorAssistantDiffSchema, EditorAssistantMessageSchema } from './schema';
+
 const logger = loggerFactory('growi:routes:apiv3:openai:message');
 
 // -----------------------------------------------------------------------------
 // 型定義
 // -----------------------------------------------------------------------------
 
-// スキーマ定義
-const EditorAssistantMessageSchema = z.object({
-  message: z.string().describe('A friendly message explaining what changes were made or suggested'),
-});
-
-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'),
-});
-
 const EditorAssistantResponseSchema = z.object({
   contents: z.array(z.union([EditorAssistantMessageSchema, EditorAssistantDiffSchema])),
 }).describe('The response format for the editor assistant');
 
-// 型定義
-type EditorAssistantMessage = z.infer<typeof EditorAssistantMessageSchema>;
-type EditorAssistantDiff = z.infer<typeof EditorAssistantDiffSchema>;
 
 type ReqBody = {
   userMessage: string,
@@ -63,261 +48,6 @@ type Req = Request<undefined, Response, ReqBody> & {
   user: IUserHasId,
 }
 
-// -----------------------------------------------------------------------------
-// ユーティリティ関数 (外形のみ)
-// -----------------------------------------------------------------------------
-
-/**
- * 型ガード: メッセージ型かどうかを判定する
- */
-const isMessageItem = (item: unknown): item is EditorAssistantMessage => {
-  return typeof item === 'object' && item !== null && 'message' in item;
-};
-
-/**
- * 型ガード: 差分型かどうかを判定する
- */
-const isDiffItem = (item: unknown): item is EditorAssistantDiff => {
-  return typeof item === 'object' && item !== null
-    && 'start' in item && 'end' in item && 'text' 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ストリームから編集用のメッセージと差分を抽出する
- */
-class EditorStreamProcessor {
-
-  // JSONプロセッサ
-  private jsonProcessor: JsonStreamProcessor;
-
-  // 確認済みのデータ
-  private messages: string[] = [];
-
-  private replacements: EditorAssistantDiff[] = [];
-
-  // ハンドラーID
-  private handlerIds: string[] = [];
-
-  constructor(private sseHelper: SseHelper) {
-    // JsonStreamProcessorの初期化
-    this.jsonProcessor = new JsonStreamProcessor();
-    this.setupHandlers();
-  }
-
-  /**
-   * 処理ハンドラーを設定
-   */
-  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),
-      ),
-    );
-  }
-
-  /**
-   * JSON文字列を処理
-   */
-  process(jsonString: string): void {
-    this.jsonProcessor.process(jsonString);
-  }
-
-  /**
-   * コンテンツ配列を処理
-   */
-  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;
-      }
-    });
-
-    // 差分処理
-    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);
-      }
-      hasUpdates = true;
-    });
-
-    // 更新があればクライアントに通知
-    if (hasUpdates) {
-      this.notifyClient();
-    }
-  }
-
-  /**
-   * 単一メッセージを処理
-   */
-  private handleMessage(message: string): void {
-    if (!this.messages.includes(message)) {
-      this.messages.push(message);
-      this.notifyClient();
-    }
-  }
-
-  /**
-   * 単一差分を処理
-   */
-  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 notifyClient(): void {
-    this.sseHelper.writeData(createEditorResponse(this.messages, this.replacements));
-  }
-
-  /**
-   * リソースを解放
-   */
-  destroy(): void {
-    // ハンドラー登録解除
-    this.handlerIds.forEach((id) => {
-      this.jsonProcessor.unregisterHandler(id);
-    });
-
-    // プロセッサ解放
-    this.jsonProcessor.destroy();
-  }
-
-  /**
-   * 最終結果を送信
-   */
-  sendFinalResult(rawBuffer: string): void {
-    try {
-      // 完全なJSONをパース
-      const parsedJson = JSON.parse(rawBuffer);
-
-      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');
-      }
-    }
-    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');
-      }
-    }
-  }
-
-  /**
-   * 現在のメッセージリスト
-   */
-  get messagesList(): string[] {
-    return this.messages;
-  }
-
-  /**
-   * 現在の差分リスト
-   */
-  get replacementsList(): EditorAssistantDiff[] {
-    return this.replacements;
-  }
-
-}
 
 // -----------------------------------------------------------------------------
 // エンドポイントハンドラーファクトリ

+ 16 - 0
apps/app/src/features/openai/server/routes/edit/schema.ts

@@ -0,0 +1,16 @@
+import { z } from 'zod';
+
+// -----------------------------------------------------------------------------
+// 型定義
+// -----------------------------------------------------------------------------
+
+// スキーマ定義
+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'),
+});

+ 3 - 0
apps/app/src/features/openai/server/routes/utils/json-stream-processor.ts

@@ -91,6 +91,7 @@ export class JsonStreamProcessor implements IJsonStreamProcessor {
 
     // データハンドラ
     this.jsonValueStream.on('data', ({ value }) => {
+      console.log({ value });
       if (!value) return;
       this.notifyHandlers(value);
     });
@@ -126,6 +127,8 @@ export class JsonStreamProcessor implements IJsonStreamProcessor {
    * JSON文字列を処理する
    */
   process(jsonString: string): void {
+    console.log({ jsonString });
+
     try {
       // パーサーに直接データを書き込む
       this.jsonParser.write(jsonString);