Просмотр исходного кода

Merge pull request #10106 from weseek/imprv/context-for-editor-assistant

imprv(ai): Add page content around the cursor position as context for editor assistant
mergify[bot] 9 месяцев назад
Родитель
Сommit
aceae110b0
20 измененных файлов с 642 добавлено и 137 удалено
  1. 1 0
      apps/app/package.json
  2. 2 0
      apps/app/public/static/locales/en_US/translation.json
  3. 3 1
      apps/app/public/static/locales/fr_FR/translation.json
  4. 2 0
      apps/app/public/static/locales/ja_JP/translation.json
  5. 19 17
      apps/app/public/static/locales/zh_CN/translation.json
  6. 2 2
      apps/app/src/client/components/PageComment/CommentEditor.tsx
  7. 1 1
      apps/app/src/client/components/PageEditor/ConflictDiffModal.tsx
  8. 3 3
      apps/app/src/client/components/PageEditor/PageEditor.tsx
  9. 1 1
      apps/app/src/client/components/PageEditor/conflict.tsx
  10. 2 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/AiAssistantSidebar.tsx
  11. 256 0
      apps/app/src/features/openai/client/services/editor-assistant/get-page-body-for-context.spec.ts
  12. 74 0
      apps/app/src/features/openai/client/services/editor-assistant/get-page-body-for-context.ts
  13. 80 17
      apps/app/src/features/openai/client/services/editor-assistant/use-editor-assistant.tsx
  14. 17 0
      apps/app/src/features/openai/interfaces/editor-assistant/sse-schemas.ts
  15. 93 91
      apps/app/src/features/openai/server/routes/edit/index.ts
  16. 65 0
      apps/app/src/features/openai/server/services/assistant/editor-assistant.ts
  17. 1 1
      packages/editor/src/client/components-internal/playground/Playground.tsx
  18. 5 1
      packages/editor/src/client/services/use-codemirror-editor/use-codemirror-editor.ts
  19. 12 2
      packages/editor/src/client/services/use-codemirror-editor/utils/get-doc.ts
  20. 3 0
      pnpm-lock.yaml

+ 1 - 0
apps/app/package.json

