|
|
@@ -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;
|
|
|
}
|
|
|
|
|
|
}
|