@@ -260,6 +260,7 @@
   },
   },
   "devDependencies": {
   "devDependencies": {
     "@apidevtools/swagger-parser": "^10.1.1",
     "@apidevtools/swagger-parser": "^10.1.1",
+    "@codemirror/state": "^6.5.2",
     "@emoji-mart/data": "^1.2.1",
     "@emoji-mart/data": "^1.2.1",
     "@growi/core-styles": "workspace:^",
     "@growi/core-styles": "workspace:^",
     "@growi/custom-icons": "workspace:^",
     "@growi/custom-icons": "workspace:^",

+ 2 - 0
apps/app/public/static/locales/en_US/translation.json

@@ -511,6 +511,8 @@
     "budget_exceeded_for_growi_cloud": "You have reached your OpenAI API usage limit. To use the Knowledge Assistant again, please add credits from the GROWI.cloud admin page for Hosted users or from the OpenAI billing page for Owned users.",
     "budget_exceeded_for_growi_cloud": "You have reached your OpenAI API usage limit. To use the Knowledge Assistant again, please add credits from the GROWI.cloud admin page for Hosted users or from the OpenAI billing page for Owned users.",
     "error_message": "An error has occurred",
     "error_message": "An error has occurred",
     "show_error_detail": "Show error details",
     "show_error_detail": "Show error details",
+    "editor_assistant_long_context_warn_with_unit_line": "The text is too long, so the Editor Assistant will reference approximately lines {{startPosition}} to {{endPosition}} for its response.",
+    "editor_assistant_long_context_warn_with_unit_char": "The text is too long, so the Editor Assistant will reference characters {{startPosition}} to {{endPosition}} for its response.",
     "discard": "Discard",
     "discard": "Discard",
     "accept": "Accept",
     "accept": "Accept",
     "use_assistant": "Use Assistant",
     "use_assistant": "Use Assistant",

+ 3 - 1
apps/app/public/static/locales/fr_FR/translation.json

@@ -505,6 +505,8 @@
     "budget_exceeded_for_growi_cloud": "Vous avez atteint votre limite d'utilisation de l'API de l'OpenAI. Pour utiliser à nouveau l'assistant de connaissance, veuillez ajouter des crédits à partir de la page d'administration de GROWI.cloud pour les utilisateurs hébergés ou à partir de la page de facturation de l'OpenAI pour les utilisateurs propriétaires.",
     "budget_exceeded_for_growi_cloud": "Vous avez atteint votre limite d'utilisation de l'API de l'OpenAI. Pour utiliser à nouveau l'assistant de connaissance, veuillez ajouter des crédits à partir de la page d'administration de GROWI.cloud pour les utilisateurs hébergés ou à partir de la page de facturation de l'OpenAI pour les utilisateurs propriétaires.",
     "error_message": "Erreur",
     "error_message": "Erreur",
     "show_error_detail": "Détails de l'exposition",
     "show_error_detail": "Détails de l'exposition",
+    "editor_assistant_long_context_warn_with_unit_line": "Le texte est trop long, l'Assistant de rédaction se référera approximativement aux lignes {{startPosition}} à {{endPosition}} pour sa réponse.",
+    "editor_assistant_long_context_warn_with_unit_char": "Le texte est trop long, l'Assistant de rédaction se référera aux caractères {{startPosition}} à {{endPosition}} pour sa réponse.",
     "discard": "Annuler",
     "discard": "Annuler",
     "accept": "Accepter",
     "accept": "Accepter",
     "use_assistant": "Utiliser l'assistant",
     "use_assistant": "Utiliser l'assistant",
@@ -595,7 +597,7 @@
   "default_ai_assistant": {
   "default_ai_assistant": {
     "not_set": "L'assistant par défaut n'est pas configuré"
     "not_set": "L'assistant par défaut n'est pas configuré"
   },
   },
- "ai_assistant_tree": {
+  "ai_assistant_tree": {
     "add_assistant": "Ajouter un assistant",
     "add_assistant": "Ajouter un assistant",
     "my_assistants": "Mes assistants",
     "my_assistants": "Mes assistants",
     "team_assistants": "Assistants d'équipe",
     "team_assistants": "Assistants d'équipe",

+ 2 - 0
apps/app/public/static/locales/ja_JP/translation.json

@@ -543,6 +543,8 @@
     "budget_exceeded_for_growi_cloud": "OpenAI の API の利用上限に達しました。ナレッジアシスタントを再度利用するには Hosted の場合は GROWI.cloud の管理画面から Owned の場合は OpenAI の請求ページからクレジットを追加してください。",
     "budget_exceeded_for_growi_cloud": "OpenAI の API の利用上限に達しました。ナレッジアシスタントを再度利用するには Hosted の場合は GROWI.cloud の管理画面から Owned の場合は OpenAI の請求ページからクレジットを追加してください。",
     "error_message": "エラーが発生しました",
     "error_message": "エラーが発生しました",
     "show_error_detail": "詳細を表示",
     "show_error_detail": "詳細を表示",
+    "editor_assistant_long_context_warn_with_unit_line": "本文が長すぎるため、エディターアシスタントは {{startPosition}}行から{{endPosition}}行付近までを参照して回答します",
+    "editor_assistant_long_context_warn_with_unit_char": "本文が長すぎるため、エディターアシスタントは {{startPosition}}文字目から{{endPosition}}文字目までを参照して回答します",
     "discard": "破棄",
     "discard": "破棄",
     "accept": "採用",
     "accept": "採用",
     "use_assistant": "アシスタントを使用する",
     "use_assistant": "アシスタントを使用する",

+ 19 - 17
apps/app/public/static/locales/zh_CN/translation.json

@@ -123,8 +123,8 @@
   "V5 Page Migration": "转换为V5的兼容性",
   "V5 Page Migration": "转换为V5的兼容性",
   "GROWI.5.0_new_schema": "GROWI.5.0 new schema",
   "GROWI.5.0_new_schema": "GROWI.5.0 new schema",
   "See_more_detail_on_new_schema": "更多详情请见<a href='https://docs.growi.org/en/admin-guide/upgrading/50x.html#about-the-new-v5-compatible-format' target='_blank'> {{title}}</a> <span class='growi-custom-icons'>external_link</span> ",
   "See_more_detail_on_new_schema": "更多详情请见<a href='https://docs.growi.org/en/admin-guide/upgrading/50x.html#about-the-new-v5-compatible-format' target='_blank'> {{title}}</a> <span class='growi-custom-icons'>external_link</span> ",
-	"Markdown Settings": "Markdown设置",
-	"external_account_management": "外部账户管理",
+  "Markdown Settings": "Markdown设置",
+  "external_account_management": "外部账户管理",
   "UserGroup": "用户组",
   "UserGroup": "用户组",
   "ChildUserGroup": "儿童用户组",
   "ChildUserGroup": "儿童用户组",
   "Basic Settings": "基础设置",
   "Basic Settings": "基础设置",
@@ -192,12 +192,12 @@
   "custom_navigation": {
   "custom_navigation": {
     "no_pages_under_this_page": "There are no pages under this page."
     "no_pages_under_this_page": "There are no pages under this page."
   },
   },
-"author_info": {
-  "created_at": "创建日期",
-  "created_by": "创建者:",
-  "last_revision_posted_at": "最后更新日期",
-  "updated_by": "更新者:"
-},
+  "author_info": {
+    "created_at": "创建日期",
+    "created_by": "创建者:",
+    "last_revision_posted_at": "最后更新日期",
+    "updated_by": "更新者:"
+  },
   "installer": {
   "installer": {
     "tab": "创建账户",
     "tab": "创建账户",
     "title": "安装",
     "title": "安装",
@@ -321,9 +321,9 @@
       "no_deadline": "此页面没有到期日期",
       "no_deadline": "此页面没有到期日期",
       "not_indexed1": "此页面可能不会被全文搜索引擎索引。",
       "not_indexed1": "此页面可能不会被全文搜索引擎索引。",
       "not_indexed2": "页面正文超过了{{threshold}}指定的阈值。"
       "not_indexed2": "页面正文超过了{{threshold}}指定的阈值。"
-		}
-	},
-	"page_edit": {
+    }
+  },
+  "page_edit": {
     "input_channels": "频道名",
     "input_channels": "频道名",
     "theme": "主题",
     "theme": "主题",
     "keymap": "键表",
     "keymap": "键表",
@@ -336,12 +336,12 @@
     },
     },
     "editor_config": "编辑器配置",
     "editor_config": "编辑器配置",
     "editor_assistant": "编辑助手",
     "editor_assistant": "编辑助手",
-		"Show active line": "显示活动行",
-		"auto_format_table": "自动格式化表格",
-		"overwrite_scopes": "{{operation}和覆盖所有子体的作用域",
-		"notice": {
-			"conflict": "无法保存您所做的更改,因为其他人正在编辑此页。请在重新加载页面后重新编辑受影响的部分。"
-		},
+    "Show active line": "显示活动行",
+    "auto_format_table": "自动格式化表格",
+    "overwrite_scopes": "{{operation}和覆盖所有子体的作用域",
+    "notice": {
+      "conflict": "无法保存您所做的更改,因为其他人正在编辑此页。请在重新加载页面后重新编辑受影响的部分。"
+    },
     "changes_not_saved": "您所做的更改可能不会保存。你真的想继续前进吗?"
     "changes_not_saved": "您所做的更改可能不会保存。你真的想继续前进吗?"
   },
   },
   "page_comment": {
   "page_comment": {
@@ -500,6 +500,8 @@
     "budget_exceeded_for_growi_cloud": "您已达到 OpenAI API 使用上限。如需再次使用知识助手,请从GROWI.cloud管理页面为托管用户添加点数,或从OpenAI计费页面为自有用户添加点数。",
     "budget_exceeded_for_growi_cloud": "您已达到 OpenAI API 使用上限。如需再次使用知识助手,请从GROWI.cloud管理页面为托管用户添加点数,或从OpenAI计费页面为自有用户添加点数。",
     "error_message": "错误",
     "error_message": "错误",
     "show_error_detail": "显示详情",
     "show_error_detail": "显示详情",
+    "editor_assistant_long_context_warn_with_unit_line": "文本过长,编辑助理将参考大约第 {{startPosition}} 行到第 {{endPosition}} 行来响应",
+    "editor_assistant_long_context_warn_with_unit_char": "文本过长,编辑助理将参考第 {{startPosition}} 个字符到第 {{endPosition}} 个字符来响应",
     "discard": "丢弃",
     "discard": "丢弃",
     "accept": "接受",
     "accept": "接受",
     "use_assistant": "使用助手",
     "use_assistant": "使用助手",

+ 2 - 2
apps/app/src/client/components/PageComment/CommentEditor.tsx

@@ -152,7 +152,7 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
   }, [onCanceled, initializeEditor]);
   }, [onCanceled, initializeEditor]);
 
 
   const postCommentHandler = useCallback(async() => {
   const postCommentHandler = useCallback(async() => {
-    const commentBodyToPost = codeMirrorEditor?.getDoc() ?? '';
+    const commentBodyToPost = codeMirrorEditor?.getDocString() ?? '';
 
 
     try {
     try {
       if (currentCommentId != null) {
       if (currentCommentId != null) {
@@ -276,7 +276,7 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
           </TabPane>
           </TabPane>
           <TabPane tabId="comment_preview">
           <TabPane tabId="comment_preview">
             <div className="comment-preview-container">
             <div className="comment-preview-container">
-              <CommentPreview markdown={codeMirrorEditor?.getDoc() ?? ''} />
+              <CommentPreview markdown={codeMirrorEditor?.getDocString() ?? ''} />
             </div>
             </div>
           </TabPane>
           </TabPane>
         </TabContent>
         </TabContent>

+ 1 - 1
apps/app/src/client/components/PageEditor/ConflictDiffModal.tsx

@@ -60,7 +60,7 @@ const ConflictDiffModalCore = (props: ConflictDiffModalCoreProps): JSX.Element =
   }, [isRevisionselected]);
   }, [isRevisionselected]);
 
 
   const resolveConflictHandler = useCallback(async() => {
   const resolveConflictHandler = useCallback(async() => {
-    const newBody = codeMirrorEditor?.getDoc();
+    const newBody = codeMirrorEditor?.getDocString();
     if (newBody == null) {
     if (newBody == null) {
       return;
       return;
     }
     }

+ 3 - 3
apps/app/src/client/components/PageEditor/PageEditor.tsx

@@ -156,7 +156,7 @@ export const PageEditorSubstance = (props: Props): JSX.Element => {
 
 
   const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(GlobalCodeMirrorEditorKey.MAIN);
   const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(GlobalCodeMirrorEditorKey.MAIN);
 
 
-  const [markdownToPreview, setMarkdownToPreview] = useState<string>(codeMirrorEditor?.getDoc() ?? '');
+  const [markdownToPreview, setMarkdownToPreview] = useState<string>(codeMirrorEditor?.getDocString() ?? '');
   const setMarkdownPreviewWithDebounce = useMemo(() => debounce(100, throttle(150, (value: string) => {
   const setMarkdownPreviewWithDebounce = useMemo(() => debounce(100, throttle(150, (value: string) => {
     setMarkdownToPreview(value);
     setMarkdownToPreview(value);
   })), []);
   })), []);
@@ -217,7 +217,7 @@ export const PageEditorSubstance = (props: Props): JSX.Element => {
   }, [pageId, selectedGrant, mutateWaitingSaveProcessing, updatePage, mutateIsGrantNormalized, t]);
   }, [pageId, selectedGrant, mutateWaitingSaveProcessing, updatePage, mutateIsGrantNormalized, t]);
 
 
   const saveAndReturnToViewHandler = useCallback(async(opts: SaveOptions) => {
   const saveAndReturnToViewHandler = useCallback(async(opts: SaveOptions) => {
-    const markdown = codeMirrorEditor?.getDoc();
+    const markdown = codeMirrorEditor?.getDocString();
     const revisionId = isRevisionIdRequiredForPageUpdate ? currentRevisionId : undefined;
     const revisionId = isRevisionIdRequiredForPageUpdate ? currentRevisionId : undefined;
     const page = await save(revisionId, markdown, opts, onConflict);
     const page = await save(revisionId, markdown, opts, onConflict);
     if (page == null) {
     if (page == null) {
@@ -229,7 +229,7 @@ export const PageEditorSubstance = (props: Props): JSX.Element => {
   }, [codeMirrorEditor, currentRevisionId, isRevisionIdRequiredForPageUpdate, mutateEditorMode, onConflict, save, updateStateAfterSave]);
   }, [codeMirrorEditor, currentRevisionId, isRevisionIdRequiredForPageUpdate, mutateEditorMode, onConflict, save, updateStateAfterSave]);
 
 
   const saveWithShortcut = useCallback(async() => {
   const saveWithShortcut = useCallback(async() => {
-    const markdown = codeMirrorEditor?.getDoc();
+    const markdown = codeMirrorEditor?.getDocString();
     const revisionId = isRevisionIdRequiredForPageUpdate ? currentRevisionId : undefined;
     const revisionId = isRevisionIdRequiredForPageUpdate ? currentRevisionId : undefined;
     const page = await save(revisionId, markdown, undefined, onConflict);
     const page = await save(revisionId, markdown, undefined, onConflict);
     if (page == null) {
     if (page == null) {

+ 1 - 1
apps/app/src/client/components/PageEditor/conflict.tsx

@@ -97,7 +97,7 @@ export const useConflictEffect = (): void => {
         closePageStatusAlert();
         closePageStatusAlert();
       };
       };
 
 
-      const markdown = codeMirrorEditor?.getDoc();
+      const markdown = codeMirrorEditor?.getDocString();
       openConflictDiffModal(markdown ?? '', resolveConflictHandler);
       openConflictDiffModal(markdown ?? '', resolveConflictHandler);
     };
     };
 
 

+ 2 - 0
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/AiAssistantSidebar.tsx

@@ -95,6 +95,7 @@ const AiAssistantSidebarSubstance: React.FC<AiAssistantSidebarSubstanceProps> =
     // Views
     // Views
     generateInitialView: generateInitialViewForEditorAssistant,
     generateInitialView: generateInitialViewForEditorAssistant,
     generatingEditorTextLabel,
     generatingEditorTextLabel,
+    partialContentWarnLabel,
     generateActionButtons,
     generateActionButtons,
     headerIcon: headerIconForEditorAssistant,
     headerIcon: headerIconForEditorAssistant,
     headerText: headerTextForEditorAssistant,
     headerText: headerTextForEditorAssistant,
@@ -449,6 +450,7 @@ const AiAssistantSidebarSubstance: React.FC<AiAssistantSidebarSubstanceProps> =
                         {generatingAnswerMessage.content}
                         {generatingAnswerMessage.content}
                       </MessageCard>
                       </MessageCard>
                     )}
                     )}
+                    { isEditorAssistant && partialContentWarnLabel }
                     { messageLogs.length > 0 && (
                     { messageLogs.length > 0 && (
                       <div className="d-flex justify-content-center">
                       <div className="d-flex justify-content-center">
                         <span className="bg-body-tertiary text-body-secondary rounded-pill px-3 py-1" style={{ fontSize: 'smaller' }}>
                         <span className="bg-body-tertiary text-body-secondary rounded-pill px-3 py-1" style={{ fontSize: 'smaller' }}>

+ 256 - 0
apps/app/src/features/openai/client/services/editor-assistant/get-page-body-for-context.spec.ts

@@ -0,0 +1,256 @@
+import { Text } from '@codemirror/state';
+import type { UseCodeMirrorEditor } from '@growi/editor/dist/client/services/use-codemirror-editor';
+import {
+  describe,
+  it,
+  expect,
+  vi,
+  beforeEach,
+} from 'vitest';
+import { mockDeep, type DeepMockProxy } from 'vitest-mock-extended';
+
+import { getPageBodyForContext } from './get-page-body-for-context';
+
+describe('getPageBodyForContext', () => {
+  let mockEditor: DeepMockProxy<UseCodeMirrorEditor>;
+
+  // Helper function to create identifiable content where each character shows its position
+  const createPositionalContent = (length: number): string => {
+    return Array.from({ length }, (_, i) => (i % 10).toString()).join('');
+  };
+
+  beforeEach(() => {
+    vi.clearAllMocks();
+    mockEditor = mockDeep<UseCodeMirrorEditor>();
+  });
+
+  describe('Error handling and edge cases', () => {
+    it('should return undefined when editor is undefined', () => {
+      const result = getPageBodyForContext(undefined, 10, 10);
+      expect(result).toBeUndefined();
+    });
+
+    it('should handle missing view state (defaults cursor to 0)', () => {
+      const longContent = createPositionalContent(1000);
+      const realDoc = Text.of([longContent]);
+
+      mockEditor.getDoc.mockReturnValue(realDoc);
+      mockEditor.view = undefined;
+
+      const result = getPageBodyForContext(mockEditor, 100, 200);
+
+      // Should default cursor position to 0
+      // Available before cursor: 0 (cursor at start)
+      // Shortfall before: 100 - 0 = 100
+      // Chars after: 200 + 100 = 300
+      // Expected: start=0, end=0+300=300
+      const expectedContent = longContent.slice(0, 300);
+      expect(result).toEqual({
+        content: expectedContent,
+        isPartial: true,
+        startIndex: 0,
+        endIndex: 300,
+        totalLength: 1000,
+      });
+      expect(result?.content).toHaveLength(300);
+    });
+  });
+
+  describe('Short document handling', () => {
+    it('should return getDocString when document is short', () => {
+      // Create a real Text instance with short content
+      const shortText = 'short';
+      const realDoc = Text.of([shortText]); // length: 5, shorter than maxTotalLength of 20
+
+      mockEditor.getDoc.mockReturnValue(realDoc);
+      mockEditor.getDocString.mockReturnValue(shortText);
+
+      const result = getPageBodyForContext(mockEditor, 10, 10);
+
+      expect(result).toEqual({
+        content: shortText,
+        isPartial: false,
+        totalLength: 5,
+      });
+      expect(mockEditor.getDocString).toHaveBeenCalled();
+    });
+
+    it('should return full document when length equals max total length', () => {
+      const exactLengthText = createPositionalContent(150); // exactly 150 chars
+      const realDoc = Text.of([exactLengthText]);
+
+      mockEditor.getDoc.mockReturnValue(realDoc);
+      mockEditor.getDocString.mockReturnValue(exactLengthText);
+
+      const result = getPageBodyForContext(mockEditor, 50, 100); // total: 150
+
+      expect(result).toEqual({
+        content: exactLengthText,
+        isPartial: false,
+        totalLength: 150,
+      });
+      expect(mockEditor.getDocString).toHaveBeenCalled();
+    });
+
+    it('should return full document when length is less than max total length', () => {
+      const shortText = 'Short document'; // 14 chars
+      const realDoc = Text.of([shortText]);
+
+      mockEditor.getDoc.mockReturnValue(realDoc);
+      mockEditor.getDocString.mockReturnValue(shortText);
+
+      const result = getPageBodyForContext(mockEditor, 50, 100); // total: 150
+
+      expect(result).toEqual({
+        content: shortText,
+        isPartial: false,
+        totalLength: 14,
+      });
+    });
+  });
+
+  describe('Core shortfall compensation logic', () => {
+    it('should extract correct range when cursor is in middle (no shortfall)', () => {
+      const longContent = createPositionalContent(2000);
+      const realDoc = Text.of([longContent]);
+      const cursorPos = 1000;
+
+      mockEditor.getDoc.mockReturnValue(realDoc);
+
+      // Mock view with cursor at position 1000
+      if (mockEditor.view?.state?.selection?.main) {
+        Object.defineProperty(mockEditor.view.state.selection.main, 'head', { value: cursorPos });
+      }
+
+      const result = getPageBodyForContext(mockEditor, 200, 300);
+
+      // Expected: start=800, end=1300 (no shortfall needed)
+      const expectedContent = longContent.slice(800, 1300);
+      expect(result).toEqual({
+        content: expectedContent,
+        isPartial: true,
+        startIndex: 800,
+        endIndex: 1300,
+        totalLength: 2000,
+      });
+      expect(result?.content).toHaveLength(500); // 1300 - 800 = 500
+    });
+
+    it('should compensate shortfall when cursor is near document end', () => {
+      const longContent = createPositionalContent(1000);
+      const realDoc = Text.of([longContent]);
+      const cursorPos = 950; // Near end
+
+      mockEditor.getDoc.mockReturnValue(realDoc);
+
+      // Mock view with cursor at position 950
+      if (mockEditor.view?.state?.selection?.main) {
+        Object.defineProperty(mockEditor.view.state.selection.main, 'head', { value: cursorPos });
+      }
+
+      const result = getPageBodyForContext(mockEditor, 100, 200);
+
+      // Available after cursor: 1000 - 950 = 50
+      // Shortfall: 200 - 50 = 150
+      // Chars before: 100 + 150 = 250
+      // Expected: start=max(0, 950-250)=700, end=950+50=1000
+      const expectedContent = longContent.slice(700, 1000);
+      expect(result).toEqual({
+        content: expectedContent,
+        isPartial: true,
+        startIndex: 700,
+        endIndex: 1000,
+        totalLength: 1000,
+      });
+      expect(result?.content).toHaveLength(300); // 1000 - 700 = 300
+    });
+
+    it('should handle extreme case: cursor at document end', () => {
+      const longContent = createPositionalContent(1000);
+      const realDoc = Text.of([longContent]);
+      const cursorPos = 1000; // At very end
+
+      mockEditor.getDoc.mockReturnValue(realDoc);
+
+      // Mock view with cursor at position 1000
+      if (mockEditor.view?.state?.selection?.main) {
+        Object.defineProperty(mockEditor.view.state.selection.main, 'head', { value: cursorPos });
+      }
+
+      const result = getPageBodyForContext(mockEditor, 100, 200);
+
+      // Available after cursor: 0
+      // Shortfall: 200 - 0 = 200
+      // Chars before: 100 + 200 = 300
+      // Expected: start=max(0, 1000-300)=700, end=1000+0=1000
+      const expectedContent = longContent.slice(700, 1000);
+      expect(result).toEqual({
+        content: expectedContent,
+        isPartial: true,
+        startIndex: 700,
+        endIndex: 1000,
+        totalLength: 1000,
+      });
+      expect(result?.content).toHaveLength(300); // 1000 - 700 = 300
+    });
+
+    it('should handle cursor at document start with startPos boundary', () => {
+      const longContent = createPositionalContent(1000);
+      const realDoc = Text.of([longContent]);
+      const cursorPos = 0; // At start
+
+      mockEditor.getDoc.mockReturnValue(realDoc);
+
+      // Mock view with cursor at position 0
+      if (mockEditor.view?.state?.selection?.main) {
+        Object.defineProperty(mockEditor.view.state.selection.main, 'head', { value: cursorPos });
+      }
+
+      const result = getPageBodyForContext(mockEditor, 100, 200);
+
+      // Available before cursor: 0 (cursor at start)
+      // Shortfall before: 100 - 0 = 100
+      // Chars after: 200 + 100 = 300
+      // Expected: start=0, end=0+300=300
+      const expectedContent = longContent.slice(0, 300);
+      expect(result).toEqual({
+        content: expectedContent,
+        isPartial: true,
+        startIndex: 0,
+        endIndex: 300,
+        totalLength: 1000,
+      });
+      expect(result?.content).toHaveLength(300);
+    });
+
+    it('should handle truly extreme shortfall with cursor very near end', () => {
+      const longContent = createPositionalContent(1000);
+      const realDoc = Text.of([longContent]);
+      const cursorPos = 995; // Very near end
+
+      mockEditor.getDoc.mockReturnValue(realDoc);
+
+      // Mock view with cursor at position 995
+      if (mockEditor.view?.state?.selection?.main) {
+        Object.defineProperty(mockEditor.view.state.selection.main, 'head', { value: cursorPos });
+      }
+
+      const result = getPageBodyForContext(mockEditor, 50, 500); // Total: 550 < 1000
+
+      // Available after cursor: 1000 - 995 = 5
+      // Shortfall: 500 - 5 = 495
+      // Chars before: 50 + 495 = 545
+      // Expected: start=max(0, 995-545)=450, end=995+5=1000
+      const expectedContent = longContent.slice(450, 1000);
+      expect(result).toEqual({
+        content: expectedContent,
+        isPartial: true,
+        startIndex: 450,
+        endIndex: 1000,
+        totalLength: 1000,
+      });
+      expect(result?.content).toHaveLength(550); // 1000 - 450 = 550
+    });
+
+  });
+});

+ 74 - 0
apps/app/src/features/openai/client/services/editor-assistant/get-page-body-for-context.ts

@@ -0,0 +1,74 @@
+import type { UseCodeMirrorEditor } from '@growi/editor/dist/client/services/use-codemirror-editor';
+
+export type PageBodyContextResult = {
+  content: string;
+  isPartial: boolean;
+  startIndex?: number; // Only present when partial
+  endIndex?: number; // Only present when partial
+  totalLength: number; // Total length of the original document
+};
+
+/**
+ * Get page body text for AI context processing
+ * @param codeMirrorEditor - CodeMirror editor instance
+ * @param maxLengthBeforeCursor - Maximum number of characters to include before cursor position
+ * @param maxLengthAfterCursor - Maximum number of characters to include after cursor position
+ * @returns Page body context result with metadata, or undefined if editor is not available
+ */
+export const getPageBodyForContext = (
+    codeMirrorEditor: UseCodeMirrorEditor | undefined,
+    maxLengthBeforeCursor: number,
+    maxLengthAfterCursor: number,
+): PageBodyContextResult | undefined => {
+  const doc = codeMirrorEditor?.getDoc();
+  const length = doc?.length ?? 0;
+
+  if (length === 0 || !doc) {
+    return undefined;
+  }
+
+  const maxTotalLength = maxLengthBeforeCursor + maxLengthAfterCursor;
+
+  if (length > maxTotalLength) {
+    // Get cursor position
+    const cursorPos = codeMirrorEditor?.view?.state.selection.main.head ?? 0;
+
+    // Calculate how many characters are available before and after cursor
+    const availableBeforeCursor = cursorPos;
+    const availableAfterCursor = length - cursorPos;
+
+    // Calculate actual chars to take before and after cursor
+    const charsBeforeCursor = Math.min(maxLengthBeforeCursor, availableBeforeCursor);
+    const charsAfterCursor = Math.min(maxLengthAfterCursor, availableAfterCursor);
+
+    // Calculate shortfalls and redistribute
+    const shortfallBefore = maxLengthBeforeCursor - charsBeforeCursor;
+    const shortfallAfter = maxLengthAfterCursor - charsAfterCursor;
+
+    // Redistribute shortfalls
+    const finalCharsAfterCursor = Math.min(charsAfterCursor + shortfallBefore, availableAfterCursor);
+    const finalCharsBeforeCursor = Math.min(charsBeforeCursor + shortfallAfter, availableBeforeCursor);
+
+    // Calculate start and end positions
+    const startPos = Math.max(cursorPos - finalCharsBeforeCursor, 0);
+    const endPos = cursorPos + finalCharsAfterCursor;
+
+    const content = doc.slice(startPos, endPos).toString();
+
+    return {
+      content,
+      isPartial: true,
+      startIndex: startPos,
+      endIndex: endPos,
+      totalLength: length,
+    };
+  }
+
+  const content = codeMirrorEditor?.getDocString() ?? '';
+
+  return {
+    content,
+    isPartial: false,
+    totalLength: length,
+  };
+};

+ 80 - 17
apps/app/src/features/openai/client/services/editor-assistant/use-editor-assistant.tsx

@@ -13,6 +13,10 @@ import { useTranslation } from 'react-i18next';
 import { type Text as YText } from 'yjs';
 import { type Text as YText } from 'yjs';
 
 
 import { apiv3Post } from '~/client/util/apiv3-client';
 import { apiv3Post } from '~/client/util/apiv3-client';
+import { useIsEnableUnifiedMergeView } from '~/stores-universal/context';
+import { useCurrentPageId } from '~/stores/page';
+
+import type { AiAssistantHasId } from '../../../interfaces/ai-assistant';
 import {
 import {
   SseMessageSchema,
   SseMessageSchema,
   SseDetectedDiffSchema,
   SseDetectedDiffSchema,
@@ -20,20 +24,18 @@ import {
   type SseMessage,
   type SseMessage,
   type SseDetectedDiff,
   type SseDetectedDiff,
   type SseFinalized,
   type SseFinalized,
-} from '~/features/openai/interfaces/editor-assistant/sse-schemas';
-import { handleIfSuccessfullyParsed } from '~/features/openai/utils/handle-if-successfully-parsed';
-import { useIsEnableUnifiedMergeView } from '~/stores-universal/context';
-import { useCurrentPageId } from '~/stores/page';
-
-import type { AiAssistantHasId } from '../../../interfaces/ai-assistant';
+  type EditRequestBody,
+} from '../../../interfaces/editor-assistant/sse-schemas';
 import type { MessageLog } from '../../../interfaces/message';
 import type { MessageLog } from '../../../interfaces/message';
 import type { IThreadRelationHasId } from '../../../interfaces/thread-relation';
 import type { IThreadRelationHasId } from '../../../interfaces/thread-relation';
 import { ThreadType } from '../../../interfaces/thread-relation';
 import { ThreadType } from '../../../interfaces/thread-relation';
+import { handleIfSuccessfullyParsed } from '../../../utils/handle-if-successfully-parsed';
 import { AiAssistantDropdown } from '../../components/AiAssistant/AiAssistantSidebar/AiAssistantDropdown';
 import { AiAssistantDropdown } from '../../components/AiAssistant/AiAssistantSidebar/AiAssistantDropdown';
 import { QuickMenuList } from '../../components/AiAssistant/AiAssistantSidebar/QuickMenuList';
 import { QuickMenuList } from '../../components/AiAssistant/AiAssistantSidebar/QuickMenuList';
 import { useAiAssistantSidebar } from '../../stores/ai-assistant';
 import { useAiAssistantSidebar } from '../../stores/ai-assistant';
 import { useClientEngineIntegration, shouldUseClientProcessing } from '../client-engine-integration';
 import { useClientEngineIntegration, shouldUseClientProcessing } from '../client-engine-integration';
 
 
+import { getPageBodyForContext } from './get-page-body-for-context';
 import { performSearchReplace } from './search-replace-engine';
 import { performSearchReplace } from './search-replace-engine';
 
 
 interface CreateThread {
 interface CreateThread {
@@ -79,6 +81,7 @@ type UseEditorAssistant = () => {
   // Views
   // Views
   generateInitialView: GenerateInitialView,
   generateInitialView: GenerateInitialView,
   generatingEditorTextLabel?: JSX.Element,
   generatingEditorTextLabel?: JSX.Element,
+  partialContentWarnLabel?: JSX.Element,
   generateActionButtons: GenerateActionButtons,
   generateActionButtons: GenerateActionButtons,
   headerIcon: JSX.Element,
   headerIcon: JSX.Element,
   headerText: JSX.Element,
   headerText: JSX.Element,
@@ -120,6 +123,10 @@ export const useEditorAssistant: UseEditorAssistant = () => {
   const [selectedAiAssistant, setSelectedAiAssistant] = useState<AiAssistantHasId>();
   const [selectedAiAssistant, setSelectedAiAssistant] = useState<AiAssistantHasId>();
   const [selectedText, setSelectedText] = useState<string>();
   const [selectedText, setSelectedText] = useState<string>();
   const [isGeneratingEditorText, setIsGeneratingEditorText] = useState<boolean>(false);
   const [isGeneratingEditorText, setIsGeneratingEditorText] = useState<boolean>(false);
+  const [partialContentInfo, setPartialContentInfo] = useState<{
+    startIndex: number;
+    endIndex: number;
+  } | null>(null);
 
 
   const isTextSelected = useMemo(() => selectedText != null && selectedText.length !== 0, [selectedText]);
   const isTextSelected = useMemo(() => selectedText != null && selectedText.length !== 0, [selectedText]);
 
 
@@ -156,24 +163,41 @@ export const useEditorAssistant: UseEditorAssistant = () => {
   }, [selectedAiAssistant?._id]);
   }, [selectedAiAssistant?._id]);
 
 
   const postMessage: PostMessage = useCallback(async(threadId, formData) => {
   const postMessage: PostMessage = useCallback(async(threadId, formData) => {
-    const getPageBody = (): string | undefined => {
-      // TODO: Reduce to character limit
-      // refs: https://redmine.weseek.co.jp/issues/167688
-      return codeMirrorEditor?.getDoc();
-    };
+    // Clear partial content info on new request
+    setPartialContentInfo(null);
 
 
     // Disable UnifiedMergeView when a Form is submitted with UnifiedMergeView enabled
     // Disable UnifiedMergeView when a Form is submitted with UnifiedMergeView enabled
     mutateIsEnableUnifiedMergeView(false);
     mutateIsEnableUnifiedMergeView(false);
 
 
+    const pageBodyContext = getPageBodyForContext(codeMirrorEditor, 2000, 8000);
+
+    if (!pageBodyContext) {
+      throw new Error('Unable to get page body context');
+    }
+
+    // Store partial content info if applicable
+    if (pageBodyContext.isPartial && pageBodyContext.startIndex != null && pageBodyContext.endIndex != null) {
+      setPartialContentInfo({
+        startIndex: pageBodyContext.startIndex,
+        endIndex: pageBodyContext.endIndex,
+      });
+    }
+
+    const requestBody = {
+      threadId,
+      userMessage: formData.input,
+      selectedText,
+      pageBody: pageBodyContext.content,
+      ...(pageBodyContext.isPartial && {
+        isPageBodyPartial: pageBodyContext.isPartial,
+        partialPageBodyStartIndex: pageBodyContext.startIndex,
+      }),
+    } satisfies EditRequestBody;
+
     const response = await fetch('/_api/v3/openai/edit', {
     const response = await fetch('/_api/v3/openai/edit', {
       method: 'POST',
       method: 'POST',
       headers: { 'Content-Type': 'application/json' },
       headers: { 'Content-Type': 'application/json' },
-      body: JSON.stringify({
-        threadId,
-        userMessage: formData.input,
-        selectedText,
-        pageBody: getPageBody(),
-      }),
+      body: JSON.stringify(requestBody),
     });
     });
 
 
     return response;
     return response;
@@ -441,6 +465,44 @@ export const useEditorAssistant: UseEditorAssistant = () => {
     );
     );
   }, [isGeneratingEditorText, t]);
   }, [isGeneratingEditorText, t]);
 
 
+  const partialContentWarnLabel = useMemo(() => {
+    if (!partialContentInfo) {
+      return undefined;
+    }
+
+    // Use CodeMirror's built-in posToLine method for efficient line calculation
+    let isLineMode = true;
+    const getPositionNumber = (index: number): number => {
+      const doc = codeMirrorEditor?.getDoc();
+      if (!doc) return 1;
+
+      try {
+        // return line number if possible
+        return doc.lineAt(index).number;
+      }
+      catch {
+        // Fallback: return character index and switch to character mode
+        isLineMode = false;
+        return index + 1;
+      }
+    };
+
+    const startPosition = getPositionNumber(partialContentInfo.startIndex);
+    const endPosition = getPositionNumber(partialContentInfo.endIndex);
+
+    const translationKey = isLineMode
+      ? 'sidebar_ai_assistant.editor_assistant_long_context_warn_with_unit_line'
+      : 'sidebar_ai_assistant.editor_assistant_long_context_warn_with_unit_char';
+
+    return (
+      <div className="alert alert-warning py-2 px-3 mb-3" role="alert">
+        <small>
+          {t(translationKey, { startPosition, endPosition })}
+        </small>
+      </div>
+    );
+  }, [partialContentInfo, t, codeMirrorEditor]);
+
   return {
   return {
     createThread,
     createThread,
     postMessage,
     postMessage,
@@ -453,6 +515,7 @@ export const useEditorAssistant: UseEditorAssistant = () => {
     // Views
     // Views
     generateInitialView,
     generateInitialView,
     generatingEditorTextLabel,
     generatingEditorTextLabel,
+    partialContentWarnLabel,
     generateActionButtons,
     generateActionButtons,
     headerIcon,
     headerIcon,
     headerText,
     headerText,

+ 17 - 0
apps/app/src/features/openai/interfaces/editor-assistant/sse-schemas.ts

@@ -6,6 +6,23 @@ import { LlmEditorAssistantDiffSchema } from './llm-response-schemas';
 // SSE Schemas for Streaming Editor Assistant
 // SSE Schemas for Streaming Editor Assistant
 // -----------------------------------------------------------------------------
 // -----------------------------------------------------------------------------
 
 
+// Request schemas
+export const EditRequestBodySchema = z.object({
+  userMessage: z.string(),
+  pageBody: z.string(),
+  isPageBodyPartial: z.boolean().optional()
+    .describe('Whether the page body is a partial content'),
+  partialPageBodyStartIndex: z.number().optional()
+    .describe('0-based index for the start of the partial page body'),
+  selectedText: z.string().optional(),
+  selectedPosition: z.number().optional(),
+  threadId: z.string().optional(),
+});
+
+// Type definitions
+export type EditRequestBody = z.infer<typeof EditRequestBodySchema>;
+
+
 export const SseMessageSchema = z.object({
 export const SseMessageSchema = z.object({
   appendedMessage: z.string()
   appendedMessage: z.string()
     .describe('The message that should be appended to the chat window'),
     .describe('The message that should be appended to the chat window'),

+ 93 - 91
apps/app/src/features/openai/server/routes/edit/index.ts

@@ -16,7 +16,9 @@ import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-respo
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import { LlmEditorAssistantDiffSchema, LlmEditorAssistantMessageSchema } from '../../../interfaces/editor-assistant/llm-response-schemas';
 import { LlmEditorAssistantDiffSchema, LlmEditorAssistantMessageSchema } from '../../../interfaces/editor-assistant/llm-response-schemas';
-import type { SseDetectedDiff, SseFinalized, SseMessage } from '../../../interfaces/editor-assistant/sse-schemas';
+import type {
+  SseDetectedDiff, SseFinalized, SseMessage, EditRequestBody,
+} from '../../../interfaces/editor-assistant/sse-schemas';
 import { MessageErrorCode } from '../../../interfaces/message-error';
 import { MessageErrorCode } from '../../../interfaces/message-error';
 import ThreadRelationModel from '../../models/thread-relation';
 import ThreadRelationModel from '../../models/thread-relation';
 import { getOrCreateEditorAssistant } from '../../services/assistant';
 import { getOrCreateEditorAssistant } from '../../services/assistant';
@@ -40,14 +42,7 @@ const LlmEditorAssistantResponseSchema = z.object({
 }).describe('The response format for the editor assistant');
 }).describe('The response format for the editor assistant');
 
 
 
 
-type ReqBody = {
-  userMessage: string,
-  pageBody: string,
-  selectedText?: string,
-  threadId?: string,
-}
-
-type Req = Request<undefined, Response, ReqBody> & {
+type Req = Request<undefined, Response, EditRequestBody> & {
   user: IUserHasId,
   user: IUserHasId,
 }
 }
 
 
@@ -70,77 +65,75 @@ const withMarkdownCaution = `# IMPORTANT:
 `;
 `;
 
 
 function instruction(withMarkdown: boolean): string {
 function instruction(withMarkdown: boolean): string {
-  return `
-  # USER INTENT DETECTION:
-  First, analyze the user's message to determine their intent:
-  - **Consultation Type**: Questions, discussions, explanations, or advice seeking WITHOUT explicit request to edit/modify/generate text
-  - **Edit Type**: Clear requests to edit, modify, fix, generate, create, or write content
-
-  ## EXAMPLES OF USER INTENT:
-  ### Consultation Type Examples:
-  - "What do you think about this code?"
-  - "Please give me advice on this text structure"
-  - "Why is this error occurring?"
-  - "Is there a better approach?"
-  - "Can you explain how this works?"
-  - "What are the pros and cons of this method?"
-  - "How should I organize this document?"
-
-  ### Edit Type Examples:
-  - "Please fix the following"
-  - "Add a function that..."
-  - "Rewrite this section to..."
-  - "Correct the errors in this code"
-  - "Generate a new paragraph about..."
-  - "Modify this to include..."
-  - "Create a template for..."
-
-  # RESPONSE FORMAT:
-  ## For Consultation Type (discussion/advice only):
-  Respond with a JSON object containing ONLY message objects:
-  {
-    "contents": [
-      { "message": "Your thoughtful response to the user's question or consultation.\n\nYou can use multiple paragraphs as needed." }
-    ]
-  }
-
-  ## 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 changes or proposals.\n\n" },
-      {
-        "search": "exact existing content",
-        "replace": "new content",
-        "startLine": 42  // REQUIRED: line number (1-based) where search begins
-      },
-      { "message": "Additional explanation if needed." },
-      {
-        "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." }
-    ]
-  }
-
-  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 that should be written in the present or future tense and add two consecutive line feeds ('\n\n') at the end.
-  - Objects with a "message" key for explanatory text to the user if needed.
-  - 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 : ''}
-
-  # Multilingual Support:
-  Always provide messages in the same language as the user's request.`;
+  return `# RESPONSE FORMAT:
+
+## For Consultation Type (discussion/advice only):
+Respond with a JSON object containing ONLY message objects:
+{
+  "contents": [
+    { "message": "Your thoughtful response to the user's question or consultation.\n\nYou can use multiple paragraphs as needed." }
+  ]
+}
+
+## 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 changes or proposals.\n\n" },
+    {
+      "search": "exact existing content",
+      "replace": "new content",
+      "startLine": 42  // REQUIRED: line number (1-based) where search begins
+    },
+    { "message": "Additional explanation if needed." },
+    {
+      "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." }
+  ]
+}
+
+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 that should be written in the present or future tense and add two consecutive line feeds ('\n\n') at the end.
+- Objects with a "message" key for explanatory text to the user if needed.
+- 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 : ''}`;
 }
 }
 /* eslint-disable max-len */
 /* eslint-disable max-len */
 
 
+function instructionForContexts(args: Pick<EditRequestBody, 'pageBody' | 'isPageBodyPartial' | 'partialPageBodyStartIndex' | 'selectedText' | 'selectedPosition'>): string {
+  return `# Contexts:
+## ${args.isPageBodyPartial ? 'pageBodyPartial' : 'pageBody'}:
+
+\`\`\`markdown
+${args.pageBody}
+\`\`\`
+
+${args.isPageBodyPartial && args.partialPageBodyStartIndex != null
+    ? `- **partialPageBodyStartIndex**: ${args.partialPageBodyStartIndex ?? 0}`
+    : ''
+}
+
+${args.selectedText != null
+    ? `## selectedText: \n\n\`\`\`markdown\n${args.selectedText}\n\`\`\``
+    : ''
+}
+
+${args.selectedPosition != null
+    ? `- **selectedPosition**: ${args.selectedPosition}`
+    : ''
+}
+`;
+}
+
 /**
 /**
  * Create endpoint handlers for editor assistant
  * Create endpoint handlers for editor assistant
  */
  */
@@ -157,10 +150,18 @@ export const postMessageToEditHandlersFactory: PostMessageHandlersFactory = (cro
     body('pageBody')
     body('pageBody')
       .isString()
       .isString()
       .withMessage('pageBody must be string and not empty'),
       .withMessage('pageBody must be string and not empty'),
+    body('isPageBodyPartial')
+      .optional()
+      .isBoolean()
+      .withMessage('isPageBodyPartial must be boolean'),
     body('selectedText')
     body('selectedText')
       .optional()
       .optional()
       .isString()
       .isString()
       .withMessage('selectedText must be string'),
       .withMessage('selectedText must be string'),
+    body('selectedPosition')
+      .optional()
+      .isNumeric()
+      .withMessage('selectedPosition must be number'),
     body('threadId').optional().isString().withMessage('threadId must be string'),
     body('threadId').optional().isString().withMessage('threadId must be string'),
   ];
   ];
 
 
@@ -168,7 +169,10 @@ export const postMessageToEditHandlersFactory: PostMessageHandlersFactory = (cro
     accessTokenParser, loginRequiredStrictly, certifyAiService, validator, apiV3FormValidator,
     accessTokenParser, loginRequiredStrictly, certifyAiService, validator, apiV3FormValidator,
     async(req: Req, res: ApiV3Response) => {
     async(req: Req, res: ApiV3Response) => {
       const {
       const {
-        userMessage, pageBody, selectedText, threadId,
+        userMessage,
+        pageBody, isPageBodyPartial, partialPageBodyStartIndex,
+        selectedText, selectedPosition,
+        threadId,
       } = req.body;
       } = req.body;
 
 
       // Parameter check
       // Parameter check
@@ -227,22 +231,20 @@ export const postMessageToEditHandlersFactory: PostMessageHandlersFactory = (cro
         // Create stream
         // Create stream
         const stream = openaiClient.beta.threads.runs.stream(thread.id, {
         const stream = openaiClient.beta.threads.runs.stream(thread.id, {
           assistant_id: assistant.id,
           assistant_id: assistant.id,
+          additional_instructions: [
+            instruction(pageBody != null),
+            instructionForContexts({
+              pageBody,
+              isPageBodyPartial,
+              partialPageBodyStartIndex,
+              selectedText,
+              selectedPosition,
+            }),
+          ].join('\n'),
           additional_messages: [
           additional_messages: [
-            {
-              role: 'assistant',
-              content: instruction(pageBody != null),
-            },
             {
             {
               role: 'user',
               role: 'user',
-              content: `Current markdown content:
-\`\`\`markdown
-${pageBody}
-\`\`\`
-${selectedText != null
-    ? `Current selected text by user:\`\`\`markdown\n${selectedText}\n\`\`\``
-    : ''
-}
-User request: ${userMessage}`,
+              content: `User request: ${userMessage}`,
             },
             },
           ],
           ],
           response_format: zodResponseFormat(LlmEditorAssistantResponseSchema, 'editor_assistant_response'),
           response_format: zodResponseFormat(LlmEditorAssistantResponseSchema, 'editor_assistant_response'),

+ 65 - 0
apps/app/src/features/openai/server/services/assistant/editor-assistant.ts

@@ -6,6 +6,61 @@ import { AssistantType } from './assistant-types';
 import { getOrCreateAssistant } from './create-assistant';
 import { getOrCreateAssistant } from './create-assistant';
 import { instructionsForFileSearch, instructionsForInjectionCountermeasures } from './instructions/commons';
 import { instructionsForFileSearch, instructionsForInjectionCountermeasures } from './instructions/commons';
 
 
+
+/* eslint-disable max-len */
+const instructionsForUserIntentDetection = `# USER INTENT DETECTION:
+  First, analyze the user's message to determine their intent:
+  - **Consultation Type**: Questions, discussions, explanations, or advice seeking WITHOUT explicit request to edit/modify/generate text
+  - **Edit Type**: Clear requests to edit, modify, fix, generate, create, or write content
+
+  ## EXAMPLES OF USER INTENT:
+  ### Consultation Type Examples:
+  - "What do you think about this code?"
+  - "Please give me advice on this text structure"
+  - "Why is this error occurring?"
+  - "Is there a better approach?"
+  - "Can you explain how this works?"
+  - "What are the pros and cons of this method?"
+  - "How should I organize this document?"
+
+  ### Edit Type Examples:
+  - "Please fix the following"
+  - "Add a function that..."
+  - "Rewrite this section to..."
+  - "Generate a new paragraph about..."
+  - "Modify this to include..."
+  - "Translate this text to English"`;
+/* eslint-enable max-len */
+
+const instructionsForContexts = `## Editing Contexts
+
+The user will provide you with following contexts related to their markdown content.
+
+### Page body
+The main content of the page, which is written in markdown format. The uer is editing currently this content.
+
+- **pageBody**:
+  - The main content of the page, which is written in markdown format.
+
+- **pageBodyPartial**:
+  - A partial content of the page body, which is written in markdown format and around the cursor position.
+  - This is used when the whole page body is too large to process at once.
+
+- **partialPageBodyStartIndex**:
+  - The start index of the partial page body in the whole page body.
+  - This is expected to be used to provide **startLine** exactly.
+
+### Selected text
+
+- **selectedText**:
+  - The text selected by the user in the page body. The user is focusing on this text to edit.
+
+- **selectedPosition**:
+  - The position of the cursor at the selectedText in the whole page body.
+  - This is expected to be used to **selectedText** exactly and provide **startLine** exactly.
+`;
+
+
 let editorAssistant: OpenAI.Beta.Assistant | undefined;
 let editorAssistant: OpenAI.Beta.Assistant | undefined;
 
 
 export const getOrCreateEditorAssistant = async(): Promise<OpenAI.Beta.Assistant> => {
 export const getOrCreateEditorAssistant = async(): Promise<OpenAI.Beta.Assistant> => {
@@ -25,6 +80,16 @@ Your task is to help users edit their markdown content based on their requests.
 ${instructionsForInjectionCountermeasures}
 ${instructionsForInjectionCountermeasures}
 ---
 ---
 
 
+# Multilingual Support:
+Always provide messages in the same language as the user's request.
+---
+
+${instructionsForContexts}
+---
+
+${instructionsForUserIntentDetection}
+---
+
 ${instructionsForFileSearch}
 ${instructionsForFileSearch}
 `,
 `,
     /* eslint-enable max-len */
     /* eslint-enable max-len */

+ 1 - 1
packages/editor/src/client/components-internal/playground/Playground.tsx

@@ -68,7 +68,7 @@ export const Playground = (): JSX.Element => {
   // set handler to save with shortcut key
   // set handler to save with shortcut key
   const saveHandler = useCallback(() => {
   const saveHandler = useCallback(() => {
     // eslint-disable-next-line no-console
     // eslint-disable-next-line no-console
-    console.log({ doc: codeMirrorEditor?.getDoc() });
+    console.log({ doc: codeMirrorEditor?.getDocString() });
     toast.success('Saved.', { autoClose: 2000 });
     toast.success('Saved.', { autoClose: 2000 });
   }, [codeMirrorEditor]);
   }, [codeMirrorEditor]);
 
 

+ 5 - 1
packages/editor/src/client/services/use-codemirror-editor/use-codemirror-editor.ts

@@ -11,7 +11,8 @@ import { useAppendExtensions, type AppendExtensions } from './utils/append-exten
 import { useFocus, type Focus } from './utils/focus';
 import { useFocus, type Focus } from './utils/focus';
 import type { FoldDrawio } from './utils/fold-drawio';
 import type { FoldDrawio } from './utils/fold-drawio';
 import { useFoldDrawio } from './utils/fold-drawio';
 import { useFoldDrawio } from './utils/fold-drawio';
-import { useGetDoc, type GetDoc } from './utils/get-doc';
+import type { GetDocString } from './utils/get-doc';
+import { useGetDoc, type GetDoc, useGetDocString } from './utils/get-doc';
 import { useInitDoc, type InitDoc } from './utils/init-doc';
 import { useInitDoc, type InitDoc } from './utils/init-doc';
 import { useInsertMarkdownElements, type InsertMarkdowElements } from './utils/insert-markdown-elements';
 import { useInsertMarkdownElements, type InsertMarkdowElements } from './utils/insert-markdown-elements';
 import { useInsertPrefix, type InsertPrefix } from './utils/insert-prefix';
 import { useInsertPrefix, type InsertPrefix } from './utils/insert-prefix';
@@ -24,6 +25,7 @@ type UseCodeMirrorEditorUtils = {
   initDoc: InitDoc,
   initDoc: InitDoc,
   appendExtensions: AppendExtensions,
   appendExtensions: AppendExtensions,
   getDoc: GetDoc,
   getDoc: GetDoc,
+  getDocString: GetDocString,
   focus: Focus,
   focus: Focus,
   setCaretLine: SetCaretLine,
   setCaretLine: SetCaretLine,
   insertText: InsertText,
   insertText: InsertText,
@@ -65,6 +67,7 @@ export const useCodeMirrorEditor = (props?: UseCodeMirror): UseCodeMirrorEditor
   const initDoc = useInitDoc(view);
   const initDoc = useInitDoc(view);
   const appendExtensions = useAppendExtensions(view);
   const appendExtensions = useAppendExtensions(view);
   const getDoc = useGetDoc(view);
   const getDoc = useGetDoc(view);
+  const getDocString = useGetDocString(view);
   const focus = useFocus(view);
   const focus = useFocus(view);
   const setCaretLine = useSetCaretLine(view);
   const setCaretLine = useSetCaretLine(view);
   const insertText = useInsertText(view);
   const insertText = useInsertText(view);
@@ -79,6 +82,7 @@ export const useCodeMirrorEditor = (props?: UseCodeMirror): UseCodeMirrorEditor
     initDoc,
     initDoc,
     appendExtensions,
     appendExtensions,
     getDoc,
     getDoc,
+    getDocString,
     focus,
     focus,
     setCaretLine,
     setCaretLine,
     insertText,
     insertText,

+ 12 - 2
packages/editor/src/client/services/use-codemirror-editor/utils/get-doc.ts

@@ -1,13 +1,23 @@
 import { useCallback } from 'react';
 import { useCallback } from 'react';
 
 
+import { Text } from '@codemirror/state';
 import type { EditorView } from '@codemirror/view';
 import type { EditorView } from '@codemirror/view';
 
 
-export type GetDoc = () => string;
+export type GetDoc = () => Text;
+export type GetDocString = () => string;
 
 
 export const useGetDoc = (view?: EditorView): GetDoc => {
 export const useGetDoc = (view?: EditorView): GetDoc => {
 
 
   return useCallback(() => {
   return useCallback(() => {
-    return view?.state.doc.toString() ?? '';
+    return view?.state.doc ?? Text.empty;
+  }, [view]);
+
+};
+
+export const useGetDocString = (view?: EditorView): GetDocString => {
+
+  return useCallback(() => {
+    return (view?.state.doc ?? Text.empty).toString();
   }, [view]);
   }, [view]);
 
 
 };
 };

+ 3 - 0
pnpm-lock.yaml

@@ -770,6 +770,9 @@ importers:
       '@apidevtools/swagger-parser':
       '@apidevtools/swagger-parser':
         specifier: ^10.1.1
         specifier: ^10.1.1
         version: 10.1.1(openapi-types@12.1.3)
         version: 10.1.1(openapi-types@12.1.3)
+      '@codemirror/state':
+        specifier: ^6.5.2
+        version: 6.5.2
       '@emoji-mart/data':
       '@emoji-mart/data':
         specifier: ^1.2.1
         specifier: ^1.2.1
         version: 1.2.1
         version: 1.2.1