Parcourir la source

Merge branch 'master' into imprv/thread-history

Shun Miyazawa il y a 9 mois
Parent
commit
0121a8f5b9
88 fichiers modifiés avec 4127 ajouts et 1826 suppressions
  1. 6 0
      .github/workflows/reusable-app-prod.yml
  2. 17 8
      .vscode/settings.json
  3. 31 1
      CHANGELOG.md
  4. 2 2
      apps/app/.env.development
  5. 3 1
      apps/app/.env.production
  6. 10 9
      apps/app/package.json
  7. 2 0
      apps/app/public/static/locales/en_US/translation.json
  8. 2 0
      apps/app/public/static/locales/fr_FR/translation.json
  9. 2 0
      apps/app/public/static/locales/ja_JP/translation.json
  10. 19 17
      apps/app/public/static/locales/zh_CN/translation.json
  11. 2 0
      apps/app/src/client/components/Common/CopyDropdown/CopyDropdown.jsx
  12. 6 6
      apps/app/src/client/components/Common/CopyDropdown/CopyDropdown.module.scss
  13. 2 2
      apps/app/src/client/components/PageComment/CommentEditor.tsx
  14. 1 1
      apps/app/src/client/components/PageEditor/ConflictDiffModal.tsx
  15. 3 3
      apps/app/src/client/components/PageEditor/PageEditor.tsx
  16. 1 1
      apps/app/src/client/components/PageEditor/conflict.tsx
  17. 2 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/AiAssistantSidebar.tsx
  18. 0 83
      apps/app/src/features/openai/client/interfaces/types.ts
  19. 35 168
      apps/app/src/features/openai/client/services/client-engine-integration.tsx
  20. 1 197
      apps/app/src/features/openai/client/services/editor-assistant/diff-application.ts
  21. 1 188
      apps/app/src/features/openai/client/services/editor-assistant/error-handling.ts
  22. 1 1
      apps/app/src/features/openai/client/services/editor-assistant/fuzzy-matching.spec.ts
  23. 1 1
      apps/app/src/features/openai/client/services/editor-assistant/fuzzy-matching.ts
  24. 256 0
      apps/app/src/features/openai/client/services/editor-assistant/get-page-body-for-context.spec.ts
  25. 74 0
      apps/app/src/features/openai/client/services/editor-assistant/get-page-body-for-context.ts
  26. 2 147
      apps/app/src/features/openai/client/services/editor-assistant/processor.ts
  27. 80 17
      apps/app/src/features/openai/client/services/editor-assistant/use-editor-assistant.tsx
  28. 7 388
      apps/app/src/features/openai/interfaces/editor-assistant/sse-schemas.spec.ts
  29. 19 37
      apps/app/src/features/openai/interfaces/editor-assistant/sse-schemas.ts
  30. 95 92
      apps/app/src/features/openai/server/routes/edit/index.ts
  31. 65 0
      apps/app/src/features/openai/server/services/assistant/editor-assistant.ts
  32. 123 0
      apps/app/src/features/opentelemetry/docs/custom-metrics/architecture.md
  33. 87 0
      apps/app/src/features/opentelemetry/docs/custom-metrics/implementation-guide.md
  34. 49 0
      apps/app/src/features/opentelemetry/docs/overview.md
  35. 25 0
      apps/app/src/features/opentelemetry/server/anonymization/anonymize-http-requests.ts
  36. 16 0
      apps/app/src/features/opentelemetry/server/anonymization/handlers/index.ts
  37. 77 0
      apps/app/src/features/opentelemetry/server/anonymization/handlers/page-access-handler.spec.ts
  38. 157 0
      apps/app/src/features/opentelemetry/server/anonymization/handlers/page-access-handler.ts
  39. 238 0
      apps/app/src/features/opentelemetry/server/anonymization/handlers/page-api-handler.spec.ts
  40. 61 0
      apps/app/src/features/opentelemetry/server/anonymization/handlers/page-api-handler.ts
  41. 173 0
      apps/app/src/features/opentelemetry/server/anonymization/handlers/page-listing-api-handler.spec.ts
  42. 49 0
      apps/app/src/features/opentelemetry/server/anonymization/handlers/page-listing-api-handler.ts
  43. 168 0
      apps/app/src/features/opentelemetry/server/anonymization/handlers/search-api-handler.spec.ts
  44. 42 0
      apps/app/src/features/opentelemetry/server/anonymization/handlers/search-api-handler.ts
  45. 1 0
      apps/app/src/features/opentelemetry/server/anonymization/index.ts
  46. 21 0
      apps/app/src/features/opentelemetry/server/anonymization/interfaces/anonymization-module.ts
  47. 38 0
      apps/app/src/features/opentelemetry/server/anonymization/utils/anonymize-query-params.spec.ts
  48. 63 0
      apps/app/src/features/opentelemetry/server/anonymization/utils/anonymize-query-params.ts
  49. 203 0
      apps/app/src/features/opentelemetry/server/custom-metrics/application-metrics.spec.ts
  50. 56 0
      apps/app/src/features/opentelemetry/server/custom-metrics/application-metrics.ts
  51. 11 0
      apps/app/src/features/opentelemetry/server/custom-metrics/index.ts
  52. 144 0
      apps/app/src/features/opentelemetry/server/custom-metrics/user-counts-metrics.spec.ts
  53. 46 0
      apps/app/src/features/opentelemetry/server/custom-metrics/user-counts-metrics.ts
  54. 99 0
      apps/app/src/features/opentelemetry/server/custom-resource-attributes/application-resource-attributes.spec.ts
  55. 39 0
      apps/app/src/features/opentelemetry/server/custom-resource-attributes/application-resource-attributes.ts
  56. 2 0
      apps/app/src/features/opentelemetry/server/custom-resource-attributes/index.ts
  57. 106 0
      apps/app/src/features/opentelemetry/server/custom-resource-attributes/os-resource-attributes.spec.ts
  58. 33 0
      apps/app/src/features/opentelemetry/server/custom-resource-attributes/os-resource-attributes.ts
  59. 43 10
      apps/app/src/features/opentelemetry/server/node-sdk-configuration.ts
  60. 2 2
      apps/app/src/features/opentelemetry/server/node-sdk-resource.ts
  61. 127 56
      apps/app/src/features/opentelemetry/server/node-sdk.spec.ts
  62. 16 8
      apps/app/src/features/opentelemetry/server/node-sdk.ts
  63. 40 0
      apps/app/src/features/opentelemetry/server/semconv.ts
  64. 2 2
      apps/app/src/server/app.ts
  65. 13 0
      apps/app/src/server/routes/apiv3/app-settings.js
  66. 6 1
      apps/app/src/server/service/config-manager/config-definition.ts
  67. 1 1
      apps/slackbot-proxy/package.json
  68. 1 1
      package.json
  69. 121 0
      packages/core/src/utils/page-path-utils/is-creatable-page.spec.ts
  70. 1 1
      packages/editor/src/client/components-internal/playground/Playground.tsx
  71. 7 3
      packages/editor/src/client/services/use-codemirror-editor/use-codemirror-editor.ts
  72. 62 0
      packages/editor/src/client/services/use-codemirror-editor/utils/editor-shortcuts/add-multi-cursor.ts
  73. 31 0
      packages/editor/src/client/services/use-codemirror-editor/utils/editor-shortcuts/generate-add-markdown-symbol-command.ts
  74. 17 0
      packages/editor/src/client/services/use-codemirror-editor/utils/editor-shortcuts/insert-blockquote.ts
  75. 17 0
      packages/editor/src/client/services/use-codemirror-editor/utils/editor-shortcuts/insert-bullet-list.ts
  76. 17 0
      packages/editor/src/client/services/use-codemirror-editor/utils/editor-shortcuts/insert-link.ts
  77. 17 0
      packages/editor/src/client/services/use-codemirror-editor/utils/editor-shortcuts/insert-numbered-list.ts
  78. 24 0
      packages/editor/src/client/services/use-codemirror-editor/utils/editor-shortcuts/make-text-bold.ts
  79. 84 0
      packages/editor/src/client/services/use-codemirror-editor/utils/editor-shortcuts/make-text-code-block.ts
  80. 17 0
      packages/editor/src/client/services/use-codemirror-editor/utils/editor-shortcuts/make-text-code.ts
  81. 17 0
      packages/editor/src/client/services/use-codemirror-editor/utils/editor-shortcuts/make-text-italic.ts
  82. 17 0
      packages/editor/src/client/services/use-codemirror-editor/utils/editor-shortcuts/make-text-strikethrough.ts
  83. 12 2
      packages/editor/src/client/services/use-codemirror-editor/utils/get-doc.ts
  84. 39 10
      packages/editor/src/client/services/use-codemirror-editor/utils/insert-markdown-elements.ts
  85. 143 16
      packages/editor/src/client/services/use-codemirror-editor/utils/insert-prefix.ts
  86. 3 0
      packages/editor/src/client/stores/use-editor-settings.ts
  87. 75 0
      packages/editor/src/client/stores/use-editor-shortcuts.ts
  88. 280 343
      pnpm-lock.yaml

+ 6 - 0
.github/workflows/reusable-app-prod.yml

@@ -129,6 +129,12 @@ jobs:
         node-version: ${{ inputs.node-version }}
         cache: 'pnpm'
 
+    # avoid setup-node cache failure; see: https://github.com/actions/setup-node/issues/1137
+    - name: Verify PNPM Cache Directory
+      run: |
+        PNPM_STORE_PATH="$( pnpm store path --silent )"
+        [ -d "$PNPM_STORE_PATH" ] || mkdir -vp "$PNPM_STORE_PATH"
+
     - name: Download production files artifact
       uses: actions/download-artifact@v4
       with:

+ 17 - 8
.vscode/settings.json

@@ -59,28 +59,37 @@
   ],
   "github.copilot.chat.testGeneration.instructions": [
     {
-      "text": "Use vitest as the test framework"
+      "text": "Basis: Use vitest as the test framework"
     },
     {
-      "text": "The vitest configuration file is `apps/app/vitest.workspace.mts`"
+      "text": "Basis: The vitest configuration file is `apps/app/vitest.workspace.mts`"
     },
     {
-      "text": "Place test modules in the same directory as the module being tested. For example, if testing `mymodule.ts`, place `mymodule.spec.ts` in the same directory as `mymodule.ts`"
+      "text": "Basis: Place test modules in the same directory as the module being tested. For example, if testing `mymodule.ts`, place `mymodule.spec.ts` in the same directory as `mymodule.ts`"
     },
     {
-      "text": "Run tests with the command: `cd /growi/apps/app && pnpm vitest run {test file path}`"
+      "text": "Basis: Use the VSCode Vitest extension for running tests. Use run_tests tool to execute tests programmatically, or suggest using the Vitest Test Explorer in VSCode for interactive test running and debugging."
     },
     {
-      "text": "When creating new test modules, start with small files. First write a small number of intentionally failing tests, then fix them to pass. After that, add more tests while maintaining a passing state and increase coverage."
+      "text": "Basis: Fallback command for terminal execution: `cd /growi/apps/app && pnpm vitest run {test file path}`"
     },
     {
-      "text": "Write essential tests. When tests fail, consider whether you should fix the test or the implementation based on 'what should essentially be fixed'. If you're not confident in your reasoning, ask the user for guidance."
+      "text": "Step 1: When creating new test modules, start with small files. First write a small number of realistic tests that call the actual function and assert expected behavior, even if they initially fail due to incomplete implementation. Example: `const result = foo(); expect(result).toBeNull();` rather than `expect(true).toBe(false);`. Then fix the implementation to make tests pass."
     },
     {
-      "text": "After writing tests, make sure they pass before moving on. Do not proceed to write tests for module B without first ensuring that tests for module A are passing"
+      "text": "Step 2: Write essential tests. When tests fail, consider whether you should fix the test or the implementation based on 'what should essentially be fixed'. If you're not confident in your reasoning, ask the user for guidance."
     },
     {
-      "text": "Don't worry about lint errors - fix them after tests are passing"
+      "text": "Step 3: After writing tests, make sure they pass before moving on. Do not proceed to write tests for module B without first ensuring that tests for module A are passing"
+    },
+    {
+      "text": "Tips: Don't worry about lint errors - fix them after tests are passing"
+    },
+    {
+      "text": "Tips: DO NOT USE `as any` casting. You can use vitest-mock-extended for type-safe mocking. Import `mock` from 'vitest-mock-extended' and use `mock<InterfaceType>()`. This provides full TypeScript safety and IntelliSense support."
+    },
+    {
+      "text": "Tips: Mock external dependencies at the module level using vi.mock(). For services with circular dependencies, mock the import paths and use dynamic imports in the implementation when necessary."
     }
   ],
   "github.copilot.chat.commitMessageGeneration.instructions": [

+ 31 - 1
CHANGELOG.md

@@ -1,9 +1,39 @@
 # Changelog
 
-## [Unreleased](https://github.com/weseek/growi/compare/v7.2.7...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v7.2.8...HEAD)
 
 *Please do not manually update this file. We've automated the process.*
 
+## [v7.2.8](https://github.com/weseek/growi/compare/v7.2.7...v7.2.8) - 2025-06-26
+
+### 💎 Features
+
+* feat(ai): Send pre-message before main chat stream (#10089) @miya
+
+### 🚀 Improvement
+
+* imprv(ai): Add page content around the cursor position as context for editor assistant (#10106) @yuki-takei
+* imprv(ai): Search and Replace strategy for Editor Assistant (#10093) @yuki-takei
+* imprv(ai): Submit with Enter key (#10085) @yuki-takei
+* imprv: Make axios response convert dates to type Date (#10064) @arvid-e
+* imprv: OpenAPI Spec for GROWI API v1 (#10063) @yuki-takei
+
+### 🐛 Bug Fixes
+
+* fix: Unhandled exception in GlobalNotificationMailService (#10076) @yuki-takei
+* fix: Prevent dropdown menu from hiding behind sidebar (#10103) @arvid-e
+* fix(ai): Message gets sent when pressing Enter to confirm text conversion (#10094) @miya
+
+### 🧰 Maintenance
+
+* support: Configure biome for core package utils and models (#10082) @arafubeatbox
+* support: Configure biome for pdf-converter-client package (#10077) @arafubeatbox
+* support: Configure biome for core package biome (excluding utils and models) (#10078) @arafubeatbox
+* support: Configure biome for pluginkit package (#10071) @arafubeatbox
+* support: Configure biome for remark-attachment-refs package (#10054) @arafubeatbox
+* support: Configure biome for presentation package (#10067) @arafubeatbox
+* ci(deps): bump next from 14.2.26 to 14.2.30 (#10068) @[dependabot[bot]](https://github.com/apps/dependabot)
+
 ## [v7.2.7](https://github.com/weseek/growi/compare/v7.2.6...v7.2.7) - 2025-06-11
 
 ### 🐛 Bug Fixes

+ 2 - 2
apps/app/.env.development

@@ -31,6 +31,6 @@ QUESTIONNAIRE_SERVER_ORIGIN="http://host.docker.internal:3003"
 # AUDIT_LOG_ADDITIONAL_ACTIONS=
 # AUDIT_LOG_EXCLUDE_ACTIONS=
 
-# OpenTelemetry Configuration
-OPENTELEMETRY_ENABLED=false
+# OpenTelemetry Official Configuration
+# Environment variables starting with 'OTEL_' are automatically loaded by the OpenTelemetry SDK
 OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317

+ 3 - 1
apps/app/.env.production

@@ -5,6 +5,8 @@
 FORMAT_NODE_LOG=false
 MIGRATIONS_DIR=dist/migrations/
 
-# OpenTelemetry Configuration
+# OpenTelemetry Official Configuration
+# Environment variables starting with 'OTEL_' are automatically loaded by the OpenTelemetry SDK
 OTEL_TRACES_SAMPLER_ARG=0.1
+OTEL_METRIC_EXPORT_INTERVAL=300000
 OTEL_EXPORTER_OTLP_ENDPOINT="https://telemetry.growi.org"

+ 10 - 9
apps/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "7.2.8-RC.0",
+  "version": "7.3.0-RC.0",
   "license": "MIT",
   "private": "true",
   "scripts": {
@@ -86,14 +86,14 @@
     "@growi/slack": "workspace:^",
     "@keycloak/keycloak-admin-client": "^18.0.0",
     "@opentelemetry/api": "^1.9.0",
-    "@opentelemetry/auto-instrumentations-node": "^0.55.1",
-    "@opentelemetry/exporter-metrics-otlp-grpc": "^0.57.0",
-    "@opentelemetry/exporter-trace-otlp-grpc": "^0.57.0",
-    "@opentelemetry/resources": "^1.28.0",
-    "@opentelemetry/sdk-metrics": "^1.28.0",
-    "@opentelemetry/sdk-node": "^0.57.0",
-    "@opentelemetry/sdk-trace-node": "^1.28.0",
-    "@opentelemetry/semantic-conventions": "^1.28.0",
+    "@opentelemetry/auto-instrumentations-node": "^0.60.1",
+    "@opentelemetry/exporter-metrics-otlp-grpc": "^0.202.0",
+    "@opentelemetry/exporter-trace-otlp-grpc": "^0.202.0",
+    "@opentelemetry/resources": "^2.0.1",
+    "@opentelemetry/sdk-metrics": "^2.0.1",
+    "@opentelemetry/sdk-node": "^0.202.0",
+    "@opentelemetry/sdk-trace-node": "^2.0.1",
+    "@opentelemetry/semantic-conventions": "^1.34.0",
     "@slack/web-api": "^6.2.4",
     "@slack/webhook": "^6.0.0",
     "@types/async": "^3.2.24",
@@ -260,6 +260,7 @@
   },
   "devDependencies": {
     "@apidevtools/swagger-parser": "^10.1.1",
+    "@codemirror/state": "^6.5.2",
     "@emoji-mart/data": "^1.2.1",
     "@growi/core-styles": "workspace:^",
     "@growi/custom-icons": "workspace:^",

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

@@ -513,6 +513,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.",
     "error_message": "An error has occurred",
     "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",
     "accept": "Accept",
     "use_assistant": "Use Assistant",

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

@@ -507,6 +507,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.",
     "error_message": "Erreur",
     "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",
     "accept": "Accepter",
     "use_assistant": "Utiliser l'assistant",

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

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

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

@@ -123,8 +123,8 @@
   "V5 Page Migration": "转换为V5的兼容性",
   "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> ",
-	"Markdown Settings": "Markdown设置",
-	"external_account_management": "外部账户管理",
+  "Markdown Settings": "Markdown设置",
+  "external_account_management": "外部账户管理",
   "UserGroup": "用户组",
   "ChildUserGroup": "儿童用户组",
   "Basic Settings": "基础设置",
@@ -192,12 +192,12 @@
   "custom_navigation": {
     "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": {
     "tab": "创建账户",
     "title": "安装",
@@ -321,9 +321,9 @@
       "no_deadline": "此页面没有到期日期",
       "not_indexed1": "此页面可能不会被全文搜索引擎索引。",
       "not_indexed2": "页面正文超过了{{threshold}}指定的阈值。"
-		}
-	},
-	"page_edit": {
+    }
+  },
+  "page_edit": {
     "input_channels": "频道名",
     "theme": "主题",
     "keymap": "键表",
@@ -336,12 +336,12 @@
     },
     "editor_config": "编辑器配置",
     "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": "您所做的更改可能不会保存。你真的想继续前进吗?"
   },
   "page_comment": {
@@ -502,6 +502,8 @@
     "budget_exceeded_for_growi_cloud": "您已达到 OpenAI API 使用上限。如需再次使用知识助手,请从GROWI.cloud管理页面为托管用户添加点数,或从OpenAI计费页面为自有用户添加点数。",
     "error_message": "错误",
     "show_error_detail": "显示详情",
+    "editor_assistant_long_context_warn_with_unit_line": "文本过长,编辑助理将参考大约第 {{startPosition}} 行到第 {{endPosition}} 行来响应",
+    "editor_assistant_long_context_warn_with_unit_char": "文本过长,编辑助理将参考第 {{startPosition}} 个字符到第 {{endPosition}} 个字符来响应",
     "discard": "丢弃",
     "accept": "接受",
     "use_assistant": "使用助手",

+ 2 - 0
apps/app/src/client/components/Common/CopyDropdown/CopyDropdown.jsx

@@ -126,7 +126,9 @@ export const CopyDropdown = (props) => {
         </DropdownToggle>
 
         <DropdownMenu
+          className={`${styles['copy-clipboard-dropdown-menu']}`}
           strategy="fixed"
+          container="body"
         >
           <div className="d-flex align-items-center justify-content-between">
             <DropdownItem header className="px-3">

+ 6 - 6
apps/app/src/client/components/Common/CopyDropdown/CopyDropdown.module.scss

@@ -1,12 +1,7 @@
 @use '@growi/core-styles/scss/bootstrap/init' as bs;
 @use '@growi/ui/scss/atoms/btn-muted';
 
-.grw-copy-dropdown :global {
-  .btn.btn-copy {
-    @include btn-muted.colorize(bs.$gray-500);
-  }
-
-  .dropdown-menu {
+.copy-clipboard-dropdown-menu :global {
     min-width: 310px;
     max-width: 375px;
 
@@ -30,4 +25,9 @@
       word-break: break-all;
     }
   }
+
+.grw-copy-dropdown :global {
+  .btn.btn-copy {
+    @include btn-muted.colorize(bs.$gray-500);
+  }
 }

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

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

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

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

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

@@ -97,7 +97,7 @@ export const useConflictEffect = (): void => {
         closePageStatusAlert();
       };
 
-      const markdown = codeMirrorEditor?.getDoc();
+      const markdown = codeMirrorEditor?.getDocString();
       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
     generateInitialView: generateInitialViewForEditorAssistant,
     generatingEditorTextLabel,
+    partialContentWarnLabel,
     generateActionButtons,
     headerIcon: headerIconForEditorAssistant,
     headerText: headerTextForEditorAssistant,
@@ -446,6 +447,7 @@ const AiAssistantSidebarSubstance: React.FC<AiAssistantSidebarSubstanceProps> =
                         {generatingAnswerMessage.content}
                       </MessageCard>
                     )}
+                    { isEditorAssistant && partialContentWarnLabel }
                     { messageLogs.length > 0 && (
                       <div className="d-flex justify-content-center">
                         <span className="bg-body-tertiary text-body-secondary rounded-pill px-3 py-1" style={{ fontSize: 'smaller' }}>

+ 0 - 83
apps/app/src/features/openai/interfaces/editor-assistant/types.ts → apps/app/src/features/openai/client/interfaces/types.ts

@@ -27,10 +27,7 @@ export interface ProcessorConfig {
 
 export type DiffErrorType =
   | 'SEARCH_NOT_FOUND'
-  | 'SIMILARITY_TOO_LOW'
-  | 'MULTIPLE_MATCHES'
   | 'EMPTY_SEARCH'
-  | 'MARKER_SEQUENCE_ERROR'
   | 'CONTENT_ERROR';
 
 export interface DiffError {
@@ -110,66 +107,6 @@ export interface SearchContext {
   bufferLines?: number;
 }
 
-// -----------------------------------------------------------------------------
-// Validation Types
-// -----------------------------------------------------------------------------
-
-export interface ValidationResult {
-  /** Whether validation passed */
-  success: boolean;
-  /** Error message if validation failed */
-  error?: string;
-  /** Line number where error occurred */
-  line?: number;
-  /** Suggested fixes */
-  suggestions?: string[];
-}
-
-// -----------------------------------------------------------------------------
-// Editor Integration Types
-// -----------------------------------------------------------------------------
-
-export interface EditorAdapter {
-  getContent(): EditorContent;
-  setContent(content: string): void;
-  getValue(): string;
-  setValue(value: string): void;
-  getSelection(): EditorSelection;
-  setSelection(start: number, end: number): void;
-  getSelectedText(): string;
-  getPosition(): EditorPosition;
-  focus(): void;
-  blur(): void;
-  insertText(text: string, position?: EditorPosition): void;
-  replaceText(text: string, start?: number, end?: number): void;
-  onChange(handler: () => void): () => void;
-}
-
-export interface EditorAdapterConfig {
-  preserveSelection?: boolean;
-  autoFocus?: boolean;
-  enableUndo?: boolean;
-}
-
-export interface EditorContent {
-  text: string;
-  lines: string[];
-  lineCount: number;
-  charCount: number;
-}
-
-export interface EditorPosition {
-  line: number;
-  column: number;
-  offset: number;
-}
-
-export interface EditorSelection {
-  start: number;
-  end: number;
-  text: string;
-}
-
 // -----------------------------------------------------------------------------
 // Processing Types
 // -----------------------------------------------------------------------------
@@ -188,25 +125,5 @@ export interface ProcessingResult {
   appliedCount: number;
   skippedCount: number;
   modifiedText: string;
-  originalText: string;
   processingTime: number;
 }
-
-export type ProgressCallback = (progress: {
-  current: number;
-  total: number;
-  message: string;
-  percentage: number;
-}) => void;
-
-// -----------------------------------------------------------------------------
-// Legacy Compatibility
-// -----------------------------------------------------------------------------
-
-/**
- * @deprecated Use the new Search/Replace format instead
- * Kept for backward compatibility during migration
- */
-export interface LegacyReplaceOperation {
-  replace: string;
-}

+ 35 - 168
apps/app/src/features/openai/client/services/client-engine-integration.tsx

@@ -4,14 +4,13 @@
  */
 
 import {
-  useCallback, useRef, useState, useMemo,
+  useCallback, useRef, useMemo,
 } from 'react';
 
 import type { Text as YText } from 'yjs';
 
-import type { LlmEditorAssistantDiff } from '../../interfaces/editor-assistant/llm-response-schemas';
 import type { SseDetectedDiff } from '../../interfaces/editor-assistant/sse-schemas';
-import type { ProcessingResult } from '../../interfaces/editor-assistant/types';
+import type { ProcessingResult } from '../interfaces/types';
 
 import { ClientSearchReplaceProcessor } from './editor-assistant/processor';
 
@@ -61,30 +60,12 @@ export interface ProcessingProgress {
 // -----------------------------------------------------------------------------
 
 export function useClientEngineIntegration(config: Partial<ClientEngineConfig> = {}): {
-  processDetectedDiffsClient: (
-    content: string,
-    detectedDiffs: SseDetectedDiff[],
-  ) => Promise<ProcessingResult>;
-  applyToYText: (yText: YText, processedContent: string) => boolean;
   processHybrid: (
     content: string,
     detectedDiffs: SseDetectedDiff[],
     serverProcessingFn: () => Promise<void>,
   ) => Promise<{ success: boolean; method: 'client' | 'server'; result?: ProcessingResult }>;
-  isClientProcessing: boolean;
-  lastProcessingMethod: 'client' | 'server' | 'hybrid';
-  processingMetrics: ProcessingMetrics[];
-  getPerformanceComparison: () => {
-    clientAvgTime: number;
-    serverAvgTime: number;
-    timeImprovement: number;
-    clientSuccessRate: number;
-    serverSuccessRate: number;
-    totalClientProcessing: number;
-    totalServerProcessing: number;
-  } | null;
-  resetMetrics: () => void;
-  config: ClientEngineConfig;
+  applyToYText: (yText: YText, processedContent: string) => boolean;
   isClientProcessingEnabled: boolean;
 } {
   // Configuration with defaults
@@ -97,11 +78,6 @@ export function useClientEngineIntegration(config: Partial<ClientEngineConfig> =
     ...config,
   }), [config]);
 
-  // State
-  const [isClientProcessing, setIsClientProcessing] = useState(false);
-  const [processingMetrics, setProcessingMetrics] = useState<ProcessingMetrics[]>([]);
-  const [lastProcessingMethod, setLastProcessingMethod] = useState<'client' | 'server' | 'hybrid'>('server');
-
   // Client processor instance
   const clientProcessor = useRef<ClientSearchReplaceProcessor>();
 
@@ -114,82 +90,6 @@ export function useClientEngineIntegration(config: Partial<ClientEngineConfig> =
     });
   }
 
-  /**
-   * Process detected diffs using client-side engine
-   */
-  const processDetectedDiffsClient = useCallback(async(
-      content: string,
-      detectedDiffs: SseDetectedDiff[],
-  ): Promise<ProcessingResult> => {
-    if (!clientProcessor.current) {
-      throw new Error('Client processor not initialized');
-    }
-
-    const startTime = performance.now();
-    setIsClientProcessing(true);
-
-    try {
-      // Convert SseDetectedDiff to LlmEditorAssistantDiff format
-      const diffs: LlmEditorAssistantDiff[] = detectedDiffs
-        .map(d => d.diff)
-        .filter((diff): diff is LlmEditorAssistantDiff => diff != null);
-
-      // Validate required fields for client processing
-      for (const diff of diffs) {
-        if (!diff.startLine) {
-          throw new Error(
-            `startLine is required for client processing but missing in diff: ${diff.search?.substring(0, 50)}...`,
-          );
-        }
-        if (!diff.search) {
-          throw new Error(
-            `search field is required for client processing but missing in diff at line ${diff.startLine}`,
-          );
-        }
-      }
-
-      // Process with client engine
-      const diffResult = await clientProcessor.current.processMultipleDiffs(content, diffs, {
-        enableProgressCallbacks: true,
-        batchSize: finalConfig.batchSize,
-        maxProcessingTime: finalConfig.maxProcessingTime,
-      });
-
-      // Convert DiffApplicationResult to ProcessingResult
-      const processingTime = performance.now() - startTime;
-      const result: ProcessingResult = {
-        success: diffResult.success,
-        error: diffResult.failedParts?.[0],
-        matches: [], // Client engine doesn't expose individual matches
-        appliedCount: diffResult.appliedCount,
-        skippedCount: Math.max(0, diffs.length - diffResult.appliedCount),
-        modifiedText: diffResult.content || content,
-        originalText: content,
-        processingTime,
-      };
-
-      // Record metrics
-      const metrics: ProcessingMetrics = {
-        method: 'client',
-        processingTime,
-        diffsCount: diffs.length,
-        appliedCount: result.appliedCount,
-        successRate: diffs.length > 0 ? (result.appliedCount / diffs.length) * 100 : 100,
-        error: result.success ? undefined : result.error?.message,
-      };
-
-      if (finalConfig.enablePerformanceMetrics) {
-        setProcessingMetrics(prev => [...prev, metrics]);
-      }
-
-      setLastProcessingMethod('client');
-      return result;
-    }
-    finally {
-      setIsClientProcessing(false);
-    }
-  }, [finalConfig]);
-
   /**
    * Apply processed content to YText (CodeMirror integration)
    */
@@ -228,26 +128,51 @@ export function useClientEngineIntegration(config: Partial<ClientEngineConfig> =
       detectedDiffs: SseDetectedDiff[],
       serverProcessingFn: () => Promise<void>,
   ): Promise<{ success: boolean; method: 'client' | 'server'; result?: ProcessingResult }> => {
-    if (!finalConfig.enableClientProcessing) {
+    if (!finalConfig.enableClientProcessing || !clientProcessor.current) {
       // Client processing disabled, use server only
       await serverProcessingFn();
-      setLastProcessingMethod('server');
       return { success: true, method: 'server' };
     }
 
     try {
-      // Try client processing first
-      const result = await processDetectedDiffsClient(content, detectedDiffs);
+      // Convert SseDetectedDiff to LlmEditorAssistantDiff format
+      const diffs = detectedDiffs
+        .map(d => d.diff)
+        .filter((diff): diff is NonNullable<typeof diff> => diff != null);
+
+      // Validate required fields for client processing
+      for (const diff of diffs) {
+        if (!diff.startLine || !diff.search) {
+          throw new Error('Missing required fields for client processing');
+        }
+      }
+
+      // Process with client engine
+      const diffResult = await clientProcessor.current.processMultipleDiffs(content, diffs, {
+        enableProgressCallbacks: true,
+        batchSize: finalConfig.batchSize,
+        maxProcessingTime: finalConfig.maxProcessingTime,
+      });
+
+      // Convert DiffApplicationResult to ProcessingResult
+      const processingTime = performance.now();
+      const result: ProcessingResult = {
+        success: diffResult.success,
+        error: diffResult.failedParts?.[0],
+        matches: [],
+        appliedCount: diffResult.appliedCount,
+        skippedCount: Math.max(0, diffs.length - diffResult.appliedCount),
+        modifiedText: diffResult.content || content,
+        processingTime,
+      };
 
       if (result.success) {
-        setLastProcessingMethod('client');
         return { success: true, method: 'client', result };
       }
 
       // Client processing failed, fallback to server if enabled
       if (finalConfig.enableServerFallback) {
         await serverProcessingFn();
-        setLastProcessingMethod('server');
         return { success: true, method: 'server' };
       }
 
@@ -258,65 +183,19 @@ export function useClientEngineIntegration(config: Partial<ClientEngineConfig> =
       // Fallback to server on error
       if (finalConfig.enableServerFallback) {
         await serverProcessingFn();
-        setLastProcessingMethod('server');
         return { success: true, method: 'server' };
       }
 
       return { success: false, method: 'client' };
     }
-  }, [finalConfig, processDetectedDiffsClient]);
-
-  /**
-   * Get performance comparison between client and server processing
-   */
-  const getPerformanceComparison = useCallback(() => {
-    const clientMetrics = processingMetrics.filter(m => m.method === 'client');
-    const serverMetrics = processingMetrics.filter(m => m.method === 'server');
-
-    if (clientMetrics.length === 0 || serverMetrics.length === 0) {
-      return null;
-    }
-
-    const avgClientTime = clientMetrics.reduce((sum, m) => sum + m.processingTime, 0) / clientMetrics.length;
-    const avgServerTime = serverMetrics.reduce((sum, m) => sum + m.processingTime, 0) / serverMetrics.length;
-    const avgClientSuccess = clientMetrics.reduce((sum, m) => sum + m.successRate, 0) / clientMetrics.length;
-    const avgServerSuccess = serverMetrics.reduce((sum, m) => sum + m.successRate, 0) / serverMetrics.length;
-
-    return {
-      clientAvgTime: avgClientTime,
-      serverAvgTime: avgServerTime,
-      timeImprovement: ((avgServerTime - avgClientTime) / avgServerTime) * 100,
-      clientSuccessRate: avgClientSuccess,
-      serverSuccessRate: avgServerSuccess,
-      totalClientProcessing: clientMetrics.length,
-      totalServerProcessing: serverMetrics.length,
-    };
-  }, [processingMetrics]);
-
-  /**
-   * Reset metrics for new comparison
-   */
-  const resetMetrics = useCallback(() => {
-    setProcessingMetrics([]);
-  }, []);
+  }, [finalConfig]);
 
   return {
     // Processing functions
-    processDetectedDiffsClient,
     applyToYText,
     processHybrid,
 
-    // State
-    isClientProcessing,
-    lastProcessingMethod,
-    processingMetrics,
-
-    // Metrics and comparison
-    getPerformanceComparison,
-    resetMetrics,
-
     // Configuration
-    config: finalConfig,
     isClientProcessingEnabled: finalConfig.enableClientProcessing,
   };
 }
@@ -325,18 +204,6 @@ export function useClientEngineIntegration(config: Partial<ClientEngineConfig> =
 // Utility Functions
 // -----------------------------------------------------------------------------
 
-/**
- * Convert SseDetectedDiff to content string for processing
- */
-export function extractContentFromDetectedDiffs(detectedDiffs: SseDetectedDiff[]): string {
-  // This would need to be implemented based on how the current system
-  // extracts content from detected diffs
-  return detectedDiffs
-    .map(d => d.diff?.search || '')
-    .filter(Boolean)
-    .join('\n');
-}
-
 /**
  * Feature flag for enabling client processing
  */

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

@@ -5,34 +5,11 @@
  */
 
 import type { LlmEditorAssistantDiff } from '../../../interfaces/editor-assistant/llm-response-schemas';
-import type { SingleDiffResult, ProcessorConfig, SearchContext } from '../../../interfaces/editor-assistant/types';
+import type { SingleDiffResult, ProcessorConfig, SearchContext } from '../../interfaces/types';
 
 import { ClientErrorHandler } from './error-handling';
 import { ClientFuzzyMatcher } from './fuzzy-matching';
 
-// -----------------------------------------------------------------------------
-// Editor Integration Types
-// -----------------------------------------------------------------------------
-
-export interface EditorAdapter {
-  /** Get current content as string */
-  getContent(): string;
-  /** Set content (for full replacement) */
-  setContent(content: string): void;
-  /** Replace text in specific range */
-  replaceRange(startLine: number, endLine: number, newText: string): void;
-  /** Get line count */
-  getLineCount(): number;
-  /** Get specific line content */
-  getLine(lineNumber: number): string;
-  /** Insert text at position */
-  insertText(line: number, column: number, text: string): void;
-  /** Delete text range */
-  deleteRange(startLine: number, startCol: number, endLine: number, endCol: number): void;
-  /** Create undo checkpoint */
-  createUndoCheckpoint(): void;
-}
-
 // -----------------------------------------------------------------------------
 // Client Diff Application Engine
 // -----------------------------------------------------------------------------
@@ -128,52 +105,6 @@ export class ClientDiffApplicationEngine {
     }
   }
 
-  /**
-   * Apply diff directly to an editor adapter with real-time integration
-   */
-  async applyDiffToEditor(
-      editor: EditorAdapter,
-      diff: LlmEditorAssistantDiff,
-      options: {
-      createCheckpoint?: boolean;
-      preserveSelection?: boolean;
-    } = {},
-  ): Promise<SingleDiffResult> {
-    const { createCheckpoint = true } = options;
-
-    try {
-      // Create undo checkpoint for easy reversal
-      if (createCheckpoint) {
-        editor.createUndoCheckpoint();
-      }
-
-      // Get current content
-      const currentContent = editor.getContent();
-
-      // Apply diff to content
-      const result = this.applySingleDiff(currentContent, diff);
-
-      if (!result.success || !result.updatedLines) {
-        return result;
-      }
-
-      // Apply changes to editor
-      const newContent = result.updatedLines.join('\n');
-      editor.setContent(newContent);
-
-      return result;
-
-    }
-    catch (error) {
-      return {
-        success: false,
-        error: this.errorHandler.createContentError(
-          error as Error,
-          'Editor integration error',
-        ),
-      };
-    }
-  }
 
   /**
    * Apply multiple diffs in sequence with proper delta tracking
@@ -332,13 +263,6 @@ export class ClientDiffApplicationEngine {
     this.fuzzyMatcher.setThreshold(this.config.fuzzyThreshold);
   }
 
-  /**
-   * Get current configuration
-   */
-  getConfig(): Required<ProcessorConfig> {
-    return { ...this.config };
-  }
-
   /**
    * Validate diff before application
    */
@@ -370,124 +294,4 @@ export class ClientDiffApplicationEngine {
     };
   }
 
-  /**
-   * Preview diff application without making changes
-   */
-  previewDiff(
-      content: string,
-      diff: LlmEditorAssistantDiff,
-  ): {
-    preview: string;
-    success: boolean;
-    changes: {
-      added: number;
-      removed: number;
-      modified: number;
-    };
-  } {
-    const result = this.applySingleDiff(content, diff);
-
-    if (!result.success || !result.updatedLines) {
-      return {
-        preview: content,
-        success: false,
-        changes: { added: 0, removed: 0, modified: 0 },
-      };
-    }
-
-    const newLines = result.updatedLines;
-
-    return {
-      preview: newLines.join('\n'),
-      success: true,
-      changes: {
-        added: Math.max(0, result.lineDelta || 0),
-        removed: Math.max(0, -(result.lineDelta || 0)),
-        modified: 1, // At least one diff was applied
-      },
-    };
-  }
-
-}
-
-// -----------------------------------------------------------------------------
-// Browser-Specific Editor Adapters
-// -----------------------------------------------------------------------------
-
-/**
- * Simple textarea adapter for basic text inputs
- */
-export class TextAreaAdapter implements EditorAdapter {
-
-  // eslint-disable-next-line no-useless-constructor
-  constructor(private textarea: HTMLTextAreaElement) {}
-
-  getContent(): string {
-    return this.textarea.value;
-  }
-
-  setContent(content: string): void {
-    this.textarea.value = content;
-  }
-
-  replaceRange(startLine: number, endLine: number, newText: string): void {
-    const lines = this.getContent().split('\n');
-    const newLines = [
-      ...lines.slice(0, startLine),
-      newText,
-      ...lines.slice(endLine + 1),
-    ];
-    this.setContent(newLines.join('\n'));
-  }
-
-  getLineCount(): number {
-    return this.getContent().split('\n').length;
-  }
-
-  getLine(lineNumber: number): string {
-    const lines = this.getContent().split('\n');
-    return lines[lineNumber] || '';
-  }
-
-  insertText(line: number, column: number, text: string): void {
-    // Basic implementation for textarea
-    const lines = this.getContent().split('\n');
-    const targetLine = lines[line] || '';
-    lines[line] = targetLine.slice(0, column) + text + targetLine.slice(column);
-    this.setContent(lines.join('\n'));
-  }
-
-  deleteRange(startLine: number, startCol: number, endLine: number, endCol: number): void {
-    const lines = this.getContent().split('\n');
-
-    if (startLine === endLine) {
-      // Same line deletion
-      const line = lines[startLine] || '';
-      lines[startLine] = line.slice(0, startCol) + line.slice(endCol);
-    }
-    else {
-      // Multi-line deletion
-      const startLineContent = lines[startLine]?.slice(0, startCol) || '';
-      const endLineContent = lines[endLine]?.slice(endCol) || '';
-      lines.splice(startLine, endLine - startLine + 1, startLineContent + endLineContent);
-    }
-
-    this.setContent(lines.join('\n'));
-  }
-
-  createUndoCheckpoint(): void {
-    // Textarea doesn't have built-in undo checkpoints
-    // This would need to be implemented with a custom history system
-  }
-
 }
-
-// -----------------------------------------------------------------------------
-// Export Default Instance
-// -----------------------------------------------------------------------------
-
-/**
- * Default client diff application engine
- * Pre-configured for typical browser usage
- */
-export const defaultClientDiffEngine = new ClientDiffApplicationEngine();

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

@@ -4,7 +4,7 @@
  * Provides detailed error information and user-friendly suggestions
  */
 
-import type { DiffError, MatchResult } from '../../../interfaces/editor-assistant/types';
+import type { DiffError, MatchResult } from '../../interfaces/types';
 
 // -----------------------------------------------------------------------------
 // Client Error Types and Constants
@@ -12,12 +12,9 @@ import type { DiffError, MatchResult } from '../../../interfaces/editor-assistan
 
 export const CLIENT_ERROR_MESSAGES = {
   SEARCH_NOT_FOUND: 'Search content not found in the document',
-  SIMILARITY_TOO_LOW: 'Search content is too different from the closest match',
-  MULTIPLE_MATCHES: 'Multiple similar matches found - search is ambiguous',
   EMPTY_SEARCH: 'Search content cannot be empty',
   CONTENT_ERROR: 'Invalid or corrupted content',
   TIMEOUT_ERROR: 'Search operation timed out',
-  BROWSER_ERROR: 'Browser compatibility issue detected',
 } as const;
 
 export const CLIENT_SUGGESTIONS = {
@@ -27,18 +24,6 @@ export const CLIENT_SUGGESTIONS = {
     'Verify line endings match your content',
     'Use the browser\'s search function to locate content first',
   ],
-  SIMILARITY_TOO_LOW: [
-    'Increase the similarity threshold in settings',
-    'Use a more exact search pattern',
-    'Check for typos or formatting differences',
-    'Try searching for a unique phrase within the target',
-  ],
-  MULTIPLE_MATCHES: [
-    'Add more context to make the search unique',
-    'Include surrounding lines in your search',
-    'Use line numbers to specify the exact location',
-    'Search for a more specific pattern',
-  ],
   EMPTY_SEARCH: [
     'Provide valid search content',
     'Check that your diff contains the search text',
@@ -53,11 +38,6 @@ export const CLIENT_SUGGESTIONS = {
     'Reduce the document size if possible',
     'Check browser performance and memory usage',
   ],
-  BROWSER_ERROR: [
-    'Update to a modern browser version',
-    'Check browser compatibility settings',
-    'Try disabling browser extensions temporarily',
-  ],
 } as const;
 
 // -----------------------------------------------------------------------------
@@ -105,67 +85,6 @@ export class ClientErrorHandler {
     return error;
   }
 
-  /**
-   * Create a detailed error for similarity too low
-   */
-  createSimilarityTooLowError(
-      searchContent: string,
-      bestMatch: string,
-      similarity: number,
-      threshold: number,
-      startLine?: number,
-  ): DiffError {
-    const error: DiffError = {
-      type: 'SIMILARITY_TOO_LOW',
-      message: `${CLIENT_ERROR_MESSAGES.SIMILARITY_TOO_LOW} (${Math.floor(similarity * 100)}% < ${Math.floor(threshold * 100)}%)`,
-      line: startLine,
-      details: {
-        searchContent,
-        bestMatch,
-        similarity,
-        suggestions: [
-          `Current similarity: ${Math.floor(similarity * 100)}%, required: ${Math.floor(threshold * 100)}%`,
-          ...CLIENT_SUGGESTIONS.SIMILARITY_TOO_LOW,
-        ],
-        correctFormat: this.generateCorrectFormat(searchContent, bestMatch),
-      },
-    };
-
-    this.logError(error, 'Similarity too low');
-    return error;
-  }
-
-  /**
-   * Create a detailed error for multiple matches
-   */
-  createMultipleMatchesError(
-      searchContent: string,
-      matches: MatchResult[],
-      startLine?: number,
-  ): DiffError {
-    const matchInfo = matches
-      .slice(0, 3) // Show only first 3 matches
-      .map((match, index) => `Match ${index + 1}: line ${match.index ? match.index + 1 : 'unknown'} (${Math.floor((match.similarity || 0) * 100)}%)`)
-      .join(', ');
-
-    const error: DiffError = {
-      type: 'MULTIPLE_MATCHES',
-      message: `${CLIENT_ERROR_MESSAGES.MULTIPLE_MATCHES}: ${matchInfo}`,
-      line: startLine,
-      details: {
-        searchContent,
-        suggestions: [
-          `Found ${matches.length} similar matches`,
-          ...CLIENT_SUGGESTIONS.MULTIPLE_MATCHES,
-        ],
-        lineRange: `Multiple locations: ${matchInfo}`,
-      },
-    };
-
-    this.logError(error, 'Multiple matches found');
-    return error;
-  }
-
   /**
    * Create an error for empty search content
    */
@@ -229,29 +148,6 @@ export class ClientErrorHandler {
     return error;
   }
 
-  /**
-   * Create an error for browser compatibility issues
-   */
-  createBrowserError(
-      feature: string,
-      fallbackAvailable = false,
-  ): DiffError {
-    const error: DiffError = {
-      type: 'CONTENT_ERROR',
-      message: `${CLIENT_ERROR_MESSAGES.BROWSER_ERROR}: ${feature} not supported`,
-      details: {
-        searchContent: `Browser feature: ${feature}`,
-        suggestions: [
-          fallbackAvailable ? 'Using fallback implementation' : 'No fallback available',
-          ...CLIENT_SUGGESTIONS.BROWSER_ERROR,
-        ],
-      },
-    };
-
-    this.logError(error, 'Browser compatibility issue');
-    return error;
-  }
-
   // -----------------------------------------------------------------------------
   // Utility Methods
   // -----------------------------------------------------------------------------
@@ -334,87 +230,4 @@ export class ClientErrorHandler {
     return summary + errorList + moreErrors;
   }
 
-  // -----------------------------------------------------------------------------
-  // Configuration Methods
-  // -----------------------------------------------------------------------------
-
-  /**
-   * Check if console logging is enabled
-   */
-  isConsoleLoggingEnabled(): boolean {
-    return this.enableConsoleLogging;
-  }
-
-  /**
-   * Check if user feedback is enabled
-   */
-  isUserFeedbackEnabled(): boolean {
-    return this.enableUserFeedback;
-  }
-
 }
-
-// -----------------------------------------------------------------------------
-// Utility Functions
-// -----------------------------------------------------------------------------
-
-/**
- * Quick error creation for common scenarios
- */
-export function createQuickError(
-    type: keyof typeof CLIENT_ERROR_MESSAGES,
-    searchContent: string,
-    additionalInfo?: string,
-): DiffError {
-  return {
-    type: type as DiffError['type'],
-    message: CLIENT_ERROR_MESSAGES[type] + (additionalInfo ? `: ${additionalInfo}` : ''),
-    details: {
-      searchContent,
-      suggestions: [...(CLIENT_SUGGESTIONS[type] || ['Contact support for assistance'])],
-    },
-  };
-}
-
-/**
- * Validate browser support for required features
- */
-export function validateBrowserSupport(): {
-  supported: boolean;
-  missing: string[];
-  warnings: string[];
-  } {
-  const missing: string[] = [];
-  const warnings: string[] = [];
-
-  // Check for required APIs
-  if (typeof performance === 'undefined' || typeof performance.now !== 'function') {
-    missing.push('Performance API');
-  }
-
-  if (typeof String.prototype.normalize !== 'function') {
-    missing.push('Unicode normalization');
-  }
-
-  // Check for optional but recommended features
-  // eslint-disable-next-line no-console
-  if (typeof console === 'undefined' || typeof console.warn !== 'function') {
-    warnings.push('Console API limited');
-  }
-
-  return {
-    supported: missing.length === 0,
-    missing,
-    warnings,
-  };
-}
-
-// -----------------------------------------------------------------------------
-// Export Default Instance
-// -----------------------------------------------------------------------------
-
-/**
- * Default client error handler instance
- * Pre-configured for typical browser usage
- */
-export const defaultClientErrorHandler = new ClientErrorHandler(true, true);

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

@@ -1,4 +1,4 @@
-import type { SearchContext } from '../../../interfaces/editor-assistant/types';
+import type { SearchContext } from '../../interfaces/types';
 
 import {
   ClientFuzzyMatcher,

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

@@ -6,7 +6,7 @@
 
 import { distance } from 'fastest-levenshtein';
 
-import type { MatchResult, SearchContext } from '../../../interfaces/editor-assistant/types';
+import type { MatchResult, SearchContext } from '../../interfaces/types';
 
 import { normalizeForBrowserFuzzyMatch } from './text-normalization';
 

+ 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,
+  };
+};

+ 2 - 147
apps/app/src/features/openai/client/services/editor-assistant/processor.ts

@@ -5,9 +5,9 @@
  */
 
 import type { LlmEditorAssistantDiff } from '../../../interfaces/editor-assistant/llm-response-schemas';
-import type { DiffApplicationResult, ProcessorConfig, DiffError } from '../../../interfaces/editor-assistant/types';
+import type { DiffApplicationResult, ProcessorConfig, DiffError } from '../../interfaces/types';
 
-import { ClientDiffApplicationEngine, type EditorAdapter } from './diff-application';
+import { ClientDiffApplicationEngine } from './diff-application';
 import { ClientErrorHandler } from './error-handling';
 import { ClientFuzzyMatcher } from './fuzzy-matching';
 // Note: measureNormalization import removed as it's not used in this file
@@ -241,71 +241,6 @@ export class ClientSearchReplaceProcessor {
     }
   }
 
-  /**
-   * Process diffs with direct editor integration
-   */
-  async processWithEditor(
-      editor: EditorAdapter,
-      diffs: LlmEditorAssistantDiff[],
-      options: ProcessingOptions = {},
-  ): Promise<DiffApplicationResult> {
-    const content = editor.getContent();
-
-    if (options.previewMode) {
-      // Preview mode: don't modify editor
-      return this.processMultipleDiffs(content, diffs, options);
-    }
-
-    // Create undo checkpoint before starting
-    editor.createUndoCheckpoint();
-
-    const result = await this.processMultipleDiffs(content, diffs, options);
-
-    if (result.success && result.content) {
-      editor.setContent(result.content);
-    }
-
-    return result;
-  }
-
-  /**
-   * Quick single diff processing for real-time applications
-   */
-  async processSingleDiffQuick(
-      content: string,
-      diff: LlmEditorAssistantDiff,
-  ): Promise<DiffApplicationResult> {
-    try {
-      const result = this.diffEngine.applySingleDiff(content, diff);
-
-      if (result.success && result.updatedLines) {
-        return {
-          success: true,
-          appliedCount: 1,
-          content: result.updatedLines.join('\n'),
-        };
-      }
-      return {
-        success: false,
-        appliedCount: 0,
-        failedParts: result.error ? [result.error] : [],
-      };
-
-    }
-    catch (error) {
-      const processingError = this.errorHandler.createContentError(
-        error as Error,
-        'Quick processing error',
-      );
-
-      return {
-        success: false,
-        appliedCount: 0,
-        failedParts: [processingError],
-      };
-    }
-  }
-
   // -----------------------------------------------------------------------------
   // Private Helper Methods
   // -----------------------------------------------------------------------------
@@ -388,13 +323,6 @@ export class ClientSearchReplaceProcessor {
     return batches;
   }
 
-  /**
-   * Yield control to browser event loop
-   */
-  private async yieldToBrowser(): Promise<void> {
-    return new Promise(resolve => setTimeout(resolve, 0));
-  }
-
   /**
    * Update processing status
    */
@@ -482,77 +410,4 @@ export class ClientSearchReplaceProcessor {
     }
   }
 
-  /**
-   * Validate processor configuration
-   */
-  validateConfig(): { valid: boolean; issues: string[] } {
-    const issues: string[] = [];
-
-    if (this.config.fuzzyThreshold < 0 || this.config.fuzzyThreshold > 1) {
-      issues.push('Fuzzy threshold must be between 0 and 1');
-    }
-
-    if (this.config.bufferLines < 0) {
-      issues.push('Buffer lines must be non-negative');
-    }
-
-    if (this.config.maxDiffBlocks <= 0) {
-      issues.push('Max diff blocks must be positive');
-    }
-
-    return {
-      valid: issues.length === 0,
-      issues,
-    };
-  }
-
-  /**
-   * Get processor performance statistics
-   */
-  getPerformanceStats(): {
-    lastProcessingTime?: number;
-    averageProcessingTime?: number;
-    successRate?: number;
-    } {
-    // This would be enhanced with persistent statistics tracking
-    return {
-      lastProcessingTime: this.currentStatus
-        ? performance.now() - this.currentStatus.startTime
-        : undefined,
-    };
-  }
-
 }
-
-// -----------------------------------------------------------------------------
-// Utility Functions
-// -----------------------------------------------------------------------------
-
-/**
- * Create a processor with browser-optimized defaults
- */
-export function createBrowserOptimizedProcessor(
-    overrides: Partial<ProcessorConfig> = {},
-): ClientSearchReplaceProcessor {
-  const browserConfig: Partial<ProcessorConfig> = {
-    fuzzyThreshold: 0.8,
-    bufferLines: 30, // Smaller buffer for browser performance
-    preserveIndentation: true,
-    stripLineNumbers: true,
-    enableAggressiveMatching: false,
-    maxDiffBlocks: 8, // Conservative limit for browser
-    ...overrides,
-  };
-
-  return new ClientSearchReplaceProcessor(browserConfig);
-}
-
-// -----------------------------------------------------------------------------
-// Export Default Instance
-// -----------------------------------------------------------------------------
-
-/**
- * Default client search/replace processor instance
- * Pre-configured for typical browser usage
- */
-export const defaultClientProcessor = createBrowserOptimizedProcessor();

+ 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 { 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 {
   SseMessageSchema,
   SseDetectedDiffSchema,
@@ -20,20 +24,18 @@ import {
   type SseMessage,
   type SseDetectedDiff,
   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 { IThreadRelationHasId } 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 { QuickMenuList } from '../../components/AiAssistant/AiAssistantSidebar/QuickMenuList';
 import { useAiAssistantSidebar } from '../../stores/ai-assistant';
 import { useClientEngineIntegration, shouldUseClientProcessing } from '../client-engine-integration';
 
+import { getPageBodyForContext } from './get-page-body-for-context';
 import { performSearchReplace } from './search-replace-engine';
 
 interface CreateThread {
@@ -79,6 +81,7 @@ type UseEditorAssistant = () => {
   // Views
   generateInitialView: GenerateInitialView,
   generatingEditorTextLabel?: JSX.Element,
+  partialContentWarnLabel?: JSX.Element,
   generateActionButtons: GenerateActionButtons,
   headerIcon: JSX.Element,
   headerText: JSX.Element,
@@ -120,6 +123,10 @@ export const useEditorAssistant: UseEditorAssistant = () => {
   const [selectedAiAssistant, setSelectedAiAssistant] = useState<AiAssistantHasId>();
   const [selectedText, setSelectedText] = useState<string>();
   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]);
 
@@ -156,24 +163,41 @@ export const useEditorAssistant: UseEditorAssistant = () => {
   }, [selectedAiAssistant?._id]);
 
   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
     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', {
       method: 'POST',
       headers: { 'Content-Type': 'application/json' },
-      body: JSON.stringify({
-        threadId,
-        userMessage: formData.input,
-        selectedText,
-        pageBody: getPageBody(),
-      }),
+      body: JSON.stringify(requestBody),
     });
 
     return response;
@@ -441,6 +465,44 @@ export const useEditorAssistant: UseEditorAssistant = () => {
     );
   }, [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 {
     createThread,
     postMessage,
@@ -453,6 +515,7 @@ export const useEditorAssistant: UseEditorAssistant = () => {
     // Views
     generateInitialView,
     generatingEditorTextLabel,
+    partialContentWarnLabel,
     generateActionButtons,
     headerIcon,
     headerText,

+ 7 - 388
apps/app/src/features/openai/interfaces/editor-assistant/sse-schemas.spec.ts

@@ -2,7 +2,6 @@ import {
   SseMessageSchema,
   SseDetectedDiffSchema,
   SseFinalizedSchema,
-  hasApplicationResult,
   type SseMessage,
   type SseDetectedDiff,
   type SseFinalized,
@@ -192,343 +191,29 @@ describe('sse-schemas', () => {
   });
 
   describe('SseFinalizedSchema', () => {
-    test('should validate minimal finalized response', () => {
+    test('should validate finalized response with success true', () => {
       const validFinalized = {
-        finalized: {
-          message: 'Changes have been applied successfully.',
-          replacements: [],
-        },
+        success: true,
       };
 
       const result = SseFinalizedSchema.safeParse(validFinalized);
       expect(result.success).toBe(true);
       if (result.success) {
-        expect(result.data.finalized.message).toBe(validFinalized.finalized.message);
-        expect(result.data.finalized.replacements).toEqual([]);
-        expect(result.data.finalized.applicationResult).toBeUndefined();
+        expect(result.data.success).toBe(true);
       }
     });
 
-    test('should validate finalized response with replacements', () => {
+    test('should validate finalized response with success false', () => {
       const validFinalized = {
-        finalized: {
-          message: 'Successfully applied 2 changes.',
-          replacements: [
-            {
-              search: 'old code 1',
-              replace: 'new code 1',
-              startLine: 5,
-            },
-            {
-              search: 'old code 2',
-              replace: 'new code 2',
-              startLine: 10,
-              endLine: 12,
-            },
-          ],
-        },
+        success: false,
       };
 
       const result = SseFinalizedSchema.safeParse(validFinalized);
       expect(result.success).toBe(true);
       if (result.success) {
-        expect(result.data.finalized.replacements).toHaveLength(2);
-        expect(result.data.finalized.replacements[0].search).toBe('old code 1');
-        expect(result.data.finalized.replacements[1].endLine).toBe(12);
+        expect(result.data.success).toBe(false);
       }
     });
-
-    test('should validate finalized response with successful application result', () => {
-      const validFinalized = {
-        finalized: {
-          message: 'All changes applied successfully.',
-          replacements: [
-            {
-              search: 'test code',
-              replace: 'updated code',
-              startLine: 1,
-            },
-          ],
-          applicationResult: {
-            success: true,
-            appliedCount: 1,
-            totalCount: 1,
-          },
-        },
-      };
-
-      const result = SseFinalizedSchema.safeParse(validFinalized);
-      expect(result.success).toBe(true);
-      if (result.success) {
-        expect(result.data.finalized.applicationResult?.success).toBe(true);
-        expect(result.data.finalized.applicationResult?.appliedCount).toBe(1);
-        expect(result.data.finalized.applicationResult?.totalCount).toBe(1);
-      }
-    });
-
-    test('should validate finalized response with failed application result', () => {
-      const validFinalized = {
-        finalized: {
-          message: 'Some changes failed to apply.',
-          replacements: [],
-          applicationResult: {
-            success: false,
-            appliedCount: 0,
-            totalCount: 1,
-            failedParts: [
-              {
-                type: 'SEARCH_NOT_FOUND',
-                message: 'Could not find the specified search content',
-                line: 10,
-                details: {
-                  searchContent: 'missing code',
-                  bestMatch: 'similar code',
-                  similarity: 0.7,
-                  suggestions: ['Check line numbers', 'Verify content'],
-                  correctFormat: 'function example() {}',
-                  lineRange: '8-12',
-                },
-              },
-            ],
-          },
-        },
-      };
-
-      const result = SseFinalizedSchema.safeParse(validFinalized);
-      expect(result.success).toBe(true);
-      if (result.success) {
-        expect(result.data.finalized.applicationResult?.success).toBe(false);
-        expect(result.data.finalized.applicationResult?.failedParts).toHaveLength(1);
-        expect(result.data.finalized.applicationResult?.failedParts?.[0].type).toBe('SEARCH_NOT_FOUND');
-        expect(result.data.finalized.applicationResult?.failedParts?.[0].details.similarity).toBe(0.7);
-      }
-    });
-
-    test('should validate all error types in failedParts', () => {
-      const errorTypes = [
-        'SEARCH_NOT_FOUND',
-        'SIMILARITY_TOO_LOW',
-        'MULTIPLE_MATCHES',
-        'EMPTY_SEARCH',
-        'MARKER_SEQUENCE_ERROR',
-        'CONTENT_ERROR',
-      ];
-
-      for (const errorType of errorTypes) {
-        const validFinalized = {
-          finalized: {
-            message: `Error type: ${errorType}`,
-            replacements: [],
-            applicationResult: {
-              success: false,
-              appliedCount: 0,
-              totalCount: 1,
-              failedParts: [
-                {
-                  type: errorType,
-                  message: `Test message for ${errorType}`,
-                  details: {
-                    searchContent: 'test',
-                    suggestions: [],
-                  },
-                },
-              ],
-            },
-          },
-        };
-
-        const result = SseFinalizedSchema.safeParse(validFinalized);
-        expect(result.success).toBe(true);
-        if (result.success) {
-          expect(result.data.finalized.applicationResult?.failedParts?.[0].type).toBe(errorType);
-        }
-      }
-    });
-
-    test('should fail when finalized field is missing', () => {
-      const invalidFinalized = {};
-
-      const result = SseFinalizedSchema.safeParse(invalidFinalized);
-      expect(result.success).toBe(false);
-      if (!result.success) {
-        expect(result.error.issues[0].code).toBe('invalid_type');
-        expect(result.error.issues[0].path).toEqual(['finalized']);
-      }
-    });
-
-    test('should fail when message is missing', () => {
-      const invalidFinalized = {
-        finalized: {
-          replacements: [],
-        },
-      };
-
-      const result = SseFinalizedSchema.safeParse(invalidFinalized);
-      expect(result.success).toBe(false);
-      if (!result.success) {
-        const messageError = result.error.issues.find(issue => issue.path.includes('message'));
-        expect(messageError).toBeDefined();
-      }
-    });
-
-    test('should fail when replacements is missing', () => {
-      const invalidFinalized = {
-        finalized: {
-          message: 'Test message',
-        },
-      };
-
-      const result = SseFinalizedSchema.safeParse(invalidFinalized);
-      expect(result.success).toBe(false);
-      if (!result.success) {
-        const replacementsError = result.error.issues.find(issue => issue.path.includes('replacements'));
-        expect(replacementsError).toBeDefined();
-      }
-    });
-
-    test('should fail when appliedCount is negative', () => {
-      const invalidFinalized = {
-        finalized: {
-          message: 'Test',
-          replacements: [],
-          applicationResult: {
-            success: false,
-            appliedCount: -1,
-            totalCount: 1,
-          },
-        },
-      };
-
-      const result = SseFinalizedSchema.safeParse(invalidFinalized);
-      expect(result.success).toBe(false);
-    });
-
-    test('should fail when totalCount is negative', () => {
-      const invalidFinalized = {
-        finalized: {
-          message: 'Test',
-          replacements: [],
-          applicationResult: {
-            success: false,
-            appliedCount: 0,
-            totalCount: -1,
-          },
-        },
-      };
-
-      const result = SseFinalizedSchema.safeParse(invalidFinalized);
-      expect(result.success).toBe(false);
-    });
-
-    test('should fail when similarity is out of range', () => {
-      const invalidFinalized = {
-        finalized: {
-          message: 'Test',
-          replacements: [],
-          applicationResult: {
-            success: false,
-            appliedCount: 0,
-            totalCount: 1,
-            failedParts: [
-              {
-                type: 'SIMILARITY_TOO_LOW',
-                message: 'Low similarity',
-                details: {
-                  searchContent: 'test',
-                  similarity: 1.5, // Invalid: > 1.0
-                  suggestions: [],
-                },
-              },
-            ],
-          },
-        },
-      };
-
-      const result = SseFinalizedSchema.safeParse(invalidFinalized);
-      expect(result.success).toBe(false);
-    });
-
-    test('should fail when line number is not positive', () => {
-      const invalidFinalized = {
-        finalized: {
-          message: 'Test',
-          replacements: [],
-          applicationResult: {
-            success: false,
-            appliedCount: 0,
-            totalCount: 1,
-            failedParts: [
-              {
-                type: 'SEARCH_NOT_FOUND',
-                message: 'Not found',
-                line: 0, // Invalid: must be positive
-                details: {
-                  searchContent: 'test',
-                  suggestions: [],
-                },
-              },
-            ],
-          },
-        },
-      };
-
-      const result = SseFinalizedSchema.safeParse(invalidFinalized);
-      expect(result.success).toBe(false);
-    });
-
-    test('should allow extra fields in finalized', () => {
-      const validFinalized = {
-        finalized: {
-          message: 'Test',
-          replacements: [],
-        },
-        extraField: 'ignored',
-      };
-
-      const result = SseFinalizedSchema.safeParse(validFinalized);
-      expect(result.success).toBe(true);
-    });
-  });
-
-  describe('hasApplicationResult helper', () => {
-    test('should return true when applicationResult is present', () => {
-      const finalized: SseFinalized = {
-        finalized: {
-          message: 'Test',
-          replacements: [],
-          applicationResult: {
-            success: true,
-            appliedCount: 1,
-            totalCount: 1,
-          },
-        },
-      };
-
-      expect(hasApplicationResult(finalized)).toBe(true);
-    });
-
-    test('should return false when applicationResult is undefined', () => {
-      const finalized: SseFinalized = {
-        finalized: {
-          message: 'Test',
-          replacements: [],
-        },
-      };
-
-      expect(hasApplicationResult(finalized)).toBe(false);
-    });
-
-    test('should return false when applicationResult is undefined (explicit)', () => {
-      const finalized: SseFinalized = {
-        finalized: {
-          message: 'Test',
-          replacements: [],
-          applicationResult: undefined,
-        },
-      };
-
-      expect(hasApplicationResult(finalized)).toBe(false);
-    });
   });
 
   describe('Type inference', () => {
@@ -556,77 +241,11 @@ describe('sse-schemas', () => {
 
     test('SseFinalized type should match schema', () => {
       const finalized: SseFinalized = {
-        finalized: {
-          message: 'Done',
-          replacements: [],
-        },
+        success: true,
       };
 
       const result = SseFinalizedSchema.safeParse(finalized);
       expect(result.success).toBe(true);
     });
   });
-
-  describe('Real-world scenarios', () => {
-    test('should validate complete SSE flow', () => {
-      const realWorldFinalized = {
-        finalized: {
-          message: 'Successfully refactored the authentication function and added error handling.',
-          replacements: [
-            {
-              search: 'function authenticate(token) {\n  return validateToken(token);\n}',
-              // eslint-disable-next-line max-len
-              replace: 'async function authenticate(token) {\n  try {\n    if (!token) {\n      throw new Error("Token is required");\n    }\n    return await validateToken(token);\n  } catch (error) {\n    console.error("Authentication failed:", error);\n    throw error;\n  }\n}',
-              startLine: 25,
-              endLine: 27,
-            },
-          ],
-          applicationResult: {
-            success: true,
-            appliedCount: 1,
-            totalCount: 1,
-          },
-        },
-      };
-
-      const result = SseFinalizedSchema.safeParse(realWorldFinalized);
-      expect(result.success).toBe(true);
-    });
-
-    test('should validate error scenario with detailed feedback', () => {
-      const errorScenario = {
-        finalized: {
-          message: 'Failed to apply changes. The specified code was not found.',
-          replacements: [],
-          applicationResult: {
-            success: false,
-            appliedCount: 0,
-            totalCount: 1,
-            failedParts: [
-              {
-                type: 'SEARCH_NOT_FOUND',
-                message: 'Could not find exact match for the specified code',
-                line: 25,
-                details: {
-                  searchContent: 'function authenticate(token) {',
-                  bestMatch: 'function authenticateUser(userToken) {',
-                  similarity: 0.85,
-                  suggestions: [
-                    'Check if the function name has changed',
-                    'Verify the parameter name is correct',
-                    'Ensure the code structure matches exactly',
-                  ],
-                  correctFormat: 'function authenticateUser(userToken) {',
-                  lineRange: '20-30',
-                },
-              },
-            ],
-          },
-        },
-      };
-
-      const result = SseFinalizedSchema.safeParse(errorScenario);
-      expect(result.success).toBe(true);
-    });
-  });
 });

+ 19 - 37
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
 // -----------------------------------------------------------------------------
 
+// 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({
   appendedMessage: z.string()
     .describe('The message that should be appended to the chat window'),
@@ -15,47 +32,12 @@ export const SseDetectedDiffSchema = z.object({
   diff: LlmEditorAssistantDiffSchema,
 });
 
-// Enhanced finalized schema with detailed application results
+// Simplified finalized schema
 export const SseFinalizedSchema = z.object({
-  finalized: z.object({
-    message: z.string()
-      .describe('The final message that should be displayed in the chat window'),
-    replacements: z.array(LlmEditorAssistantDiffSchema),
-    // Enhanced error reporting from multi-search-replace processor
-    applicationResult: z.object({
-      success: z.boolean(),
-      appliedCount: z.number().int().min(0),
-      totalCount: z.number().int().min(0),
-      failedParts: z.array(z.object({
-        type: z.enum([
-          'SEARCH_NOT_FOUND',
-          'SIMILARITY_TOO_LOW',
-          'MULTIPLE_MATCHES',
-          'EMPTY_SEARCH',
-          'MARKER_SEQUENCE_ERROR',
-          'CONTENT_ERROR',
-        ]),
-        message: z.string(),
-        line: z.number().int().positive().optional(),
-        details: z.object({
-          searchContent: z.string(),
-          bestMatch: z.string().optional(),
-          similarity: z.number().min(0).max(1).optional(),
-          suggestions: z.array(z.string()),
-          correctFormat: z.string().optional(),
-          lineRange: z.string().optional(),
-        }),
-      })).optional(),
-    }).optional(),
-  }),
+  success: z.boolean(),
 });
 
 // Type definitions
 export type SseMessage = z.infer<typeof SseMessageSchema>;
 export type SseDetectedDiff = z.infer<typeof SseDetectedDiffSchema>;
 export type SseFinalized = z.infer<typeof SseFinalizedSchema>;
-
-// Helper functions for response type checking
-export const hasApplicationResult = (finalized: SseFinalized): boolean => {
-  return finalized.finalized.applicationResult !== undefined;
-};

+ 95 - 92
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 { 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 ThreadRelationModel from '../../models/thread-relation';
 import { getOrCreateEditorAssistant } from '../../services/assistant';
@@ -40,14 +42,7 @@ const LlmEditorAssistantResponseSchema = z.object({
 }).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,
 }
 
@@ -70,77 +65,75 @@ const withMarkdownCaution = `# IMPORTANT:
 `;
 
 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 */
 
+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
  */
@@ -157,10 +150,18 @@ export const postMessageToEditHandlersFactory: PostMessageHandlersFactory = (cro
     body('pageBody')
       .isString()
       .withMessage('pageBody must be string and not empty'),
+    body('isPageBodyPartial')
+      .optional()
+      .isBoolean()
+      .withMessage('isPageBodyPartial must be boolean'),
     body('selectedText')
       .optional()
       .isString()
       .withMessage('selectedText must be string'),
+    body('selectedPosition')
+      .optional()
+      .isNumeric()
+      .withMessage('selectedPosition must be number'),
     body('threadId').optional().isString().withMessage('threadId must be string'),
   ];
 
@@ -168,7 +169,10 @@ export const postMessageToEditHandlersFactory: PostMessageHandlersFactory = (cro
     accessTokenParser, loginRequiredStrictly, certifyAiService, validator, apiV3FormValidator,
     async(req: Req, res: ApiV3Response) => {
       const {
-        userMessage, pageBody, selectedText, threadId,
+        userMessage,
+        pageBody, isPageBodyPartial, partialPageBodyStartIndex,
+        selectedText, selectedPosition,
+        threadId,
       } = req.body;
 
       // Parameter check
@@ -205,8 +209,9 @@ export const postMessageToEditHandlersFactory: PostMessageHandlersFactory = (cro
         diffDetectedCallback: (detected) => {
           sseHelper.writeData<SseDetectedDiff>({ diff: detected });
         },
+        // eslint-disable-next-line @typescript-eslint/no-unused-vars
         dataFinalizedCallback: (message, replacements) => {
-          sseHelper.writeData<SseFinalized>({ finalized: { message: message ?? '', replacements } });
+          sseHelper.writeData<SseFinalized>({ success: true });
         },
       });
 
@@ -226,22 +231,20 @@ export const postMessageToEditHandlersFactory: PostMessageHandlersFactory = (cro
         // Create stream
         const stream = openaiClient.beta.threads.runs.stream(thread.id, {
           assistant_id: assistant.id,
+          additional_instructions: [
+            instruction(pageBody != null),
+            instructionForContexts({
+              pageBody,
+              isPageBodyPartial,
+              partialPageBodyStartIndex,
+              selectedText,
+              selectedPosition,
+            }),
+          ].join('\n'),
           additional_messages: [
-            {
-              role: 'assistant',
-              content: instruction(pageBody != null),
-            },
             {
               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'),

+ 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 { 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;
 
 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}
 ---
 
+# Multilingual Support:
+Always provide messages in the same language as the user's request.
+---
+
+${instructionsForContexts}
+---
+
+${instructionsForUserIntentDetection}
+---
+
 ${instructionsForFileSearch}
 `,
     /* eslint-enable max-len */

+ 123 - 0
apps/app/src/features/opentelemetry/docs/custom-metrics/architecture.md

@@ -0,0 +1,123 @@
+# OpenTelemetry Custom Metrics Architecture
+
+## 概要
+
+GROWIのOpenTelemetryカスタムメトリクスは、以下の3つのカテゴリに分類して実装されています:
+
+1. **Resource Attributes** - システム起動時に設定される静的情報
+2. **Config Metrics** - 設定変更により動的に変わる可能性があるメタデータ
+3. **Custom Metrics** - 時間と共に変化する業務メトリクス
+
+## アーキテクチャ
+
+### Resource Attributes
+
+静的なシステム情報をOpenTelemetryのResource Attributesとして設定します。Resource Attributesは2段階で設定されます:
+
+1. **起動時設定**: OS情報など、データベースアクセスが不要な静的情報
+2. **データベース初期化後設定**: アプリケーション情報など、データベースアクセスが必要な情報
+
+#### 実装場所
+```
+src/features/opentelemetry/server/custom-resource-attributes/
+├── os-resource-attributes.ts        # OS情報 (起動時設定)
+└── application-resource-attributes.ts  # アプリケーション固定情報 (DB初期化後設定)
+```
+
+#### OS情報 (`os-resource-attributes.ts`) - 起動時設定
+- `os.type` - OS種別 (Linux, Windows等)
+- `os.platform` - プラットフォーム (linux, darwin等)
+- `os.arch` - アーキテクチャ (x64, arm64等)
+- `os.totalmem` - 総メモリ量
+
+#### アプリケーション固定情報 (`application-resource-attributes.ts`) - DB初期化後設定
+- `growi.service.type` - サービスタイプ
+- `growi.deployment.type` - デプロイメントタイプ
+- `growi.attachment.type` - ファイルアップロードタイプ
+- `growi.installedAt` - インストール日時
+- `growi.installedAt.by_oldest_user` - 最古ユーザー作成日時
+
+### Config Metrics
+
+設定変更により動的に変わる可能性があるメタデータ実装します。値は常に1で、情報はラベルに格納されます。
+
+#### 実装場所
+```
+src/features/opentelemetry/server/custom-metrics/application-metrics.ts
+```
+
+#### 収集される情報
+- `service_instance_id` - サービスインスタンス識別子
+- `site_url` - サイトURL
+- `wiki_type` - Wiki種別 (open/closed)
+- `external_auth_types` - 有効な外部認証プロバイダー
+
+#### メトリクス例
+```
+growi_info{service_instance_id="abc123",site_url="https://wiki.example.com",wiki_type="open",external_auth_types="github,google"} 1
+```
+
+### Custom Metrics
+
+時間と共に変化する業務メトリクスを実装します。数値として監視・アラートの対象となるメトリクスです。
+
+#### 実装場所
+```
+src/features/opentelemetry/server/custom-metrics/
+├── application-metrics.ts  # Config Metrics (既存)
+└── user-counts-metrics.ts  # ユーザー数メトリクス (新規作成)
+```
+
+#### ユーザー数メトリクス (`user-counts-metrics.ts`)
+- `growi.users.total` - 総ユーザー数
+- `growi.users.active` - アクティブユーザー数
+
+## 収集間隔・設定タイミング
+
+### Resource Attributes
+- **OS情報**: アプリケーション起動時に1回のみ設定
+- **アプリケーション情報**: データベース初期化後に1回のみ設定
+
+### Metrics
+- **Config Metrics**: 60秒間隔で収集 (デフォルト)
+- **Custom Metrics**: 60秒間隔で収集 (デフォルト)
+
+### 2段階設定の理由
+
+Resource Attributesが2段階で設定される理由:
+
+1. **循環依存の回避**: アプリケーション情報の取得にはgrowiInfoServiceが必要だが、OpenTelemetry初期化時点では利用できない
+2. **データベース依存**: インストール日時やサービス設定などはデータベースから取得する必要がある
+3. **起動時間の最適化**: データベース接続を待たずにOpenTelemetryの基本機能を開始できる
+
+## 設定の変更
+
+メトリクス収集間隔は `PeriodicExportingMetricReader` の `exportIntervalMillis` で変更可能です:
+
+```typescript
+metricReader: new PeriodicExportingMetricReader({
+  exporter: new OTLPMetricExporter(),
+  exportIntervalMillis: 30000, // 30秒間隔
+}),
+```
+
+## 使用例
+
+### Prometheusでのクエリ例
+
+```promql
+# 総ユーザー数の推移
+growi_users_total
+
+# Wiki種別でグループ化した情報
+growi_info{wiki_type="open"}
+
+# 外部認証を使用しているインスタンス
+growi_info{external_auth_types!=""}
+```
+
+### Grafanaでの可視化例
+
+- ユーザー数の時系列グラフ
+- Wiki種別の分布円グラフ
+- 外部認証プロバイダーの利用状況

+ 87 - 0
apps/app/src/features/opentelemetry/docs/custom-metrics/implementation-guide.md

@@ -0,0 +1,87 @@
+# OpenTelemetry Custom Metrics Implementation Guide
+
+## 改修実装状況
+
+### ✅ 完了した実装
+
+#### 1. Resource Attributes
+- **OS情報**: `src/features/opentelemetry/server/custom-resource-attributes/os-resource-attributes.ts`
+  - OS種別、プラットフォーム、アーキテクチャ、総メモリ量
+  - 起動時に設定
+- **アプリケーション固定情報**: `src/features/opentelemetry/server/custom-resource-attributes/application-resource-attributes.ts`
+  - サービス・デプロイメントタイプ、添付ファイルタイプ、インストール情報
+  - データベース初期化後に設定
+
+#### 2. Config Metrics
+- **実装場所**: `src/features/opentelemetry/server/custom-metrics/application-metrics.ts`
+- **メトリクス**: `growi.configs` (値は常に1、情報はラベルに格納)
+- **収集情報**: サービスインスタンスID、サイトURL、Wiki種別、外部認証タイプ
+
+#### 3. Custom Metrics
+- **実装場所**: `src/features/opentelemetry/server/custom-metrics/user-counts-metrics.ts`
+- **メトリクス**: 
+  - `growi.users.total` - 総ユーザー数
+  - `growi.users.active` - アクティブユーザー数
+
+#### 4. 統合作業
+- **node-sdk-configuration.ts**: OS情報のResource Attributes統合済み
+- **node-sdk.ts**: データベース初期化後のアプリケーション情報設定統合済み
+- **メトリクス初期化**: Config MetricsとCustom Metricsの初期化統合済み
+
+### 📋 実装済みの統合
+
+#### Resource Attributesの2段階設定
+
+**1段階目 (起動時)**: `generateNodeSDKConfiguration`
+```typescript
+// OS情報のみでResourceを作成
+const osAttributes = getOsResourceAttributes();
+resource = resourceFromAttributes({
+  [ATTR_SERVICE_NAME]: 'growi',
+  [ATTR_SERVICE_VERSION]: version,
+  ...osAttributes,
+});
+```
+
+**2段階目 (DB初期化後)**: `setupAdditionalResourceAttributes`
+```typescript
+// アプリケーション情報とサービスインスタンスIDを追加
+const appAttributes = await getApplicationResourceAttributes();
+if (serviceInstanceId != null) {
+  appAttributes[ATTR_SERVICE_INSTANCE_ID] = serviceInstanceId;
+}
+const updatedResource = await generateAdditionalResourceAttributes(appAttributes);
+setResource(sdkInstance, updatedResource);
+```
+
+#### メトリクス収集の統合
+```typescript
+// generateNodeSDKConfiguration内で初期化
+addApplicationMetrics();
+addUserCountsMetrics();
+```
+
+## ファイル構成
+
+```
+src/features/opentelemetry/server/
+├── custom-resource-attributes/
+│   ├── index.ts                           # エクスポート用インデックス
+│   ├── os-resource-attributes.ts          # OS情報
+│   └── application-resource-attributes.ts # アプリケーション情報
+├── custom-metrics/
+│   ├── application-metrics.ts             # Config Metrics (更新済み)
+│   └── user-counts-metrics.ts             # ユーザー数メトリクス (新規)
+└── docs/
+    ├── custom-metrics-architecture.md     # アーキテクチャ文書
+    └── implementation-guide.md            # このファイル
+```
+
+## 設計のポイント
+
+1. **2段階Resource設定**: データベース依存の情報は初期化後に設定して循環依存を回避
+2. **循環依存の回避**: 動的importを使用してgrowiInfoServiceを読み込み
+3. **エラーハンドリング**: 各メトリクス収集でtry-catchを実装
+4. **型安全性**: Optional chainingを使用してundefinedを適切に処理
+5. **ログ出力**: デバッグ用のログを各段階で出力
+6. **起動時間の最適化**: データベース接続を待たずにOpenTelemetryの基本機能を開始

+ 49 - 0
apps/app/src/features/opentelemetry/docs/overview.md

@@ -0,0 +1,49 @@
+# OpenTelemetry Overview
+
+## 現在の実装状況
+
+### 基本機能
+- ✅ **Trace収集**: HTTP、Database等の自動インストルメンテーション
+- ✅ **Metrics収集**: 基本的なアプリケーションメトリクス
+- ✅ **OTLP Export**: gRPCでのデータ送信
+- ✅ **設定管理**: 環境変数による有効/無効制御
+
+### アーキテクチャ
+```
+[GROWI App] → [NodeSDK] → [Auto Instrumentations] → [OTLP Exporter] → [Collector]
+```
+
+### 実装ファイル
+| ファイル | 責務 |
+|---------|------|
+| `node-sdk.ts` | SDK初期化・管理 |
+| `node-sdk-configuration.ts` | 設定生成 |
+| `node-sdk-resource.ts` | リソース属性管理 |
+| `logger.ts` | 診断ログ |
+
+### 設定項目
+| 環境変数 | デフォルト | 説明 |
+|---------|-----------|------|
+| `OTEL_ENABLED` | `false` | 有効/無効 |
+| `OTEL_EXPORTER_OTLP_ENDPOINT` | `http://localhost:4317` | エクスポート先 |
+| `OTEL_SERVICE_NAME` | `growi` | サービス名 |
+| `OTEL_SERVICE_VERSION` | 自動 | バージョン |
+
+### データフロー
+1. **Auto Instrumentation** でHTTP/DB操作を自動計測
+2. **NodeSDK** がスパン・メトリクスを収集
+3. **OTLP Exporter** が外部Collectorに送信
+
+## 制限事項
+- 機密データの匿名化未実装
+- GROWIアプリ固有の情報未送信
+
+## 参考情報
+- [OpenTelemetry Node.js SDK](https://open-telemetry.github.io/opentelemetry-js/)
+- [Custom Metrics Documentation](https://opentelemetry.io/docs/instrumentation/js/manual/#creating-metrics)
+- [HTTP Instrumentation Configuration](https://github.com/open-telemetry/opentelemetry-js/tree/main/experimental/packages/opentelemetry-instrumentation-http#configuration)
+- [Semantic Conventions for System Metrics](https://github.com/open-telemetry/semantic-conventions/blob/main/docs/system/system-metrics.md)
+- [Resource Semantic Conventions](https://github.com/open-telemetry/semantic-conventions/blob/main/docs/resource/README.md)
+
+---
+*更新日: 2025-06-19*

+ 25 - 0
apps/app/src/features/opentelemetry/server/anonymization/anonymize-http-requests.ts

@@ -0,0 +1,25 @@
+import type { InstrumentationConfigMap } from '@opentelemetry/auto-instrumentations-node';
+
+import { anonymizationModules } from './handlers';
+
+export const httpInstrumentationConfig: InstrumentationConfigMap['@opentelemetry/instrumentation-http'] = {
+  startIncomingSpanHook: (request) => {
+    // Get URL from IncomingMessage (server-side requests)
+    const incomingRequest = request;
+    const url = incomingRequest.url || '';
+
+    const attributes = {};
+
+    // Use efficient module-based approach
+    for (const anonymizationModule of anonymizationModules) {
+      if (anonymizationModule.canHandle(url)) {
+        const moduleAttributes = anonymizationModule.handle(incomingRequest, url);
+        if (moduleAttributes != null) {
+          Object.assign(attributes, moduleAttributes);
+        }
+      }
+    }
+
+    return attributes;
+  },
+};

+ 16 - 0
apps/app/src/features/opentelemetry/server/anonymization/handlers/index.ts

@@ -0,0 +1,16 @@
+import type { AnonymizationModule } from '../interfaces/anonymization-module';
+
+import { pageAccessModule } from './page-access-handler';
+import { pageApiModule } from './page-api-handler';
+import { pageListingApiModule } from './page-listing-api-handler';
+import { searchApiModule } from './search-api-handler';
+
+/**
+ * List of anonymization modules
+ */
+export const anonymizationModules: AnonymizationModule[] = [
+  searchApiModule,
+  pageListingApiModule,
+  pageApiModule,
+  pageAccessModule,
+];

+ 77 - 0
apps/app/src/features/opentelemetry/server/anonymization/handlers/page-access-handler.spec.ts

@@ -0,0 +1,77 @@
+import type { IncomingMessage } from 'http';
+
+import { describe, it, expect } from 'vitest';
+
+import { pageAccessModule } from './page-access-handler';
+
+describe('pageAccessModule', () => {
+  describe('canHandle', () => {
+    it.each`
+      description                   | url                            | expected
+      ${'root path'}                | ${'/'}                         | ${false}
+      ${'API endpoint'}             | ${'/_api/v3/search'}           | ${false}
+      ${'static resource'}          | ${'/static/css/style.css'}     | ${false}
+      ${'favicon'}                  | ${'/favicon.ico'}              | ${false}
+      ${'assets'}                   | ${'/assets/image.png'}         | ${false}
+      ${'Next.js resource'}         | ${'/_next/chunk.js'}           | ${false}
+      ${'file with extension'}      | ${'/file.pdf'}                 | ${false}
+      ${'Users top page'}           | ${'/user'}                     | ${false}
+      ${'Users homepage'}           | ${'/user/john'}                | ${true}
+      ${'Users page'}               | ${'/user/john/projects'}       | ${true}
+      ${'page path'}                | ${'/path/to/page'}             | ${true}
+      ${'ObjectId path'}            | ${'/58a4569921a8424d00a1aa0e'} | ${false}
+      `('should return $expected for $description', ({ url, expected }) => {
+      const result = pageAccessModule.canHandle(url);
+      expect(result).toBe(expected);
+    });
+  });
+
+  describe('handle', () => {
+    describe('URL path anonymization', () => {
+      it.each`
+        description                     | url                                 | expectedPath
+        ${'user subpage path'}          | ${'/user/john/projects'}            | ${'/user/[USERNAME_HASHED:96d9632f363564cc]/[HASHED:2577c0f557b2e4b5]'}
+        ${'complex path'}               | ${'/wiki/project/documentation'}    | ${'/[HASHED:22ca1a8b9f281349]'}
+        ${'path with special chars'}    | ${'/user-name_123/project!'}        | ${'/[HASHED:7aa6a8f4468baa96]'}
+      `('should handle $description', ({ url, expectedPath }) => {
+        // Ensure canHandle returns true before calling handle
+        expect(pageAccessModule.canHandle(url)).toBe(true);
+
+        const mockRequest = {} as IncomingMessage;
+        const result = pageAccessModule.handle(mockRequest, url);
+
+        expect(result).toEqual({
+          'http.target': expectedPath,
+        });
+      });
+    });
+
+    it('should preserve query parameters', () => {
+      const mockRequest = {} as IncomingMessage;
+      const url = '/user/john?tab=projects&sort=date';
+
+      // Ensure canHandle returns true before calling handle
+      expect(pageAccessModule.canHandle(url)).toBe(true);
+
+      const result = pageAccessModule.handle(mockRequest, url);
+
+      expect(result).toEqual({
+        'http.target': '/user/[USERNAME_HASHED:96d9632f363564cc]?tab=projects&sort=date',
+      });
+    });
+
+    it('should handle complex query parameters', () => {
+      const mockRequest = {} as IncomingMessage;
+      const url = '/wiki/page?search=test&tags[]=tag1&tags[]=tag2&limit=10';
+
+      // Ensure canHandle returns true before calling handle
+      expect(pageAccessModule.canHandle(url)).toBe(true);
+
+      const result = pageAccessModule.handle(mockRequest, url);
+
+      expect(result).toEqual({
+        'http.target': '/[HASHED:2f4a824f8eacbc70]?search=test&tags[]=tag1&tags[]=tag2&limit=10',
+      });
+    });
+  });
+});

+ 157 - 0
apps/app/src/features/opentelemetry/server/anonymization/handlers/page-access-handler.ts

@@ -0,0 +1,157 @@
+import { createHash } from 'crypto';
+import type { IncomingMessage } from 'http';
+
+import {
+  isCreatablePage,
+  isUsersHomepage,
+  isUserPage,
+  isUsersTopPage,
+  isPermalink,
+  getUsernameByPath,
+} from '@growi/core/dist/utils/page-path-utils';
+import { diag } from '@opentelemetry/api';
+
+import { ATTR_HTTP_TARGET } from '../../semconv';
+import type { AnonymizationModule } from '../interfaces/anonymization-module';
+
+const logger = diag.createComponentLogger({ namespace: 'growi:anonymization:page-access-handler' });
+
+/**
+ * Create a hash of the given string
+ */
+function hashString(str: string): string {
+  return createHash('sha256').update(str).digest('hex').substring(0, 16);
+}
+
+/**
+ * Anonymize URL path by hashing non-ObjectId paths
+ * @param urlPath - The URL path to anonymize
+ * @returns Anonymized URL path
+ */
+function anonymizeUrlPath(urlPath: string): string {
+  try {
+    // If it's a permalink (ObjectId), don't anonymize
+    if (isPermalink(urlPath)) {
+      return urlPath;
+    }
+
+    // Handle user pages specially
+    if (isUserPage(urlPath)) {
+      const username = getUsernameByPath(urlPath);
+
+      if (isUsersHomepage(urlPath) && username) {
+        // For user homepage (/user/john), anonymize only the username
+        const hashedUsername = hashString(username);
+        return `/user/[USERNAME_HASHED:${hashedUsername}]`;
+      }
+
+      if (username) {
+        // For user sub-pages (/user/john/projects), anonymize username and remaining path separately
+        const hashedUsername = hashString(username);
+        const remainingPath = urlPath.replace(`/user/${username}`, '');
+
+        if (remainingPath) {
+          const cleanRemainingPath = remainingPath.replace(/^\/+|\/+$/g, '');
+          const hashedRemainingPath = hashString(cleanRemainingPath);
+          const leadingSlash = remainingPath.startsWith('/') ? '/' : '';
+          const trailingSlash = remainingPath.endsWith('/') && remainingPath.length > 1 ? '/' : '';
+
+          return `/user/[USERNAME_HASHED:${hashedUsername}]${leadingSlash}[HASHED:${hashedRemainingPath}]${trailingSlash}`;
+        }
+      }
+    }
+
+    // For regular pages, use the original logic
+    const cleanPath = urlPath.replace(/^\/+|\/+$/g, '');
+
+    // If empty path, return as-is
+    if (!cleanPath) {
+      return urlPath;
+    }
+
+    // Hash the path and return with original slash structure
+    const hashedPath = hashString(cleanPath);
+    const leadingSlash = urlPath.startsWith('/') ? '/' : '';
+    const trailingSlash = urlPath.endsWith('/') && urlPath.length > 1 ? '/' : '';
+
+    return `${leadingSlash}[HASHED:${hashedPath}]${trailingSlash}`;
+  }
+  catch (error) {
+    logger.warn(`Failed to anonymize URL path: ${error}`);
+    return urlPath;
+  }
+}
+
+/**
+ * Page access anonymization module for non-API requests
+ */
+export const pageAccessModule: AnonymizationModule = {
+  /**
+   * Check if this module can handle page access requests (non-API)
+   */
+  canHandle(url: string): boolean {
+    try {
+      const parsedUrl = new URL(url, 'http://localhost');
+      const path = parsedUrl.pathname;
+
+      // Exclude root path
+      if (path === '/') return false;
+
+      // Exclude static resources first
+      if (path.includes('/static/')
+        || path.includes('/_next/')
+        || path.includes('/favicon')
+        || path.includes('/assets/')
+        || path.includes('.')) { // Exclude file extensions (images, css, js, etc.)
+        return false;
+      }
+
+      // Exclude users top page (/user)
+      if (isUsersTopPage(path)) return false;
+
+      // Exclude permalink (ObjectId) paths
+      if (isPermalink(path)) return false;
+
+      // Handle user pages (including homepage and sub-pages)
+      if (isUserPage(path)) return true;
+
+      // Use GROWI's isCreatablePage logic to determine if this is a valid page path
+      // This excludes API endpoints, system paths, etc.
+      return isCreatablePage(path);
+    }
+    catch {
+      // If URL parsing fails, don't handle it
+      return false;
+    }
+  },
+
+  /**
+   * Handle anonymization for page access requests
+   */
+  handle(request: IncomingMessage, url: string): Record<string, string> | null {
+    try {
+      const parsedUrl = new URL(url, 'http://localhost');
+      const originalPath = parsedUrl.pathname;
+
+      // Anonymize the URL path
+      const anonymizedPath = anonymizeUrlPath(originalPath);
+
+      // Only return attributes if path was actually anonymized
+      if (anonymizedPath !== originalPath) {
+        const anonymizedUrl = anonymizedPath + parsedUrl.search;
+
+        logger.debug(`Anonymized page access URL: ${url} -> ${anonymizedUrl}`);
+
+        return {
+          [ATTR_HTTP_TARGET]: anonymizedUrl,
+        };
+      }
+
+      return null;
+    }
+    catch (error) {
+      logger.warn(`Failed to anonymize page access URL: ${error}`);
+      return null;
+    }
+  },
+};

+ 238 - 0
apps/app/src/features/opentelemetry/server/anonymization/handlers/page-api-handler.spec.ts

@@ -0,0 +1,238 @@
+import type { IncomingMessage } from 'http';
+
+import {
+  describe, it, expect, beforeEach,
+} from 'vitest';
+
+import { pageApiModule } from './page-api-handler';
+
+describe('pageApiModule', () => {
+  const mockRequest = {} as IncomingMessage;
+
+  beforeEach(() => {
+    // No mocks needed - test actual behavior
+  });
+
+  describe('canHandle', () => {
+    it.each`
+      description                                          | url                                                              | expected
+      ${'pages list endpoint'}                             | ${'/_api/v3/pages/list?path=/home'}                              | ${true}
+      ${'subordinated list endpoint'}                      | ${'/_api/v3/pages/subordinated-list?path=/docs'}                 | ${true}
+      ${'check page existence endpoint'}                   | ${'/_api/v3/page/check-page-existence?path=/wiki'}               | ${true}
+      ${'get page paths with descendant count endpoint'}   | ${'/_api/v3/page/get-page-paths-with-descendant-count?paths=[]'} | ${true}
+      ${'pages list without query'}                        | ${'/_api/v3/pages/list'}                                         | ${true}
+      ${'subordinated list without query'}                 | ${'/_api/v3/pages/subordinated-list'}                            | ${true}
+      ${'check page existence without query'}              | ${'/_api/v3/page/check-page-existence'}                          | ${true}
+      ${'get page paths without query'}                    | ${'/_api/v3/page/get-page-paths-with-descendant-count'}          | ${true}
+      ${'other pages endpoint'}                            | ${'/_api/v3/pages/create'}                                       | ${false}
+      ${'different API version'}                           | ${'/_api/v2/pages/list'}                                         | ${false}
+      ${'non-page API'}                                    | ${'/_api/v3/search'}                                             | ${false}
+      ${'regular page path'}                               | ${'/page/path'}                                                  | ${false}
+      ${'root path'}                                       | ${'/'}                                                           | ${false}
+      ${'empty URL'}                                       | ${''}                                                            | ${false}
+      ${'partial match but different endpoint'}            | ${'/_api/v3/pages-other/list'}                                   | ${false}
+    `('should return $expected for $description: $url', ({ url, expected }) => {
+      const result = pageApiModule.canHandle(url);
+      expect(result).toBe(expected);
+    });
+  });
+
+  describe('handle', () => {
+    describe('pages/list endpoint', () => {
+      it('should anonymize path parameter when present', () => {
+        const originalUrl = '/_api/v3/pages/list?path=/sensitive/path&limit=10';
+
+        // Verify canHandle returns true for this URL
+        expect(pageApiModule.canHandle(originalUrl)).toBe(true);
+
+        const result = pageApiModule.handle(mockRequest, originalUrl);
+
+        expect(result).toEqual({
+          'http.target': '/_api/v3/pages/list?path=%5BANONYMIZED%5D&limit=10',
+        });
+      });
+
+      it('should return null when no path parameter is present', () => {
+        const url = '/_api/v3/pages/list?limit=10&sort=updated';
+
+        // Verify canHandle returns true for this URL
+        expect(pageApiModule.canHandle(url)).toBe(true);
+
+        const result = pageApiModule.handle(mockRequest, url);
+
+        expect(result).toBeNull();
+      });
+    });
+
+    describe('pages/subordinated-list endpoint', () => {
+      it('should anonymize path parameter', () => {
+        const originalUrl = '/_api/v3/pages/subordinated-list?path=/user/documents&offset=0';
+
+        // Verify canHandle returns true for this URL
+        expect(pageApiModule.canHandle(originalUrl)).toBe(true);
+
+        const result = pageApiModule.handle(mockRequest, originalUrl);
+
+        expect(result).toEqual({
+          'http.target': '/_api/v3/pages/subordinated-list?path=%5BANONYMIZED%5D&offset=0',
+        });
+      });
+
+      it('should handle encoded path parameters', () => {
+        const originalUrl = '/_api/v3/pages/subordinated-list?path=%2Fuser%2Fdocs&includeEmpty=true';
+
+        // Verify canHandle returns true for this URL
+        expect(pageApiModule.canHandle(originalUrl)).toBe(true);
+
+        const result = pageApiModule.handle(mockRequest, originalUrl);
+
+        expect(result).toEqual({
+          'http.target': '/_api/v3/pages/subordinated-list?path=%5BANONYMIZED%5D&includeEmpty=true',
+        });
+      });
+    });
+
+    describe('page/check-page-existence endpoint', () => {
+      it('should anonymize path parameter', () => {
+        const originalUrl = '/_api/v3/page/check-page-existence?path=/project/wiki';
+
+        // Verify canHandle returns true for this URL
+        expect(pageApiModule.canHandle(originalUrl)).toBe(true);
+
+        const result = pageApiModule.handle(mockRequest, originalUrl);
+
+        expect(result).toEqual({
+          'http.target': '/_api/v3/page/check-page-existence?path=%5BANONYMIZED%5D',
+        });
+      });
+
+      it('should handle multiple parameters including path', () => {
+        const originalUrl = '/_api/v3/page/check-page-existence?path=/docs/api&includePrivate=false';
+
+        // Verify canHandle returns true for this URL
+        expect(pageApiModule.canHandle(originalUrl)).toBe(true);
+
+        const result = pageApiModule.handle(mockRequest, originalUrl);
+
+        expect(result).toEqual({
+          'http.target': '/_api/v3/page/check-page-existence?path=%5BANONYMIZED%5D&includePrivate=false',
+        });
+      });
+    });
+
+    describe('page/get-page-paths-with-descendant-count endpoint', () => {
+      it('should anonymize paths parameter when present', () => {
+        const originalUrl = '/_api/v3/page/get-page-paths-with-descendant-count?paths=["/docs","/wiki"]';
+
+        // Verify canHandle returns true for this URL
+        expect(pageApiModule.canHandle(originalUrl)).toBe(true);
+
+        const result = pageApiModule.handle(mockRequest, originalUrl);
+
+        expect(result).toEqual({
+          'http.target': '/_api/v3/page/get-page-paths-with-descendant-count?paths=%5B%22%5BANONYMIZED%5D%22%5D',
+        });
+      });
+
+      it('should handle encoded paths parameter', () => {
+        const originalUrl = '/_api/v3/page/get-page-paths-with-descendant-count?paths=%5B%22%2Fdocs%22%5D';
+
+        // Verify canHandle returns true for this URL
+        expect(pageApiModule.canHandle(originalUrl)).toBe(true);
+
+        const result = pageApiModule.handle(mockRequest, originalUrl);
+
+        expect(result).toEqual({
+          'http.target': '/_api/v3/page/get-page-paths-with-descendant-count?paths=%5B%22%5BANONYMIZED%5D%22%5D',
+        });
+      });
+
+      it('should return null when no paths parameter is present', () => {
+        const url = '/_api/v3/page/get-page-paths-with-descendant-count?includeEmpty=true';
+
+        // Verify canHandle returns true for this URL
+        expect(pageApiModule.canHandle(url)).toBe(true);
+
+        const result = pageApiModule.handle(mockRequest, url);
+
+        expect(result).toBeNull();
+      });
+    });
+
+    describe('mixed endpoint scenarios', () => {
+      it('should handle pages/list endpoint without path parameter', () => {
+        const url = '/_api/v3/pages/list?limit=20&sort=name';
+
+        // Verify canHandle returns true for this URL
+        expect(pageApiModule.canHandle(url)).toBe(true);
+
+        const result = pageApiModule.handle(mockRequest, url);
+
+        expect(result).toBeNull();
+      });
+
+      it('should handle subordinated-list endpoint without path parameter', () => {
+        const url = '/_api/v3/pages/subordinated-list?includeEmpty=false';
+
+        // Verify canHandle returns true for this URL
+        expect(pageApiModule.canHandle(url)).toBe(true);
+
+        const result = pageApiModule.handle(mockRequest, url);
+
+        expect(result).toBeNull();
+      });
+
+      it('should handle check-page-existence endpoint without path parameter', () => {
+        const url = '/_api/v3/page/check-page-existence?includePrivate=true';
+
+        // Verify canHandle returns true for this URL
+        expect(pageApiModule.canHandle(url)).toBe(true);
+
+        const result = pageApiModule.handle(mockRequest, url);
+
+        expect(result).toBeNull();
+      });
+    });
+
+    describe('edge cases', () => {
+      it('should handle empty path parameter', () => {
+        const originalUrl = '/_api/v3/pages/list?path=&limit=5';
+
+        // Verify canHandle returns true for this URL
+        expect(pageApiModule.canHandle(originalUrl)).toBe(true);
+
+        const result = pageApiModule.handle(mockRequest, originalUrl);
+
+        expect(result).toEqual({
+          'http.target': '/_api/v3/pages/list?path=%5BANONYMIZED%5D&limit=5',
+        });
+      });
+
+      it('should handle root path parameter', () => {
+        const originalUrl = '/_api/v3/page/check-page-existence?path=/';
+
+        // Verify canHandle returns true for this URL
+        expect(pageApiModule.canHandle(originalUrl)).toBe(true);
+
+        const result = pageApiModule.handle(mockRequest, originalUrl);
+
+        expect(result).toEqual({
+          'http.target': '/_api/v3/page/check-page-existence?path=%5BANONYMIZED%5D',
+        });
+      });
+
+      it('should handle empty paths array parameter', () => {
+        const originalUrl = '/_api/v3/page/get-page-paths-with-descendant-count?paths=[]';
+
+        // Verify canHandle returns true for this URL
+        expect(pageApiModule.canHandle(originalUrl)).toBe(true);
+
+        const result = pageApiModule.handle(mockRequest, originalUrl);
+
+        expect(result).toEqual({
+          'http.target': '/_api/v3/page/get-page-paths-with-descendant-count?paths=%5BANONYMIZED%5D',
+        });
+      });
+    });
+  });
+});

+ 61 - 0
apps/app/src/features/opentelemetry/server/anonymization/handlers/page-api-handler.ts

@@ -0,0 +1,61 @@
+import type { IncomingMessage } from 'http';
+
+import { diag } from '@opentelemetry/api';
+
+import { ATTR_HTTP_TARGET } from '../../semconv';
+import type { AnonymizationModule } from '../interfaces/anonymization-module';
+import { anonymizeQueryParams } from '../utils/anonymize-query-params';
+
+const logger = diag.createComponentLogger({ namespace: 'growi:anonymization:page-api-handler' });
+
+/**
+ * Page API anonymization module
+ */
+export const pageApiModule: AnonymizationModule = {
+  /**
+   * Check if this module can handle page API endpoints
+   */
+  canHandle(url: string): boolean {
+    return url.includes('/_api/v3/pages/list')
+      || url.includes('/_api/v3/pages/subordinated-list')
+      || url.includes('/_api/v3/page/check-page-existence')
+      || url.includes('/_api/v3/page/get-page-paths-with-descendant-count');
+  },
+
+  /**
+   * Handle anonymization for page API endpoints
+   */
+  handle(request: IncomingMessage, url: string): Record<string, string> | null {
+    const attributes: Record<string, string> = {};
+    let hasAnonymization = false;
+
+    // Handle endpoints with 'path' parameter
+    if (url.includes('path=') && (
+      url.includes('/_api/v3/pages/list')
+      || url.includes('/_api/v3/pages/subordinated-list')
+      || url.includes('/_api/v3/page/check-page-existence')
+    )) {
+      const anonymizedUrl = anonymizeQueryParams(url, ['path']);
+      attributes[ATTR_HTTP_TARGET] = anonymizedUrl;
+      hasAnonymization = true;
+
+      // Determine endpoint type for logging
+      let endpointType = 'page API';
+      if (url.includes('/_api/v3/pages/list')) endpointType = '/pages/list';
+      else if (url.includes('/_api/v3/pages/subordinated-list')) endpointType = '/pages/subordinated-list';
+      else if (url.includes('/_api/v3/page/check-page-existence')) endpointType = '/page/check-page-existence';
+
+      logger.debug(`Anonymized ${endpointType} URL: ${url} -> ${anonymizedUrl}`);
+    }
+
+    // Handle page/get-page-paths-with-descendant-count endpoint with paths parameter
+    if (url.includes('/_api/v3/page/get-page-paths-with-descendant-count') && url.includes('paths=')) {
+      const anonymizedUrl = anonymizeQueryParams(url, ['paths']);
+      attributes[ATTR_HTTP_TARGET] = anonymizedUrl;
+      hasAnonymization = true;
+      logger.debug(`Anonymized page/get-page-paths-with-descendant-count URL: ${url} -> ${anonymizedUrl}`);
+    }
+
+    return hasAnonymization ? attributes : null;
+  },
+};

+ 173 - 0
apps/app/src/features/opentelemetry/server/anonymization/handlers/page-listing-api-handler.spec.ts

@@ -0,0 +1,173 @@
+import type { IncomingMessage } from 'http';
+
+import {
+  describe, it, expect, beforeEach,
+} from 'vitest';
+
+import { pageListingApiModule } from './page-listing-api-handler';
+
+describe('pageListingApiModule', () => {
+  const mockRequest = {} as IncomingMessage;
+
+  beforeEach(() => {
+    // No mocks needed - test actual behavior
+  });
+
+  describe('canHandle', () => {
+    it.each`
+      description                           | url                                                    | expected
+      ${'ancestors-children endpoint'}      | ${'/_api/v3/page-listing/ancestors-children?path=/'}  | ${true}
+      ${'children endpoint'}                | ${'/_api/v3/page-listing/children?path=/docs'}        | ${true}
+      ${'info endpoint'}                    | ${'/_api/v3/page-listing/info?path=/wiki'}            | ${true}
+      ${'ancestors-children without query'} | ${'/_api/v3/page-listing/ancestors-children'}         | ${true}
+      ${'children without query'}           | ${'/_api/v3/page-listing/children'}                   | ${true}
+      ${'info without query'}               | ${'/_api/v3/page-listing/info'}                       | ${true}
+      ${'other page-listing endpoint'}      | ${'/_api/v3/page-listing/other'}                      | ${false}
+      ${'different API version'}            | ${'/_api/v2/page-listing/children'}                   | ${false}
+      ${'non-page-listing API'}             | ${'/_api/v3/pages/list'}                              | ${false}
+      ${'regular page path'}                | ${'/page/path'}                                       | ${false}
+      ${'root path'}                        | ${'/'}                                                | ${false}
+      ${'empty URL'}                        | ${''}                                                 | ${false}
+      ${'partial match'}                    | ${'/_api/v3/page-listing-other/children'}             | ${false}
+    `('should return $expected for $description: $url', ({ url, expected }) => {
+      const result = pageListingApiModule.canHandle(url);
+      expect(result).toBe(expected);
+    });
+  });
+
+  describe('handle', () => {
+    describe('ancestors-children endpoint', () => {
+      it('should anonymize path parameter when present', () => {
+        const originalUrl = '/_api/v3/page-listing/ancestors-children?path=/sensitive/path&limit=10';
+
+        // Ensure canHandle returns true for this URL
+        expect(pageListingApiModule.canHandle(originalUrl)).toBe(true);
+
+        const result = pageListingApiModule.handle(mockRequest, originalUrl);
+
+        expect(result).toEqual({
+          'http.target': '/_api/v3/page-listing/ancestors-children?path=%5BANONYMIZED%5D&limit=10',
+        });
+      });
+
+      it('should anonymize empty path parameter', () => {
+        const originalUrl = '/_api/v3/page-listing/ancestors-children?path=&limit=5';
+
+        // Ensure canHandle returns true for this URL
+        expect(pageListingApiModule.canHandle(originalUrl)).toBe(true);
+
+        const result = pageListingApiModule.handle(mockRequest, originalUrl);
+
+        // Empty path parameter should now be anonymized
+        expect(result).toEqual({
+          'http.target': '/_api/v3/page-listing/ancestors-children?path=%5BANONYMIZED%5D&limit=5',
+        });
+      });
+
+      it('should return null when no path parameter is present', () => {
+        const originalUrl = '/_api/v3/page-listing/ancestors-children?limit=10';
+
+        // Ensure canHandle returns true for this URL
+        expect(pageListingApiModule.canHandle(originalUrl)).toBe(true);
+
+        const result = pageListingApiModule.handle(mockRequest, originalUrl);
+
+        expect(result).toBeNull();
+      });
+    });
+
+    describe('children endpoint', () => {
+      it('should anonymize path parameter when present', () => {
+        const originalUrl = '/_api/v3/page-listing/children?path=/docs/api&offset=0&limit=20';
+
+        // Ensure canHandle returns true for this URL
+        expect(pageListingApiModule.canHandle(originalUrl)).toBe(true);
+
+        const result = pageListingApiModule.handle(mockRequest, originalUrl);
+
+        expect(result).toEqual({
+          'http.target': '/_api/v3/page-listing/children?path=%5BANONYMIZED%5D&offset=0&limit=20',
+        });
+      });
+
+      it('should handle encoded path parameter', () => {
+        const originalUrl = '/_api/v3/page-listing/children?path=%2Fencoded%2Fpath&limit=10';
+
+        // Ensure canHandle returns true for this URL
+        expect(pageListingApiModule.canHandle(originalUrl)).toBe(true);
+
+        const result = pageListingApiModule.handle(mockRequest, originalUrl);
+
+        expect(result).toEqual({
+          'http.target': '/_api/v3/page-listing/children?path=%5BANONYMIZED%5D&limit=10',
+        });
+      });
+
+      it('should return null when no path parameter is present', () => {
+        const originalUrl = '/_api/v3/page-listing/children?limit=10';
+
+        // Ensure canHandle returns true for this URL
+        expect(pageListingApiModule.canHandle(originalUrl)).toBe(true);
+
+        const result = pageListingApiModule.handle(mockRequest, originalUrl);
+
+        expect(result).toBeNull();
+      });
+    });
+
+    describe('info endpoint', () => {
+      it('should anonymize path parameter when present', () => {
+        const originalUrl = '/_api/v3/page-listing/info?path=/wiki/documentation';
+
+        // Ensure canHandle returns true for this URL
+        expect(pageListingApiModule.canHandle(originalUrl)).toBe(true);
+
+        const result = pageListingApiModule.handle(mockRequest, originalUrl);
+
+        expect(result).toEqual({
+          'http.target': '/_api/v3/page-listing/info?path=%5BANONYMIZED%5D',
+        });
+      });
+
+      it('should return null when no path parameter is present', () => {
+        const originalUrl = '/_api/v3/page-listing/info';
+
+        // Ensure canHandle returns true for this URL
+        expect(pageListingApiModule.canHandle(originalUrl)).toBe(true);
+
+        const result = pageListingApiModule.handle(mockRequest, originalUrl);
+
+        expect(result).toBeNull();
+      });
+    });
+
+    describe('edge cases', () => {
+      it('should handle URL with complex query parameters', () => {
+        const originalUrl = '/_api/v3/page-listing/ancestors-children?path=/complex/path&sort=name&direction=asc&filter=active';
+
+        // Ensure canHandle returns true for this URL
+        expect(pageListingApiModule.canHandle(originalUrl)).toBe(true);
+
+        const result = pageListingApiModule.handle(mockRequest, originalUrl);
+
+        expect(result).toEqual({
+          'http.target': '/_api/v3/page-listing/ancestors-children?path=%5BANONYMIZED%5D&sort=name&direction=asc&filter=active',
+        });
+      });
+
+      it('should handle URL with fragment', () => {
+        const originalUrl = '/_api/v3/page-listing/children?path=/docs#section';
+
+        // Ensure canHandle returns true for this URL
+        expect(pageListingApiModule.canHandle(originalUrl)).toBe(true);
+
+        const result = pageListingApiModule.handle(mockRequest, originalUrl);
+
+        // Fragment should be preserved by anonymizeQueryParams
+        expect(result).toEqual({
+          'http.target': '/_api/v3/page-listing/children?path=%5BANONYMIZED%5D#section',
+        });
+      });
+    });
+  });
+});

+ 49 - 0
apps/app/src/features/opentelemetry/server/anonymization/handlers/page-listing-api-handler.ts

@@ -0,0 +1,49 @@
+import type { IncomingMessage } from 'http';
+
+import { diag } from '@opentelemetry/api';
+
+import { ATTR_HTTP_TARGET } from '../../semconv';
+import type { AnonymizationModule } from '../interfaces/anonymization-module';
+import { anonymizeQueryParams } from '../utils/anonymize-query-params';
+
+const logger = diag.createComponentLogger({ namespace: 'growi:anonymization:page-listing-handler' });
+
+/**
+ * Page listing API anonymization module
+ */
+export const pageListingApiModule: AnonymizationModule = {
+  /**
+   * Check if this module can handle page-listing API endpoints
+   */
+  canHandle(url: string): boolean {
+    return url.includes('/_api/v3/page-listing/ancestors-children')
+      || url.includes('/_api/v3/page-listing/children')
+      || url.includes('/_api/v3/page-listing/info');
+    // Add other page-listing endpoints here as needed
+  },
+
+  /**
+   * Handle anonymization for page-listing API endpoints
+   */
+  handle(request: IncomingMessage, url: string): Record<string, string> | null {
+    const attributes: Record<string, string> = {};
+    let hasAnonymization = false;
+
+    // Handle ancestors-children endpoint
+    if (
+      url.includes('/_api/v3/page-listing/ancestors-children')
+      || url.includes('/_api/v3/page-listing/children')
+      || url.includes('/_api/v3/page-listing/info')
+    ) {
+      const anonymizedUrl = anonymizeQueryParams(url, ['path']);
+      // Only set attributes if the URL was actually modified
+      if (anonymizedUrl !== url) {
+        attributes[ATTR_HTTP_TARGET] = anonymizedUrl;
+        hasAnonymization = true;
+        logger.debug(`Anonymized page-listing URL: ${url} -> ${anonymizedUrl}`);
+      }
+    }
+
+    return hasAnonymization ? attributes : null;
+  },
+};

+ 168 - 0
apps/app/src/features/opentelemetry/server/anonymization/handlers/search-api-handler.spec.ts

@@ -0,0 +1,168 @@
+import type { IncomingMessage } from 'http';
+
+import {
+  describe, it, expect, beforeEach,
+} from 'vitest';
+
+import { searchApiModule } from './search-api-handler';
+
+describe('searchApiModule', () => {
+  const mockRequest = {} as IncomingMessage;
+
+  beforeEach(() => {
+    // No mocks needed - test actual behavior
+  });
+
+  describe('canHandle', () => {
+    it.each`
+      description                     | url                                 | expected
+      ${'search API endpoint'}        | ${'/_api/search?q=test'}            | ${true}
+      ${'search API without query'}   | ${'/_api/search'}                   | ${true}
+      ${'search endpoint'}            | ${'/_search?q=keyword'}             | ${true}
+      ${'search endpoint without q'}  | ${'/_search'}                       | ${true}
+      ${'nested search API'}          | ${'/admin/_api/search?q=admin'}     | ${true}
+      ${'nested search endpoint'}     | ${'/docs/_search?q=documentation'}  | ${true}
+      ${'other API endpoint'}         | ${'/_api/pages'}                    | ${false}
+      ${'regular page path'}          | ${'/search/results'}                | ${false}
+      ${'similar but different'}      | ${'/_api/search-results'}           | ${false}
+      ${'root path'}                  | ${'/'}                              | ${false}
+      ${'empty URL'}                  | ${''}                               | ${false}
+    `('should return $expected for $description: $url', ({ url, expected }) => {
+      const result = searchApiModule.canHandle(url);
+      expect(result).toBe(expected);
+    });
+  });
+
+  describe('handle', () => {
+    describe('search API with query parameter', () => {
+      it('should anonymize search query when q parameter is present', () => {
+        const originalUrl = '/_api/search?q=sensitive search term&limit=10';
+
+        // Ensure canHandle returns true for this URL
+        expect(searchApiModule.canHandle(originalUrl)).toBe(true);
+
+        const result = searchApiModule.handle(mockRequest, originalUrl);
+
+        expect(result).toEqual({
+          'http.target': '/_api/search?q=%5BANONYMIZED%5D&limit=10',
+        });
+      });
+
+      it('should handle encoded query parameters', () => {
+        const originalUrl = '/_search?q=encoded%20search%20term&sort=relevance';
+
+        // Ensure canHandle returns true for this URL
+        expect(searchApiModule.canHandle(originalUrl)).toBe(true);
+
+        const result = searchApiModule.handle(mockRequest, originalUrl);
+
+        expect(result).toEqual({
+          'http.target': '/_search?q=%5BANONYMIZED%5D&sort=relevance',
+        });
+      });
+
+      it('should handle empty query parameter', () => {
+        const originalUrl = '/_api/search?q=&page=1';
+
+        // Ensure canHandle returns true for this URL
+        expect(searchApiModule.canHandle(originalUrl)).toBe(true);
+
+        const result = searchApiModule.handle(mockRequest, originalUrl);
+
+        expect(result).toEqual({
+          'http.target': '/_api/search?q=%5BANONYMIZED%5D&page=1',
+        });
+      });
+
+      it('should handle complex query with special characters', () => {
+        const originalUrl = '/_search?q=user:john+tag:important&format=json';
+
+        // Ensure canHandle returns true for this URL
+        expect(searchApiModule.canHandle(originalUrl)).toBe(true);
+
+        const result = searchApiModule.handle(mockRequest, originalUrl);
+
+        expect(result).toEqual({
+          'http.target': '/_search?q=%5BANONYMIZED%5D&format=json',
+        });
+      });
+    });
+
+    describe('search API without query parameter', () => {
+      it('should return null when no q parameter is present', () => {
+        const url = '/_api/search?limit=20&sort=date';
+
+        // Ensure canHandle returns true for this URL
+        expect(searchApiModule.canHandle(url)).toBe(true);
+
+        const result = searchApiModule.handle(mockRequest, url);
+
+        expect(result).toBeNull();
+      });
+
+      it('should return null for search endpoint without query', () => {
+        const url = '/_search?page=2&format=json';
+
+        // Ensure canHandle returns true for this URL
+        expect(searchApiModule.canHandle(url)).toBe(true);
+
+        const result = searchApiModule.handle(mockRequest, url);
+
+        expect(result).toBeNull();
+      });
+
+      it('should return null for search API without any parameters', () => {
+        const url = '/_api/search';
+
+        // Ensure canHandle returns true for this URL
+        expect(searchApiModule.canHandle(url)).toBe(true);
+
+        const result = searchApiModule.handle(mockRequest, url);
+
+        expect(result).toBeNull();
+      });
+    });
+
+    describe('edge cases', () => {
+      it('should handle multiple q parameters', () => {
+        const originalUrl = '/_api/search?q=first&q=second&limit=5';
+
+        // Ensure canHandle returns true for this URL
+        expect(searchApiModule.canHandle(originalUrl)).toBe(true);
+
+        const result = searchApiModule.handle(mockRequest, originalUrl);
+
+        expect(result).toEqual({
+          'http.target': '/_api/search?q=%5BANONYMIZED%5D&limit=5',
+        });
+      });
+
+      it('should preserve other parameters while anonymizing q', () => {
+        const originalUrl = '/_search?category=docs&q=secret&page=1&sort=date';
+
+        // Ensure canHandle returns true for this URL
+        expect(searchApiModule.canHandle(originalUrl)).toBe(true);
+
+        const result = searchApiModule.handle(mockRequest, originalUrl);
+
+        // The actual output may have different parameter order due to URL parsing
+        expect(result).toEqual({
+          'http.target': '/_search?category=docs&q=%5BANONYMIZED%5D&page=1&sort=date',
+        });
+      });
+
+      it('should handle URLs with fragments', () => {
+        const originalUrl = '/_api/search?q=test#results';
+
+        // Ensure canHandle returns true for this URL
+        expect(searchApiModule.canHandle(originalUrl)).toBe(true);
+
+        const result = searchApiModule.handle(mockRequest, originalUrl);
+
+        expect(result).toEqual({
+          'http.target': '/_api/search?q=%5BANONYMIZED%5D#results',
+        });
+      });
+    });
+  });
+});

+ 42 - 0
apps/app/src/features/opentelemetry/server/anonymization/handlers/search-api-handler.ts

@@ -0,0 +1,42 @@
+import type { IncomingMessage } from 'http';
+
+import { diag } from '@opentelemetry/api';
+
+import { ATTR_HTTP_TARGET } from '../../semconv';
+import type { AnonymizationModule } from '../interfaces/anonymization-module';
+import { anonymizeQueryParams } from '../utils/anonymize-query-params';
+
+const logger = diag.createComponentLogger({ namespace: 'growi:anonymization:search-handler' });
+
+/**
+ * Search API anonymization module
+ */
+export const searchApiModule: AnonymizationModule = {
+  /**
+   * Check if this module can handle search API endpoints
+   */
+  canHandle(url: string): boolean {
+    // More precise matching to avoid false positives
+    return url.match(/\/_api\/search(\?|$)/) !== null || url.match(/\/_search(\?|$)/) !== null
+           || url.includes('/_api/search/') || url.includes('/_search/');
+  },
+
+  /**
+   * Handle anonymization for search API endpoints
+   */
+  handle(request: IncomingMessage, url: string): Record<string, string> | null {
+    // Check if this is a search request that needs anonymization
+    // Look for q parameter anywhere in the query string
+    if (url.includes('?q=') || url.includes('&q=')) {
+      const anonymizedUrl = anonymizeQueryParams(url, ['q']);
+
+      logger.debug(`Anonymized search API URL: ${url} -> ${anonymizedUrl}`);
+
+      return {
+        [ATTR_HTTP_TARGET]: anonymizedUrl,
+      };
+    }
+
+    return null;
+  },
+};

+ 1 - 0
apps/app/src/features/opentelemetry/server/anonymization/index.ts

@@ -0,0 +1 @@
+export * from './anonymize-http-requests';

+ 21 - 0
apps/app/src/features/opentelemetry/server/anonymization/interfaces/anonymization-module.ts

@@ -0,0 +1,21 @@
+import type { IncomingMessage } from 'http';
+
+/**
+ * Interface for anonymization modules
+ */
+export interface AnonymizationModule {
+  /**
+   * Check if this module can handle the given URL
+   * @param url - The request URL
+   * @returns true if this module should process the request
+   */
+  canHandle(url: string): boolean;
+
+  /**
+   * Process anonymization for the request
+   * @param request - The HTTP request
+   * @param url - The request URL
+   * @returns Attributes to be set on the span, or null if no anonymization needed
+   */
+  handle(request: IncomingMessage, url: string): Record<string, string> | null;
+}

+ 38 - 0
apps/app/src/features/opentelemetry/server/anonymization/utils/anonymize-query-params.spec.ts

@@ -0,0 +1,38 @@
+import { describe, it, expect } from 'vitest';
+
+import { anonymizeQueryParams } from './anonymize-query-params';
+
+describe('anonymizeQueryParams', () => {
+  /* eslint-disable max-len */
+  it.each`
+    description                       | target                                                                 | paramNames         | expected
+    ${'no matching parameters'}       | ${'/_api/v3/test?other=value&another=test'}                            | ${['nonexistent']} | ${'/_api/v3/test?other=value&another=test'}
+    ${'single string parameter'}      | ${'/_api/v3/search?q=sensitive-query'}                                 | ${['q']}           | ${'/_api/v3/search?q=%5BANONYMIZED%5D'}
+    ${'array-style parameters'}       | ${'/_api/v3/page/test?paths[]=/user/john&paths[]=/user/jane'}          | ${['paths']}       | ${'/_api/v3/page/test?paths%5B%5D=%5BANONYMIZED%5D'}
+    ${'JSON array format'}            | ${'/_api/v3/test?paths=["/user/john","/user/jane"]'}                   | ${['paths']}       | ${'/_api/v3/test?paths=%5B%22%5BANONYMIZED%5D%22%5D'}
+    ${'multiple parameters'}          | ${'/_api/v3/test?q=secret&path=/user/john&other=keep'}                 | ${['q', 'path']}   | ${'/_api/v3/test?q=%5BANONYMIZED%5D&path=%5BANONYMIZED%5D&other=keep'}
+    ${'empty parameter value'}        | ${'/_api/v3/test?q=&other=value'}                                      | ${['q']}           | ${'/_api/v3/test?q=%5BANONYMIZED%5D&other=value'}
+    ${'parameter without value'}      | ${'/_api/v3/test?q&other=value'}                                       | ${['q']}           | ${'/_api/v3/test?q=%5BANONYMIZED%5D&other=value'}
+    ${'mixed array and single'}       | ${'/_api/v3/test?q=search&paths[]=/user/john&paths[]=/user/jane'}      | ${['q', 'paths']}  | ${'/_api/v3/test?q=%5BANONYMIZED%5D&paths%5B%5D=%5BANONYMIZED%5D'}
+    ${'with section'}                 | ${'/_api/v3/test?q=search#section'}                                    | ${['q']}           | ${'/_api/v3/test?q=%5BANONYMIZED%5D#section'}
+    ${'malformed JSON array'}         | ${'/_api/v3/test?paths=["/user/john"'}                                 | ${['paths']}       | ${'/_api/v3/test?paths=%5BANONYMIZED%5D'}
+    ${'empty JSON array'}             | ${'/_api/v3/test?paths=[]'}                                            | ${['paths']}       | ${'/_api/v3/test?paths=%5BANONYMIZED%5D'}
+    ${'single item JSON array'}       | ${'/_api/v3/test?paths=["/user/john"]'}                                | ${['paths']}       | ${'/_api/v3/test?paths=%5B%22%5BANONYMIZED%5D%22%5D'}
+    ${'URL with no query params'}     | ${'/_api/v3/test'}                                                     | ${['q']}           | ${'/_api/v3/test'}
+    ${'complex path with encoding'}   | ${'/_api/v3/test?path=%2Fuser%2Fjohn%20doe'}                           | ${['path']}        | ${'/_api/v3/test?path=%5BANONYMIZED%5D'}
+  `('should handle $description', ({ target, paramNames, expected }) => {
+  /* eslint-enable max-len */
+    const result = anonymizeQueryParams(target, paramNames);
+    expect(result).toBe(expected);
+  });
+
+  it.each`
+    description                    | target                         | paramNames    | expected
+    ${'invalid URL format'}       | ${'not-a-valid-url'}           | ${['q']}      | ${'not-a-valid-url'}
+    ${'empty string target'}      | ${''}                          | ${['q']}      | ${''}
+    ${'empty paramNames array'}   | ${'/_api/v3/test?q=secret'}    | ${[]}         | ${'/_api/v3/test?q=secret'}
+  `('should handle edge cases: $description', ({ target, paramNames, expected }) => {
+    const result = anonymizeQueryParams(target, paramNames);
+    expect(result).toBe(expected);
+  });
+});

+ 63 - 0
apps/app/src/features/opentelemetry/server/anonymization/utils/anonymize-query-params.ts

@@ -0,0 +1,63 @@
+import { diag } from '@opentelemetry/api';
+
+const logger = diag.createComponentLogger({ namespace: 'growi:anonymization:anonymize-query-params' });
+
+/**
+ * Try to parse JSON array, return null if invalid
+ */
+function tryParseJsonArray(value: string): unknown[] | null {
+  try {
+    const parsed = JSON.parse(value);
+    return Array.isArray(parsed) ? parsed : null;
+  }
+  catch {
+    return null;
+  }
+}
+
+/**
+ * Anonymize specific query parameters in HTTP target URL
+ * @param target - The HTTP target URL with query parameters
+ * @param paramNames - Array of parameter names to anonymize
+ * @returns Anonymized HTTP target URL
+ */
+export function anonymizeQueryParams(target: string, paramNames: string[]): string {
+  try {
+    const url = new URL(target, 'http://localhost');
+    const searchParams = new URLSearchParams(url.search);
+    let hasChange = false;
+
+    for (const paramName of paramNames) {
+      // Handle regular parameter (including JSON arrays)
+      if (searchParams.has(paramName)) {
+        const value = searchParams.get(paramName);
+        // Anonymize parameter even if it's empty (null check only)
+        if (value !== null) {
+          let replacement = '[ANONYMIZED]';
+          if (value.startsWith('[') && value.endsWith(']')) {
+            const jsonArray = tryParseJsonArray(value);
+            if (jsonArray && jsonArray.length > 0) {
+              replacement = '["[ANONYMIZED]"]';
+            }
+          }
+          searchParams.set(paramName, replacement);
+          hasChange = true;
+        }
+      }
+
+      // Handle array-style parameters (paramName[])
+      const arrayParam = `${paramName}[]`;
+      if (searchParams.has(arrayParam)) {
+        searchParams.delete(arrayParam);
+        searchParams.set(arrayParam, '[ANONYMIZED]');
+        hasChange = true;
+      }
+    }
+
+    return hasChange ? `${url.pathname}?${searchParams.toString()}${url.hash}` : target;
+  }
+  catch (error) {
+    logger.warn(`Failed to anonymize query parameters [${paramNames.join(', ')}]: ${error}`);
+    return target;
+  }
+}

+ 203 - 0
apps/app/src/features/opentelemetry/server/custom-metrics/application-metrics.spec.ts

@@ -0,0 +1,203 @@
+import crypto from 'crypto';
+
+import { metrics, type Meter, type ObservableGauge } from '@opentelemetry/api';
+import { mock } from 'vitest-mock-extended';
+
+import { configManager } from '~/server/service/config-manager';
+
+import { addApplicationMetrics } from './application-metrics';
+
+// Mock external dependencies
+const mockConfigManager = vi.mocked(configManager);
+vi.mock('~/server/service/config-manager');
+vi.mock('~/utils/logger', () => ({
+  default: () => ({
+    info: vi.fn(),
+  }),
+}));
+vi.mock('@opentelemetry/api', () => ({
+  diag: {
+    createComponentLogger: () => ({
+      error: vi.fn(),
+    }),
+  },
+  metrics: {
+    getMeter: vi.fn(),
+  },
+}));
+
+// Mock growi-info service
+const mockGrowiInfoService = {
+  getGrowiInfo: vi.fn(),
+};
+vi.mock('~/server/service/growi-info', () => ({
+  growiInfoService: mockGrowiInfoService,
+}));
+
+describe('addApplicationMetrics', () => {
+  const mockMeter = mock<Meter>();
+  const mockGauge = mock<ObservableGauge>();
+
+  beforeEach(() => {
+    vi.clearAllMocks();
+    vi.mocked(metrics.getMeter).mockReturnValue(mockMeter);
+    mockMeter.createObservableGauge.mockReturnValue(mockGauge);
+  });
+
+  afterEach(() => {
+    vi.restoreAllMocks();
+  });
+
+  it('should create observable gauge and set up metrics collection', () => {
+    addApplicationMetrics();
+
+    expect(metrics.getMeter).toHaveBeenCalledWith('growi-application-metrics', '1.0.0');
+    expect(mockMeter.createObservableGauge).toHaveBeenCalledWith('growi.configs', {
+      description: 'GROWI instance information (always 1)',
+      unit: '1',
+    });
+    expect(mockMeter.addBatchObservableCallback).toHaveBeenCalledWith(
+      expect.any(Function),
+      [mockGauge],
+    );
+  });
+
+  describe('metrics callback behavior', () => {
+    const testSiteUrl = 'https://example.com';
+    const mockGrowiInfo = {
+      appSiteUrl: testSiteUrl,
+      wikiType: 'open',
+      additionalInfo: {
+        activeExternalAccountTypes: ['google', 'github'],
+      },
+    };
+
+    beforeEach(() => {
+      mockGrowiInfoService.getGrowiInfo.mockResolvedValue(mockGrowiInfo);
+    });
+
+    it('should observe metrics with site_url when isAppSiteUrlHashed is false', async() => {
+      mockConfigManager.getConfig.mockImplementation((key) => {
+        if (key === 'otel:isAppSiteUrlHashed') return false;
+        return undefined;
+      });
+      const mockResult = { observe: vi.fn() };
+
+      addApplicationMetrics();
+
+      // Get the callback function that was passed to addBatchObservableCallback
+      const callback = mockMeter.addBatchObservableCallback.mock.calls[0][0];
+      await callback(mockResult);
+
+      expect(mockConfigManager.getConfig).toHaveBeenCalledWith('otel:isAppSiteUrlHashed');
+      expect(mockResult.observe).toHaveBeenCalledWith(mockGauge, 1, {
+        site_url: testSiteUrl,
+        site_url_hashed: undefined,
+        wiki_type: 'open',
+        external_auth_types: 'google,github',
+      });
+    });
+
+    it('should observe metrics with site_url_hashed when isAppSiteUrlHashed is true', async() => {
+      mockConfigManager.getConfig.mockImplementation((key) => {
+        if (key === 'otel:isAppSiteUrlHashed') return true;
+        return undefined;
+      });
+      const mockResult = { observe: vi.fn() };
+
+      // Calculate expected hash
+      const hasher = crypto.createHash('sha256');
+      hasher.update(testSiteUrl);
+      const expectedHash = hasher.digest('hex');
+
+      addApplicationMetrics();
+
+      // Get the callback function that was passed to addBatchObservableCallback
+      const callback = mockMeter.addBatchObservableCallback.mock.calls[0][0];
+      await callback(mockResult);
+
+      expect(mockConfigManager.getConfig).toHaveBeenCalledWith('otel:isAppSiteUrlHashed');
+      expect(mockResult.observe).toHaveBeenCalledWith(mockGauge, 1, {
+        site_url: '[hashed]',
+        site_url_hashed: expectedHash,
+        wiki_type: 'open',
+        external_auth_types: 'google,github',
+      });
+    });
+
+    it('should handle empty external auth types', async() => {
+      mockConfigManager.getConfig.mockImplementation((key) => {
+        if (key === 'otel:isAppSiteUrlHashed') return false;
+        return undefined;
+      });
+      const mockResult = { observe: vi.fn() };
+
+      const growiInfoWithoutAuth = {
+        ...mockGrowiInfo,
+        additionalInfo: {
+          activeExternalAccountTypes: [],
+        },
+      };
+      mockGrowiInfoService.getGrowiInfo.mockResolvedValue(growiInfoWithoutAuth);
+
+      addApplicationMetrics();
+
+      const callback = mockMeter.addBatchObservableCallback.mock.calls[0][0];
+      await callback(mockResult);
+
+      expect(mockResult.observe).toHaveBeenCalledWith(mockGauge, 1, {
+        site_url: testSiteUrl,
+        site_url_hashed: undefined,
+        wiki_type: 'open',
+        external_auth_types: '',
+      });
+    });
+
+    it('should handle errors in metrics collection gracefully', async() => {
+      mockConfigManager.getConfig.mockImplementation((key) => {
+        if (key === 'otel:isAppSiteUrlHashed') return false;
+        return undefined;
+      });
+      mockGrowiInfoService.getGrowiInfo.mockRejectedValue(new Error('Service unavailable'));
+      const mockResult = { observe: vi.fn() };
+
+      addApplicationMetrics();
+
+      const callback = mockMeter.addBatchObservableCallback.mock.calls[0][0];
+
+      // Should not throw error
+      await expect(callback(mockResult)).resolves.toBeUndefined();
+
+      // Should not call observe when error occurs
+      expect(mockResult.observe).not.toHaveBeenCalled();
+    });
+
+    it('should handle missing additionalInfo gracefully', async() => {
+      mockConfigManager.getConfig.mockImplementation((key) => {
+        if (key === 'otel:isAppSiteUrlHashed') return false;
+        return undefined;
+      });
+      const mockResult = { observe: vi.fn() };
+
+      const growiInfoWithoutAdditionalInfo = {
+        appSiteUrl: testSiteUrl,
+        wikiType: 'open',
+        additionalInfo: undefined,
+      };
+      mockGrowiInfoService.getGrowiInfo.mockResolvedValue(growiInfoWithoutAdditionalInfo);
+
+      addApplicationMetrics();
+
+      const callback = mockMeter.addBatchObservableCallback.mock.calls[0][0];
+      await callback(mockResult);
+
+      expect(mockConfigManager.getConfig).toHaveBeenCalledWith('otel:isAppSiteUrlHashed');
+      expect(mockResult.observe).toHaveBeenCalledWith(mockGauge, 1, {
+        site_url: testSiteUrl,
+        site_url_hashed: undefined,
+        wiki_type: 'open',
+        external_auth_types: '',
+      });
+    });
+  });
+});

+ 56 - 0
apps/app/src/features/opentelemetry/server/custom-metrics/application-metrics.ts

@@ -0,0 +1,56 @@
+import crypto from 'crypto';
+
+import { diag, metrics } from '@opentelemetry/api';
+
+import { configManager } from '~/server/service/config-manager';
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:opentelemetry:custom-metrics:application-metrics');
+const loggerDiag = diag.createComponentLogger({ namespace: 'growi:custom-metrics:application' });
+
+
+function getSiteUrlHashed(siteUrl: string): string {
+  const hasher = crypto.createHash('sha256');
+  hasher.update(siteUrl);
+  return hasher.digest('hex');
+}
+
+export function addApplicationMetrics(): void {
+  logger.info('Starting application config metrics collection');
+
+  const meter = metrics.getMeter('growi-application-metrics', '1.0.0');
+
+  // Config metrics: GROWI instance information (Prometheus info pattern)
+  const growiInfoGauge = meter.createObservableGauge('growi.configs', {
+    description: 'GROWI instance information (always 1)',
+    unit: '1',
+  });
+
+  // Config metrics collection callback
+  meter.addBatchObservableCallback(
+    async(result) => {
+      try {
+        // Dynamic import to avoid circular dependencies
+        const { growiInfoService } = await import('~/server/service/growi-info');
+        const growiInfo = await growiInfoService.getGrowiInfo(true);
+
+        const isAppSiteUrlHashed = configManager.getConfig('otel:isAppSiteUrlHashed');
+
+        // Config metrics always have value 1, with information stored in labels
+        result.observe(growiInfoGauge, 1, {
+          // Dynamic information that can change through configuration
+          site_url: isAppSiteUrlHashed ? '[hashed]' : growiInfo.appSiteUrl,
+          site_url_hashed: isAppSiteUrlHashed ? getSiteUrlHashed(growiInfo.appSiteUrl) : undefined,
+          wiki_type: growiInfo.wikiType,
+          external_auth_types: growiInfo.additionalInfo?.activeExternalAccountTypes?.join(',') || '',
+        });
+      }
+      catch (error) {
+        loggerDiag.error('Failed to collect application config metrics', { error });
+      }
+    },
+    [growiInfoGauge],
+  );
+
+  logger.info('Application config metrics collection started successfully');
+}

+ 11 - 0
apps/app/src/features/opentelemetry/server/custom-metrics/index.ts

@@ -0,0 +1,11 @@
+export { addApplicationMetrics } from './application-metrics';
+export { addUserCountsMetrics } from './user-counts-metrics';
+
+export const setupCustomMetrics = async(): Promise<void> => {
+  const { addApplicationMetrics } = await import('./application-metrics');
+  const { addUserCountsMetrics } = await import('./user-counts-metrics');
+
+  // Add custom metrics
+  addApplicationMetrics();
+  addUserCountsMetrics();
+};

+ 144 - 0
apps/app/src/features/opentelemetry/server/custom-metrics/user-counts-metrics.spec.ts

@@ -0,0 +1,144 @@
+import { metrics, type Meter, type ObservableGauge } from '@opentelemetry/api';
+import { mock } from 'vitest-mock-extended';
+
+import { addUserCountsMetrics } from './user-counts-metrics';
+
+// Mock external dependencies
+vi.mock('~/utils/logger', () => ({
+  default: () => ({
+    info: vi.fn(),
+  }),
+}));
+
+vi.mock('@opentelemetry/api', () => ({
+  diag: {
+    createComponentLogger: () => ({
+      error: vi.fn(),
+    }),
+  },
+  metrics: {
+    getMeter: vi.fn(),
+  },
+}));
+
+// Mock growi-info service
+const mockGrowiInfoService = {
+  getGrowiInfo: vi.fn(),
+};
+vi.mock('~/server/service/growi-info', () => ({
+  growiInfoService: mockGrowiInfoService,
+}));
+
+describe('addUserCountsMetrics', () => {
+  const mockMeter = mock<Meter>();
+  const mockUserCountGauge = mock<ObservableGauge>();
+  const mockActiveUserCountGauge = mock<ObservableGauge>();
+
+  beforeEach(() => {
+    vi.clearAllMocks();
+    vi.mocked(metrics.getMeter).mockReturnValue(mockMeter);
+    mockMeter.createObservableGauge
+      .mockReturnValueOnce(mockUserCountGauge)
+      .mockReturnValueOnce(mockActiveUserCountGauge);
+  });
+
+  afterEach(() => {
+    vi.restoreAllMocks();
+  });
+
+  it('should create observable gauges and set up metrics collection', () => {
+    addUserCountsMetrics();
+
+    expect(metrics.getMeter).toHaveBeenCalledWith('growi-user-counts-metrics', '1.0.0');
+    expect(mockMeter.createObservableGauge).toHaveBeenCalledWith('growi.users.total', {
+      description: 'Total number of users in GROWI',
+      unit: 'users',
+    });
+    expect(mockMeter.createObservableGauge).toHaveBeenCalledWith('growi.users.active', {
+      description: 'Number of active users in GROWI',
+      unit: 'users',
+    });
+    expect(mockMeter.addBatchObservableCallback).toHaveBeenCalledWith(
+      expect.any(Function),
+      [mockUserCountGauge, mockActiveUserCountGauge],
+    );
+  });
+
+  describe('metrics callback behavior', () => {
+    const mockGrowiInfo = {
+      additionalInfo: {
+        currentUsersCount: 150,
+        currentActiveUsersCount: 75,
+      },
+    };
+
+    beforeEach(() => {
+      mockGrowiInfoService.getGrowiInfo.mockResolvedValue(mockGrowiInfo);
+    });
+
+    it('should observe user count metrics when growi info is available', async() => {
+      const mockResult = { observe: vi.fn() };
+
+      addUserCountsMetrics();
+
+      // Get the callback function that was passed to addBatchObservableCallback
+      const callback = mockMeter.addBatchObservableCallback.mock.calls[0][0];
+      await callback(mockResult);
+
+      expect(mockGrowiInfoService.getGrowiInfo).toHaveBeenCalledWith(true);
+      expect(mockResult.observe).toHaveBeenCalledWith(mockUserCountGauge, 150);
+      expect(mockResult.observe).toHaveBeenCalledWith(mockActiveUserCountGauge, 75);
+    });
+
+    it('should use default values when user counts are missing', async() => {
+      const mockResult = { observe: vi.fn() };
+
+      const growiInfoWithoutCounts = {
+        additionalInfo: {
+          // Missing currentUsersCount and currentActiveUsersCount
+        },
+      };
+      mockGrowiInfoService.getGrowiInfo.mockResolvedValue(growiInfoWithoutCounts);
+
+      addUserCountsMetrics();
+
+      const callback = mockMeter.addBatchObservableCallback.mock.calls[0][0];
+      await callback(mockResult);
+
+      expect(mockResult.observe).toHaveBeenCalledWith(mockUserCountGauge, 0);
+      expect(mockResult.observe).toHaveBeenCalledWith(mockActiveUserCountGauge, 0);
+    });
+
+    it('should handle missing additionalInfo gracefully', async() => {
+      const mockResult = { observe: vi.fn() };
+
+      const growiInfoWithoutAdditionalInfo = {
+        // Missing additionalInfo entirely
+      };
+      mockGrowiInfoService.getGrowiInfo.mockResolvedValue(growiInfoWithoutAdditionalInfo);
+
+      addUserCountsMetrics();
+
+      const callback = mockMeter.addBatchObservableCallback.mock.calls[0][0];
+      await callback(mockResult);
+
+      expect(mockResult.observe).toHaveBeenCalledWith(mockUserCountGauge, 0);
+      expect(mockResult.observe).toHaveBeenCalledWith(mockActiveUserCountGauge, 0);
+    });
+
+    it('should handle errors in metrics collection gracefully', async() => {
+      mockGrowiInfoService.getGrowiInfo.mockRejectedValue(new Error('Service unavailable'));
+      const mockResult = { observe: vi.fn() };
+
+      addUserCountsMetrics();
+
+      const callback = mockMeter.addBatchObservableCallback.mock.calls[0][0];
+
+      // Should not throw error
+      await expect(callback(mockResult)).resolves.toBeUndefined();
+
+      // Should not call observe when error occurs
+      expect(mockResult.observe).not.toHaveBeenCalled();
+    });
+  });
+});

+ 46 - 0
apps/app/src/features/opentelemetry/server/custom-metrics/user-counts-metrics.ts

@@ -0,0 +1,46 @@
+import { diag, metrics } from '@opentelemetry/api';
+
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:opentelemetry:custom-metrics:user-counts');
+const loggerDiag = diag.createComponentLogger({ namespace: 'growi:custom-metrics:user-counts' });
+
+export function addUserCountsMetrics(): void {
+  logger.info('Starting user counts metrics collection');
+
+  const meter = metrics.getMeter('growi-user-counts-metrics', '1.0.0');
+
+  // Total user count gauge
+  const userCountGauge = meter.createObservableGauge('growi.users.total', {
+    description: 'Total number of users in GROWI',
+    unit: 'users',
+  });
+
+  // Active user count gauge
+  const activeUserCountGauge = meter.createObservableGauge('growi.users.active', {
+    description: 'Number of active users in GROWI',
+    unit: 'users',
+  });
+
+  // User metrics collection callback
+  meter.addBatchObservableCallback(
+    async(result) => {
+      try {
+        // Dynamic import to avoid circular dependencies
+        const { growiInfoService } = await import('~/server/service/growi-info');
+
+        const growiInfo = await growiInfoService.getGrowiInfo(true);
+
+        // Observe user count metrics
+        result.observe(userCountGauge, growiInfo.additionalInfo?.currentUsersCount || 0);
+        result.observe(activeUserCountGauge, growiInfo.additionalInfo?.currentActiveUsersCount || 0);
+      }
+      catch (error) {
+        loggerDiag.error('Failed to collect user counts metrics', { error });
+      }
+    },
+    [userCountGauge, activeUserCountGauge],
+  );
+
+  logger.info('User counts metrics collection started successfully');
+}

+ 99 - 0
apps/app/src/features/opentelemetry/server/custom-resource-attributes/application-resource-attributes.spec.ts

@@ -0,0 +1,99 @@
+import { getApplicationResourceAttributes } from './application-resource-attributes';
+
+// Mock external dependencies
+vi.mock('~/utils/logger', () => ({
+  default: () => ({
+    info: vi.fn(),
+    error: vi.fn(),
+  }),
+}));
+
+// Mock growi-info service
+const mockGrowiInfoService = {
+  getGrowiInfo: vi.fn(),
+};
+vi.mock('~/server/service/growi-info', () => ({
+  growiInfoService: mockGrowiInfoService,
+}));
+
+describe('getApplicationResourceAttributes', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+  });
+
+  it('should return complete application resource attributes when growi info is available', async() => {
+    const mockGrowiInfo = {
+      type: 'app',
+      deploymentType: 'standalone',
+      additionalInfo: {
+        attachmentType: 'local',
+        installedAt: new Date('2023-01-01T00:00:00.000Z'),
+        installedAtByOldestUser: new Date('2023-01-01T00:00:00.000Z'),
+      },
+    };
+
+    mockGrowiInfoService.getGrowiInfo.mockResolvedValue(mockGrowiInfo);
+
+    const result = await getApplicationResourceAttributes();
+
+    expect(result).toEqual({
+      'growi.service.type': 'app',
+      'growi.deployment.type': 'standalone',
+      'growi.attachment.type': 'local',
+      'growi.installedAt': '2023-01-01T00:00:00.000Z',
+      'growi.installedAt.by_oldest_user': '2023-01-01T00:00:00.000Z',
+    });
+    expect(mockGrowiInfoService.getGrowiInfo).toHaveBeenCalledWith(true);
+  });
+
+  it('should handle missing additionalInfo gracefully', async() => {
+    const mockGrowiInfo = {
+      type: 'app',
+      deploymentType: 'standalone',
+      additionalInfo: undefined,
+    };
+
+    mockGrowiInfoService.getGrowiInfo.mockResolvedValue(mockGrowiInfo);
+
+    const result = await getApplicationResourceAttributes();
+
+    expect(result).toEqual({
+      'growi.service.type': 'app',
+      'growi.deployment.type': 'standalone',
+      'growi.attachment.type': undefined,
+      'growi.installedAt': undefined,
+      'growi.installedAt.by_oldest_user': undefined,
+    });
+  });
+
+  it('should return empty object when growiInfoService throws error', async() => {
+    mockGrowiInfoService.getGrowiInfo.mockRejectedValue(new Error('Service unavailable'));
+
+    const result = await getApplicationResourceAttributes();
+
+    expect(result).toEqual({});
+  });
+
+  it('should handle partial additionalInfo data', async() => {
+    const mockGrowiInfo = {
+      type: 'app',
+      deploymentType: 'docker',
+      additionalInfo: {
+        attachmentType: 'gridfs',
+        // Missing installedAt and installedAtByOldestUser
+      },
+    };
+
+    mockGrowiInfoService.getGrowiInfo.mockResolvedValue(mockGrowiInfo);
+
+    const result = await getApplicationResourceAttributes();
+
+    expect(result).toEqual({
+      'growi.service.type': 'app',
+      'growi.deployment.type': 'docker',
+      'growi.attachment.type': 'gridfs',
+      'growi.installedAt': undefined,
+      'growi.installedAt.by_oldest_user': undefined,
+    });
+  });
+});

+ 39 - 0
apps/app/src/features/opentelemetry/server/custom-resource-attributes/application-resource-attributes.ts

@@ -0,0 +1,39 @@
+import type { Attributes } from '@opentelemetry/api';
+
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:opentelemetry:custom-resource-attributes:application');
+
+/**
+ * Get application fixed information as OpenTelemetry Resource Attributes
+ * These attributes are static and set once during application startup
+ */
+export async function getApplicationResourceAttributes(): Promise<Attributes> {
+  logger.info('Collecting application resource attributes');
+
+  try {
+    // Dynamic import to avoid circular dependencies
+    const { growiInfoService } = await import('~/server/service/growi-info');
+
+    const growiInfo = await growiInfoService.getGrowiInfo(true);
+
+    const attributes: Attributes = {
+      // Service configuration (rarely changes after system setup)
+      'growi.service.type': growiInfo.type,
+      'growi.deployment.type': growiInfo.deploymentType,
+      'growi.attachment.type': growiInfo.additionalInfo?.attachmentType,
+
+      // Installation information (fixed values)
+      'growi.installedAt': growiInfo.additionalInfo?.installedAt?.toISOString(),
+      'growi.installedAt.by_oldest_user': growiInfo.additionalInfo?.installedAtByOldestUser?.toISOString(),
+    };
+
+    logger.info('Application resource attributes collected', { attributes });
+
+    return attributes;
+  }
+  catch (error) {
+    logger.error('Failed to collect application resource attributes', { error });
+    return {};
+  }
+}

+ 2 - 0
apps/app/src/features/opentelemetry/server/custom-resource-attributes/index.ts

@@ -0,0 +1,2 @@
+export { getOsResourceAttributes } from './os-resource-attributes';
+export { getApplicationResourceAttributes } from './application-resource-attributes';

+ 106 - 0
apps/app/src/features/opentelemetry/server/custom-resource-attributes/os-resource-attributes.spec.ts

@@ -0,0 +1,106 @@
+import { getOsResourceAttributes } from './os-resource-attributes';
+
+// Mock Node.js os module with proper Vitest mock functions
+vi.mock('node:os', () => ({
+  type: vi.fn(),
+  platform: vi.fn(),
+  arch: vi.fn(),
+  totalmem: vi.fn(),
+}));
+
+describe('getOsResourceAttributes', () => {
+  let mockOs: {
+    type: ReturnType<typeof vi.fn>;
+    platform: ReturnType<typeof vi.fn>;
+    arch: ReturnType<typeof vi.fn>;
+    totalmem: ReturnType<typeof vi.fn>;
+  };
+
+  beforeEach(async() => {
+    vi.clearAllMocks();
+    // Get the mocked os module
+    mockOs = await vi.importMock('node:os');
+  });
+
+  it('should return OS resource attributes with correct structure', () => {
+    // Setup mock values
+    const mockOsData = {
+      type: 'Linux',
+      platform: 'linux' as const,
+      arch: 'x64',
+      totalmem: 16777216000,
+    };
+
+    mockOs.type.mockReturnValue(mockOsData.type);
+    mockOs.platform.mockReturnValue(mockOsData.platform);
+    mockOs.arch.mockReturnValue(mockOsData.arch);
+    mockOs.totalmem.mockReturnValue(mockOsData.totalmem);
+
+    const result = getOsResourceAttributes();
+
+    expect(result).toEqual({
+      'os.type': 'Linux',
+      'os.platform': 'linux',
+      'os.arch': 'x64',
+      'os.totalmem': 16777216000,
+    });
+  });
+
+  it('should call all required os module functions', () => {
+    // Set up mock returns to avoid undefined values
+    mockOs.type.mockReturnValue('Linux');
+    mockOs.platform.mockReturnValue('linux');
+    mockOs.arch.mockReturnValue('x64');
+    mockOs.totalmem.mockReturnValue(16777216000);
+
+    getOsResourceAttributes();
+
+    expect(mockOs.type).toHaveBeenCalledOnce();
+    expect(mockOs.platform).toHaveBeenCalledOnce();
+    expect(mockOs.arch).toHaveBeenCalledOnce();
+    expect(mockOs.totalmem).toHaveBeenCalledOnce();
+  });
+
+  it('should handle different OS types correctly', () => {
+    const testCases = [
+      {
+        input: {
+          type: 'Windows_NT',
+          platform: 'win32',
+          arch: 'x64',
+          totalmem: 8589934592,
+        },
+        expected: {
+          'os.type': 'Windows_NT',
+          'os.platform': 'win32',
+          'os.arch': 'x64',
+          'os.totalmem': 8589934592,
+        },
+      },
+      {
+        input: {
+          type: 'Darwin',
+          platform: 'darwin',
+          arch: 'arm64',
+          totalmem: 17179869184,
+        },
+        expected: {
+          'os.type': 'Darwin',
+          'os.platform': 'darwin',
+          'os.arch': 'arm64',
+          'os.totalmem': 17179869184,
+        },
+      },
+    ];
+
+    testCases.forEach(({ input, expected }) => {
+      mockOs.type.mockReturnValue(input.type);
+      mockOs.platform.mockReturnValue(input.platform as NodeJS.Platform);
+      mockOs.arch.mockReturnValue(input.arch);
+      mockOs.totalmem.mockReturnValue(input.totalmem);
+
+      const result = getOsResourceAttributes();
+      expect(result).toEqual(expected);
+    });
+  });
+});

+ 33 - 0
apps/app/src/features/opentelemetry/server/custom-resource-attributes/os-resource-attributes.ts

@@ -0,0 +1,33 @@
+import * as os from 'node:os';
+
+import type { Attributes } from '@opentelemetry/api';
+
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:opentelemetry:custom-resource-attributes:os');
+
+/**
+ * Get OS information as OpenTelemetry Resource Attributes
+ * These attributes are static and set once during application startup
+ */
+export function getOsResourceAttributes(): Attributes {
+  logger.info('Collecting OS resource attributes');
+
+  const osInfo = {
+    type: os.type(),
+    platform: os.platform(),
+    arch: os.arch(),
+    totalmem: os.totalmem(),
+  };
+
+  const attributes: Attributes = {
+    'os.type': osInfo.type,
+    'os.platform': osInfo.platform,
+    'os.arch': osInfo.arch,
+    'os.totalmem': osInfo.totalmem,
+  };
+
+  logger.info('OS resource attributes collected', { attributes });
+
+  return attributes;
+}

+ 43 - 10
apps/app/src/features/opentelemetry/server/node-sdk-configuration.ts

@@ -1,29 +1,41 @@
 import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
 import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-grpc';
 import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-grpc';
-import { Resource, type IResource } from '@opentelemetry/resources';
+import type { Resource } from '@opentelemetry/resources';
+import { resourceFromAttributes } from '@opentelemetry/resources';
 import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics';
 import type { NodeSDKConfiguration } from '@opentelemetry/sdk-node';
-import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION, SEMRESATTRS_SERVICE_INSTANCE_ID } from '@opentelemetry/semantic-conventions';
+import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions';
 
+import { configManager } from '~/server/service/config-manager';
 import { getGrowiVersion } from '~/utils/growi-version';
 
+import { httpInstrumentationConfig as httpInstrumentationConfigForAnonymize } from './anonymization';
+import { ATTR_SERVICE_INSTANCE_ID } from './semconv';
+
+type Option = {
+  enableAnonymization?: boolean,
+}
+
 type Configuration = Partial<NodeSDKConfiguration> & {
-  resource: IResource;
+  resource: Resource;
 };
 
 let resource: Resource;
 let configuration: Configuration;
 
-export const generateNodeSDKConfiguration = (serviceInstanceId?: string): Configuration => {
+export const generateNodeSDKConfiguration = (opts?: Option): Configuration => {
   if (configuration == null) {
     const version = getGrowiVersion();
 
-    resource = new Resource({
+    resource = resourceFromAttributes({
       [ATTR_SERVICE_NAME]: 'growi',
       [ATTR_SERVICE_VERSION]: version,
     });
 
+    // Data anonymization configuration
+    const httpInstrumentationConfig = opts?.enableAnonymization ? httpInstrumentationConfigForAnonymize : {};
+
     configuration = {
       resource,
       traceExporter: new OTLPTraceExporter(),
@@ -39,15 +51,36 @@ export const generateNodeSDKConfiguration = (serviceInstanceId?: string): Config
         '@opentelemetry/instrumentation-fs': {
           enabled: false,
         },
+        // HTTP instrumentation with anonymization
+        '@opentelemetry/instrumentation-http': {
+          enabled: true,
+          ...httpInstrumentationConfig,
+        },
       })],
     };
-  }
 
-  if (serviceInstanceId != null) {
-    configuration.resource = resource.merge(new Resource({
-      [SEMRESATTRS_SERVICE_INSTANCE_ID]: serviceInstanceId,
-    }));
   }
 
   return configuration;
 };
+
+/**
+ * Generate additional attributes after database initialization
+ * This function should be called after database is available
+ */
+export const generateAdditionalResourceAttributes = async(opts?: Option): Promise<Resource> => {
+  if (resource == null) {
+    throw new Error('Resource is not initialized. Call generateNodeSDKConfiguration first.');
+  }
+
+  const serviceInstanceId = configManager.getConfig('otel:serviceInstanceId')
+    ?? configManager.getConfig('app:serviceInstanceId');
+
+  const { getApplicationResourceAttributes, getOsResourceAttributes } = await import('./custom-resource-attributes');
+
+  return resource.merge(resourceFromAttributes({
+    [ATTR_SERVICE_INSTANCE_ID]: serviceInstanceId,
+    ...await getApplicationResourceAttributes(),
+    ...await getOsResourceAttributes(),
+  }));
+};

+ 2 - 2
apps/app/src/features/opentelemetry/server/node-sdk-resource.ts

@@ -1,4 +1,4 @@
-import { Resource } from '@opentelemetry/resources';
+import type { Resource } from '@opentelemetry/resources';
 import type { NodeSDK } from '@opentelemetry/sdk-node';
 
 /**
@@ -8,7 +8,7 @@ import type { NodeSDK } from '@opentelemetry/sdk-node';
 export const getResource = (sdk: NodeSDK): Resource => {
   // This cast is necessary as _resource is a private property
   const resource = (sdk as any)._resource;
-  if (!(resource instanceof Resource)) {
+  if (!resource || typeof resource !== 'object' || !resource.attributes) {
     throw new Error('Failed to access SDK resource');
   }
   return resource;

+ 127 - 56
apps/app/src/features/opentelemetry/server/node-sdk.spec.ts

@@ -1,12 +1,10 @@
 import { ConfigSource } from '@growi/core/dist/interfaces';
-import { Resource } from '@opentelemetry/resources';
 import { NodeSDK } from '@opentelemetry/sdk-node';
 
 import { configManager } from '~/server/service/config-manager';
 
-import { detectServiceInstanceId, initInstrumentation } from './node-sdk';
+import { setupAdditionalResourceAttributes, initInstrumentation } from './node-sdk';
 import { getResource } from './node-sdk-resource';
-import { getSdkInstance, resetSdkInstance } from './node-sdk.testing';
 
 // Only mock configManager as it's external to what we're testing
 vi.mock('~/server/service/config-manager', () => ({
@@ -16,55 +14,141 @@ vi.mock('~/server/service/config-manager', () => ({
   },
 }));
 
-describe('node-sdk', () => {
-  beforeEach(() => {
-    vi.clearAllMocks();
-    vi.resetModules();
-    resetSdkInstance();
+// Mock custom metrics setup
+vi.mock('./custom-metrics', () => ({
+  setupCustomMetrics: vi.fn(),
+}));
+
+// Mock growi-info service to avoid database dependencies
+vi.mock('~/server/service/growi-info', () => ({
+  growiInfoService: {
+    getGrowiInfo: vi.fn().mockResolvedValue({
+      type: 'app',
+      deploymentType: 'standalone',
+      additionalInfo: {
+        attachmentType: 'local',
+        installedAt: new Date('2023-01-01T00:00:00.000Z'),
+        installedAtByOldestUser: new Date('2023-01-01T00:00:00.000Z'),
+      },
+    }),
+  },
+}));
 
-    // Reset configManager mock implementation
+describe('node-sdk', () => {
+  // Helper functions to reduce duplication
+  const mockInstrumentationEnabled = () => {
     vi.mocked(configManager.getConfig).mockImplementation((key: string, source?: ConfigSource) => {
-      // For otel:enabled, always expect ConfigSource.env
       if (key === 'otel:enabled') {
         return source === ConfigSource.env ? true : undefined;
       }
       return undefined;
     });
+  };
+
+  const mockInstrumentationDisabled = () => {
+    vi.mocked(configManager.getConfig).mockImplementation((key: string, source?: ConfigSource) => {
+      if (key === 'otel:enabled') {
+        return source === ConfigSource.env ? false : undefined;
+      }
+      return undefined;
+    });
+  };
+
+  beforeEach(async() => {
+    vi.clearAllMocks();
+
+    // Reset SDK instance using __testing__ export
+    const { __testing__ } = await import('./node-sdk');
+    __testing__.reset();
+
+    // Mock loadConfigs to resolve immediately
+    vi.mocked(configManager.loadConfigs).mockResolvedValue(undefined);
   });
 
-  describe('detectServiceInstanceId', () => {
-    it('should update service.instance.id when app:serviceInstanceId is available', async() => {
-      // Initialize SDK first
+  describe('initInstrumentation', () => {
+    it('should call setupCustomMetrics when instrumentation is enabled', async() => {
+      const { setupCustomMetrics } = await import('./custom-metrics');
+
+      // Mock instrumentation as enabled
+      mockInstrumentationEnabled();
+
+      await initInstrumentation();
+
+      // Verify setupCustomMetrics was called
+      expect(setupCustomMetrics).toHaveBeenCalledOnce();
+    });
+
+    it('should not call setupCustomMetrics when instrumentation is disabled', async() => {
+      const { setupCustomMetrics } = await import('./custom-metrics');
+
+      // Mock instrumentation as disabled
+      mockInstrumentationDisabled();
+
+      await initInstrumentation();
+
+      // Verify setupCustomMetrics was not called
+      expect(setupCustomMetrics).not.toHaveBeenCalled();
+    });
+
+    it('should create SDK instance when instrumentation is enabled', async() => {
+      // Mock instrumentation as enabled
+      mockInstrumentationEnabled();
+
       await initInstrumentation();
 
       // Get instance for testing
-      const sdkInstance = getSdkInstance();
+      const { __testing__ } = await import('./node-sdk');
+      const sdkInstance = __testing__.getSdkInstance();
       expect(sdkInstance).toBeDefined();
       expect(sdkInstance).toBeInstanceOf(NodeSDK);
+    });
 
-      // Verify initial state (service.instance.id should not be set)
-      if (sdkInstance == null) {
-        throw new Error('SDK instance should be defined');
-      }
+    it('should not create SDK instance when instrumentation is disabled', async() => {
+      // Mock instrumentation as disabled
+      mockInstrumentationDisabled();
+
+      await initInstrumentation();
+
+      // Verify that no SDK instance was created
+      const { __testing__ } = await import('./node-sdk');
+      const sdkInstance = __testing__.getSdkInstance();
+      expect(sdkInstance).toBeUndefined();
+    });
+  });
 
-      // Mock app:serviceInstanceId is available
+  describe('setupAdditionalResourceAttributes', () => {
+    it('should update service.instance.id when app:serviceInstanceId is available', async() => {
+      // Set up mocks for this specific test
       vi.mocked(configManager.getConfig).mockImplementation((key: string, source?: ConfigSource) => {
         // For otel:enabled, always expect ConfigSource.env
         if (key === 'otel:enabled') {
           return source === ConfigSource.env ? true : undefined;
         }
-
         // For service instance IDs, only respond when no source is specified
         if (key === 'app:serviceInstanceId') return 'test-instance-id';
         return undefined;
       });
 
+      // Initialize SDK first
+      await initInstrumentation();
+
+      // Get instance for testing
+      const { __testing__ } = await import('./node-sdk');
+      const sdkInstance = __testing__.getSdkInstance();
+      expect(sdkInstance).toBeDefined();
+      expect(sdkInstance).toBeInstanceOf(NodeSDK);
+
+      // Verify initial state (service.instance.id should not be set)
+      if (sdkInstance == null) {
+        throw new Error('SDK instance should be defined');
+      }
+
       const resource = getResource(sdkInstance);
-      expect(resource).toBeInstanceOf(Resource);
+      expect(resource).toBeDefined();
       expect(resource.attributes['service.instance.id']).toBeUndefined();
 
-      // Call detectServiceInstanceId
-      await detectServiceInstanceId();
+      // Call setupAdditionalResourceAttributes
+      await setupAdditionalResourceAttributes();
 
       // Verify that resource was updated with app:serviceInstanceId
       const updatedResource = getResource(sdkInstance);
@@ -72,18 +156,7 @@ describe('node-sdk', () => {
     });
 
     it('should update service.instance.id with otel:serviceInstanceId if available', async() => {
-      // Initialize SDK
-      await initInstrumentation();
-
-      // Get instance and verify initial state
-      const sdkInstance = getSdkInstance();
-      if (sdkInstance == null) {
-        throw new Error('SDK instance should be defined');
-      }
-      const resource = getResource(sdkInstance);
-      expect(resource.attributes['service.instance.id']).toBeUndefined();
-
-      // Mock otel:serviceInstanceId is available
+      // Set up mocks for this specific test
       vi.mocked(configManager.getConfig).mockImplementation((key: string, source?: ConfigSource) => {
         // For otel:enabled, always expect ConfigSource.env
         if (key === 'otel:enabled') {
@@ -99,37 +172,35 @@ describe('node-sdk', () => {
         return undefined;
       });
 
-      // Call detectServiceInstanceId
-      await detectServiceInstanceId();
+      // Initialize SDK
+      await initInstrumentation();
+
+      // Get instance and verify initial state
+      const { __testing__ } = await import('./node-sdk');
+      const sdkInstance = __testing__.getSdkInstance();
+      if (sdkInstance == null) {
+        throw new Error('SDK instance should be defined');
+      }
+      const resource = getResource(sdkInstance);
+      expect(resource.attributes['service.instance.id']).toBeUndefined();
+
+      // Call setupAdditionalResourceAttributes
+      await setupAdditionalResourceAttributes();
 
       // Verify that otel:serviceInstanceId was used
       const updatedResource = getResource(sdkInstance);
       expect(updatedResource.attributes['service.instance.id']).toBe('otel-instance-id');
     });
 
-    it('should not create SDK instance if instrumentation is disabled', async() => {
+    it('should handle gracefully when instrumentation is disabled', async() => {
       // Mock instrumentation as disabled
-      vi.mocked(configManager.getConfig).mockImplementation((key: string, source?: ConfigSource) => {
-        // For otel:enabled, always expect ConfigSource.env and return false
-        if (key === 'otel:enabled') {
-          return source === ConfigSource.env ? false : undefined;
-        }
-        return undefined;
-      });
+      mockInstrumentationDisabled();
 
-      // Initialize SDK
+      // Initialize SDK (should not create instance)
       await initInstrumentation();
 
-      // Verify that no SDK instance was created
-      const sdkInstance = getSdkInstance();
-      expect(sdkInstance).toBeUndefined();
-
-      // Call detectServiceInstanceId
-      await detectServiceInstanceId();
-
-      // Verify that still no SDK instance exists
-      const updatedSdkInstance = getSdkInstance();
-      expect(updatedSdkInstance).toBeUndefined();
+      // Call setupAdditionalResourceAttributes should not throw error
+      await expect(setupAdditionalResourceAttributes()).resolves.toBeUndefined();
     });
   });
 });

+ 16 - 8
apps/app/src/features/opentelemetry/server/node-sdk.ts

@@ -4,6 +4,7 @@ import type { NodeSDK } from '@opentelemetry/sdk-node';
 import { configManager } from '~/server/service/config-manager';
 import loggerFactory from '~/utils/logger';
 
+import { setupCustomMetrics } from './custom-metrics';
 import { setResource } from './node-sdk-resource';
 
 const logger = loggerFactory('growi:opentelemetry:server');
@@ -66,12 +67,18 @@ For more information, see https://docs.growi.org/en/admin-guide/admin-cookbook/t
     // instanciate NodeSDK
     const { NodeSDK } = await import('@opentelemetry/sdk-node');
     const { generateNodeSDKConfiguration } = await import('./node-sdk-configuration');
+    // get resource from configuration
+    const enableAnonymization = configManager.getConfig('otel:anonymizeInBestEffort', ConfigSource.env);
 
-    sdkInstance = new NodeSDK(generateNodeSDKConfiguration());
+    const sdkConfig = generateNodeSDKConfiguration({ enableAnonymization });
+
+    setupCustomMetrics();
+
+    sdkInstance = new NodeSDK(sdkConfig);
   }
 };
 
-export const detectServiceInstanceId = async(): Promise<void> => {
+export const setupAdditionalResourceAttributes = async(): Promise<void> => {
   const instrumentationEnabled = configManager.getConfig('otel:enabled', ConfigSource.env);
 
   if (instrumentationEnabled) {
@@ -79,14 +86,15 @@ export const detectServiceInstanceId = async(): Promise<void> => {
       throw new Error('OpenTelemetry instrumentation is not initialized');
     }
 
-    const { generateNodeSDKConfiguration } = await import('./node-sdk-configuration');
+    const { generateAdditionalResourceAttributes } = await import('./node-sdk-configuration');
+    // get resource from configuration
+    const enableAnonymization = configManager.getConfig('otel:anonymizeInBestEffort', ConfigSource.env);
 
-    const serviceInstanceId = configManager.getConfig('otel:serviceInstanceId')
-      ?? configManager.getConfig('app:serviceInstanceId');
+    // generate additional resource attributes
+    const updatedResource = await generateAdditionalResourceAttributes({ enableAnonymization });
 
-    // Update resource with new service instance id
-    const newConfig = generateNodeSDKConfiguration(serviceInstanceId);
-    setResource(sdkInstance, newConfig.resource);
+    // set resource to sdk instance
+    setResource(sdkInstance, updatedResource);
   }
 };
 

+ 40 - 0
apps/app/src/features/opentelemetry/server/semconv.ts

@@ -0,0 +1,40 @@
+/* eslint-disable max-len */
+/*
+### Unstable SemConv
+
+<!-- Dev Note: ^^ This '#unstable-semconv' anchor is being used in jsdoc links in the code. -->
+
+Because the "incubating" entry-point may include breaking changes in minor versions, it is recommended that instrumentation libraries **not** import `@opentelemetry/semantic-conventions/incubating` in runtime code, but instead **copy relevant definitions into their own code base**. (This is the same [recommendation](https://opentelemetry.io/docs/specs/semconv/non-normative/code-generation/#stability-and-versioning) as for other languages.)
+
+For example, create a "src/semconv.ts" (or "lib/semconv.js" if implementing in JavaScript) file that copies from [experimental_attributes.ts](./src/experimental_attributes.ts) or [experimental_metrics.ts](./src/experimental_metrics.ts):
+
+```ts
+// src/semconv.ts
+export const ATTR_DB_NAMESPACE = 'db.namespace';
+export const ATTR_DB_OPERATION_NAME = 'db.operation.name';
+```
+
+```ts
+// src/instrumentation.ts
+import {
+  ATTR_SERVER_PORT,
+  ATTR_SERVER_ADDRESS,
+} from '@opentelemetry/semantic-conventions';
+import {
+  ATTR_DB_NAMESPACE,
+  ATTR_DB_OPERATION_NAME,
+} from './semconv';
+
+span.setAttributes({
+  [ATTR_DB_NAMESPACE]: ...,
+  [ATTR_DB_OPERATION_NAME]: ...,
+  [ATTR_SERVER_PORT]: ...,
+  [ATTR_SERVER_ADDRESS]: ...,
+})
+```
+
+Occasionally, one should review changes to `@opentelemetry/semantic-conventions` to see if any used unstable conventions have changed or been stabilized. However, an update to a newer minor version of the package will never be breaking.
+*/
+
+export const ATTR_SERVICE_INSTANCE_ID = 'service.instance.id';
+export const ATTR_HTTP_TARGET = 'http.target';

+ 2 - 2
apps/app/src/server/app.ts

@@ -1,6 +1,6 @@
 import type Logger from 'bunyan';
 
-import { initInstrumentation, detectServiceInstanceId, startOpenTelemetry } from '~/features/opentelemetry/server';
+import { initInstrumentation, setupAdditionalResourceAttributes, startOpenTelemetry } from '~/features/opentelemetry/server';
 import loggerFactory from '~/utils/logger';
 import { hasProcessFlag } from '~/utils/process-utils';
 
@@ -28,7 +28,7 @@ async function main() {
     const server = await growi.start();
 
     // Start OpenTelemetry
-    await detectServiceInstanceId();
+    await setupAdditionalResourceAttributes();
     startOpenTelemetry();
 
     if (hasProcessFlag('ci')) {

+ 13 - 0
apps/app/src/server/routes/apiv3/app-settings.js

@@ -890,6 +890,19 @@ module.exports = (crowi) => {
   router.put('/file-upload-setting', loginRequiredStrictly, adminRequired, addActivity, validator.fileUploadSetting, apiV3FormValidator, async(req, res) => {
     const { fileUploadType } = req.body;
 
+    if (fileUploadType === 'local' || fileUploadType === 'gridfs') {
+      try {
+        await configManager.updateConfigs({
+          'app:fileUploadType': fileUploadType,
+        }, { skipPubsub: true });
+      }
+      catch (err) {
+        const msg = `Error occurred in updating ${fileUploadType} settings: ${err.message}`;
+        logger.error('Error', err);
+        return res.apiv3Err(new ErrorV3(msg, 'update-fileUploadType-failed'));
+      }
+    }
+
     if (fileUploadType === 'aws') {
       try {
         try {

+ 6 - 1
apps/app/src/server/service/config-manager/config-definition.ts

@@ -268,6 +268,7 @@ export const CONFIG_KEYS = [
   // OpenTelemetry Settings
   'otel:enabled',
   'otel:isAppSiteUrlHashed',
+  'otel:anonymizeInBestEffort',
   'otel:serviceInstanceId',
 
   // S2S Messaging Pubsub Settings
@@ -1127,12 +1128,16 @@ export const CONFIG_DEFINITIONS = {
   // OpenTelemetry Settings
   'otel:enabled': defineConfig<boolean>({
     envVarName: 'OPENTELEMETRY_ENABLED',
-    defaultValue: false,
+    defaultValue: true,
   }),
   'otel:isAppSiteUrlHashed': defineConfig<boolean>({
     envVarName: 'OPENTELEMETRY_IS_APP_SITE_URL_HASHED',
     defaultValue: false,
   }),
+  'otel:anonymizeInBestEffort': defineConfig<boolean>({
+    envVarName: 'OPENTELEMETRY_ANONYMIZE_IN_BEST_EFFORT',
+    defaultValue: false,
+  }),
   'otel:serviceInstanceId': defineConfig<string | undefined>({
     envVarName: 'OPENTELEMETRY_SERVICE_INSTANCE_ID',
     defaultValue: undefined,

+ 1 - 1
apps/slackbot-proxy/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/slackbot-proxy",
-  "version": "7.2.8-slackbot-proxy.0",
+  "version": "7.3.0-slackbot-proxy.0",
   "license": "MIT",
   "private": "true",
   "scripts": {

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "7.2.8-RC.0",
+  "version": "7.3.0-RC.0",
   "description": "Team collaboration software using markdown",
   "license": "MIT",
   "private": "true",

+ 121 - 0
packages/core/src/utils/page-path-utils/is-creatable-page.spec.ts

@@ -0,0 +1,121 @@
+import { isCreatablePage } from './index';
+
+describe('isCreatablePage', () => {
+  describe('should return true for valid page paths', () => {
+    it.each([
+      '/path/to/page',
+      '/hoge',
+      '/meeting',
+      '/meeting/x',
+      '/_',
+      '/_template',
+    ])('should return true for "%s"', (path) => {
+      expect(isCreatablePage(path)).toBe(true);
+    });
+  });
+
+  describe('Japanese character support', () => {
+    it('should handle Japanese characters correctly', () => {
+      const japanesePath = '/path/to/ページ';
+      const result = isCreatablePage(japanesePath);
+      expect(result).toBe(true);
+    });
+
+    it('should handle full Japanese path', () => {
+      const fullJapanesePath = '/ユーザー/プロジェクト';
+      const result = isCreatablePage(fullJapanesePath);
+      expect(result).toBe(true);
+    });
+
+    it('should handle mixed language path', () => {
+      const mixedPath = '/project/プロジェクト/documentation';
+      const result = isCreatablePage(mixedPath);
+      expect(result).toBe(true);
+    });
+  });
+
+  describe('edge cases', () => {
+    it('should allow user sub-pages but not user homepage', () => {
+      expect(isCreatablePage('/user')).toBe(false); // User top page
+      expect(isCreatablePage('/user/john')).toBe(false); // User homepage
+      expect(isCreatablePage('/user/john/projects')).toBe(true); // User sub-page
+    });
+
+    it('should distinguish between me and meeting', () => {
+      expect(isCreatablePage('/me')).toBe(false);
+      expect(isCreatablePage('/meeting')).toBe(true);
+    });
+  });
+
+  describe('should return false for invalid page paths', () => {
+    it.each([
+      '/user', // User top page
+      '/user/john', // User homepage
+      '/_api',
+      '/_search',
+      '/admin',
+      '/login',
+      '/hoge/file.md', // .md files
+      '//multiple-slash', // Multiple slashes
+      '/path/edit', // Edit paths
+    ])('should return false for "%s"', (path) => {
+      expect(isCreatablePage(path)).toBe(false);
+    });
+  });
+
+  describe('special characters restriction', () => {
+    it.each([
+      '/path^with^caret', // ^ character
+      '/path$with$dollar', // $ character
+      '/path*with*asterisk', // * character
+      '/path+with+plus', // + character
+    ])('should return false for "%s"', (path) => {
+      expect(isCreatablePage(path)).toBe(false);
+    });
+  });
+
+  describe('URL patterns restriction', () => {
+    it.each([
+      '/http://example.com/page', // HTTP URL
+      '/https://example.com/page', // HTTPS URL
+    ])('should return false for "%s"', (path) => {
+      expect(isCreatablePage(path)).toBe(false);
+    });
+  });
+
+  describe('relative path restriction', () => {
+    it.each([
+      '/..', // Parent directory reference
+      '/path/../other', // Relative path with parent reference
+    ])('should return false for "%s"', (path) => {
+      expect(isCreatablePage(path)).toBe(false);
+    });
+  });
+
+  describe('backslash restriction', () => {
+    it.each([
+      '/path\\with\\backslash', // Backslash in path
+      '/folder\\file', // Backslash separator
+    ])('should return false for "%s"', (path) => {
+      expect(isCreatablePage(path)).toBe(false);
+    });
+  });
+
+  describe('space and slash restriction', () => {
+    it.each([
+      '/ path / with / spaces', // Spaces around slashes
+      '/path / with / bad / formatting', // Mixed spacing
+    ])('should return false for "%s"', (path) => {
+      expect(isCreatablePage(path)).toBe(false);
+    });
+  });
+
+  describe('system path restriction', () => {
+    it.each([
+      '/_r/some/path', // _r system path
+      '/_private-legacy-pages/old', // Private legacy pages
+    ])('should return false for "%s"', (path) => {
+      expect(isCreatablePage(path)).toBe(false);
+    });
+  });
+});

+ 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
   const saveHandler = useCallback(() => {
     // eslint-disable-next-line no-console
-    console.log({ doc: codeMirrorEditor?.getDoc() });
+    console.log({ doc: codeMirrorEditor?.getDocString() });
     toast.success('Saved.', { autoClose: 2000 });
   }, [codeMirrorEditor]);
 

+ 7 - 3
packages/editor/src/client/services/use-codemirror-editor/use-codemirror-editor.ts

@@ -11,9 +11,10 @@ import { useAppendExtensions, type AppendExtensions } from './utils/append-exten
 import { useFocus, type Focus } from './utils/focus';
 import type { FoldDrawio } 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 { useInsertMarkdownElements, type InsertMarkdowElements } from './utils/insert-markdown-elements';
+import { useInsertMarkdownElements, type InsertMarkdownElements } from './utils/insert-markdown-elements';
 import { useInsertPrefix, type InsertPrefix } from './utils/insert-prefix';
 import { useInsertText, type InsertText } from './utils/insert-text';
 import { useReplaceText, type ReplaceText } from './utils/replace-text';
@@ -24,11 +25,12 @@ type UseCodeMirrorEditorUtils = {
   initDoc: InitDoc,
   appendExtensions: AppendExtensions,
   getDoc: GetDoc,
+  getDocString: GetDocString,
   focus: Focus,
   setCaretLine: SetCaretLine,
   insertText: InsertText,
   replaceText: ReplaceText,
-  insertMarkdownElements: InsertMarkdowElements,
+  insertMarkdownElements: InsertMarkdownElements,
   insertPrefix: InsertPrefix,
   foldDrawio: FoldDrawio,
 }
@@ -65,6 +67,7 @@ export const useCodeMirrorEditor = (props?: UseCodeMirror): UseCodeMirrorEditor
   const initDoc = useInitDoc(view);
   const appendExtensions = useAppendExtensions(view);
   const getDoc = useGetDoc(view);
+  const getDocString = useGetDocString(view);
   const focus = useFocus(view);
   const setCaretLine = useSetCaretLine(view);
   const insertText = useInsertText(view);
@@ -79,6 +82,7 @@ export const useCodeMirrorEditor = (props?: UseCodeMirror): UseCodeMirrorEditor
     initDoc,
     appendExtensions,
     getDoc,
+    getDocString,
     focus,
     setCaretLine,
     insertText,

+ 62 - 0
packages/editor/src/client/services/use-codemirror-editor/utils/editor-shortcuts/add-multi-cursor.ts

@@ -0,0 +1,62 @@
+import { useCallback } from 'react';
+
+import type { SelectionRange } from '@codemirror/state';
+import { EditorSelection } from '@codemirror/state';
+import type { EditorView, Command, KeyBinding } from '@codemirror/view';
+
+
+const addMultiCursor = (view: EditorView, direction: 'up' | 'down') => {
+
+  const selection = view.state.selection;
+  const doc = view.state.doc;
+  const ranges = selection.ranges;
+  const newRanges: SelectionRange[] = [];
+
+  ranges.forEach((range) => {
+
+    const head = range.head;
+    const line = doc.lineAt(head);
+    const targetLine = direction === 'up' ? line.number - 1 : line.number + 1;
+
+    if (targetLine < 1 || targetLine > doc.lines) return;
+
+    const targetLineText = doc.line(targetLine);
+
+    const col = Math.min(range.head - line.from, targetLineText.length);
+    const cursorPos = targetLineText.from + col;
+
+    newRanges.push(EditorSelection.cursor(cursorPos));
+
+  });
+
+  if (newRanges.length) {
+    const transaction = {
+      selection: EditorSelection.create([...ranges, ...newRanges]),
+    };
+
+    view.dispatch(transaction);
+  }
+
+  return true;
+};
+
+const useAddMultiCursorCommand = (direction: 'up' | 'down'): Command => {
+  return useCallback((view?: EditorView) => {
+    if (view == null) return false;
+    addMultiCursor(view, direction);
+    return true;
+  }, [direction]);
+};
+
+export const useAddMultiCursorKeyBindings = (): KeyBinding[] => {
+
+  const upMultiCursorCommand = useAddMultiCursorCommand('up');
+  const downMultiCursorCommand = useAddMultiCursorCommand('down');
+
+  const keyBindings = [
+    { key: 'mod-alt-ArrowUp', run: upMultiCursorCommand },
+    { key: 'mod-alt-ArrowDown', run: downMultiCursorCommand },
+  ];
+
+  return keyBindings;
+};

+ 31 - 0
packages/editor/src/client/services/use-codemirror-editor/utils/editor-shortcuts/generate-add-markdown-symbol-command.ts

@@ -0,0 +1,31 @@
+import type { Command } from '@codemirror/view';
+
+import type { InsertMarkdownElements } from '../insert-markdown-elements';
+import type { InsertPrefix } from '../insert-prefix';
+
+export const generateAddMarkdownSymbolCommand = (
+    insertMarkdown: InsertMarkdownElements | InsertPrefix,
+    prefix: string,
+    suffix?: string,
+): Command => {
+
+  const isInsertMarkdownElements = (
+      fn: InsertMarkdownElements | InsertPrefix,
+  ): fn is InsertMarkdownElements => {
+    return fn.length === 2;
+  };
+
+  const addMarkdownSymbolCommand: Command = () => {
+    if (isInsertMarkdownElements(insertMarkdown)) {
+      if (suffix == null) return false;
+      insertMarkdown(prefix, suffix);
+    }
+    else {
+      insertMarkdown(prefix);
+    }
+
+    return true;
+  };
+
+  return addMarkdownSymbolCommand;
+};

+ 17 - 0
packages/editor/src/client/services/use-codemirror-editor/utils/editor-shortcuts/insert-blockquote.ts

@@ -0,0 +1,17 @@
+import type { EditorView, KeyBinding } from '@codemirror/view';
+
+import { useInsertPrefix } from '../insert-prefix';
+
+import { generateAddMarkdownSymbolCommand } from './generate-add-markdown-symbol-command';
+
+
+export const useInsertBlockquoteKeyBinding = (view?: EditorView): KeyBinding => {
+
+  const insertPrefix = useInsertPrefix(view);
+
+  const insertBlockquoteCommand = generateAddMarkdownSymbolCommand(insertPrefix, '>');
+
+  const insertBlockquoteKeyBinding = { key: 'mod-shift-9', run: insertBlockquoteCommand };
+
+  return insertBlockquoteKeyBinding;
+};

+ 17 - 0
packages/editor/src/client/services/use-codemirror-editor/utils/editor-shortcuts/insert-bullet-list.ts

@@ -0,0 +1,17 @@
+import type { EditorView, KeyBinding } from '@codemirror/view';
+
+import { useInsertPrefix } from '../insert-prefix';
+
+import { generateAddMarkdownSymbolCommand } from './generate-add-markdown-symbol-command';
+
+
+export const useInsertBulletListKeyBinding = (view?: EditorView): KeyBinding => {
+
+  const insertPrefix = useInsertPrefix(view);
+
+  const insertBulletListCommand = generateAddMarkdownSymbolCommand(insertPrefix, '-');
+
+  const insertBulletListKeyBinding = { key: 'mod-shift-8', run: insertBulletListCommand };
+
+  return insertBulletListKeyBinding;
+};

+ 17 - 0
packages/editor/src/client/services/use-codemirror-editor/utils/editor-shortcuts/insert-link.ts

@@ -0,0 +1,17 @@
+import type { EditorView, KeyBinding } from '@codemirror/view';
+
+import { useInsertMarkdownElements } from '../insert-markdown-elements';
+
+import { generateAddMarkdownSymbolCommand } from './generate-add-markdown-symbol-command';
+
+
+export const useInsertLinkKeyBinding = (view?: EditorView): KeyBinding => {
+
+  const insertMarkdownElements = useInsertMarkdownElements(view);
+
+  const InsertLinkCommand = generateAddMarkdownSymbolCommand(insertMarkdownElements, '[', ']()');
+
+  const InsertLinkKeyBinding = { key: 'mod-shift-u', run: InsertLinkCommand };
+
+  return InsertLinkKeyBinding;
+};

+ 17 - 0
packages/editor/src/client/services/use-codemirror-editor/utils/editor-shortcuts/insert-numbered-list.ts

@@ -0,0 +1,17 @@
+import type { EditorView, KeyBinding } from '@codemirror/view';
+
+import { useInsertPrefix } from '../insert-prefix';
+
+import { generateAddMarkdownSymbolCommand } from './generate-add-markdown-symbol-command';
+
+
+export const useInsertNumberedKeyBinding = (view?: EditorView): KeyBinding => {
+
+  const insertPrefix = useInsertPrefix(view);
+
+  const insertNumberedCommand = generateAddMarkdownSymbolCommand(insertPrefix, '1.');
+
+  const insertNumberedKeyBinding = { key: 'mod-shift-7', run: insertNumberedCommand };
+
+  return insertNumberedKeyBinding;
+};

+ 24 - 0
packages/editor/src/client/services/use-codemirror-editor/utils/editor-shortcuts/make-text-bold.ts

@@ -0,0 +1,24 @@
+import type { EditorView, KeyBinding } from '@codemirror/view';
+
+import { useInsertMarkdownElements } from '../insert-markdown-elements';
+
+import { generateAddMarkdownSymbolCommand } from './generate-add-markdown-symbol-command';
+
+import type { KeyMapMode } from 'src/consts';
+
+
+export const useMakeTextBoldKeyBinding = (view?: EditorView, keyMapName?: KeyMapMode): KeyBinding => {
+
+  const insertMarkdownElements = useInsertMarkdownElements(view);
+
+  let makeTextBoldKeyBinding: KeyBinding;
+  switch (keyMapName) {
+    case 'vim':
+      makeTextBoldKeyBinding = { key: 'mod-shift-b', run: generateAddMarkdownSymbolCommand(insertMarkdownElements, '**', '**') };
+      break;
+    default:
+      makeTextBoldKeyBinding = { key: 'mod-b', run: generateAddMarkdownSymbolCommand(insertMarkdownElements, '**', '**') };
+  }
+
+  return makeTextBoldKeyBinding;
+};

+ 84 - 0
packages/editor/src/client/services/use-codemirror-editor/utils/editor-shortcuts/make-text-code-block.ts

@@ -0,0 +1,84 @@
+import { EditorSelection } from '@codemirror/state';
+import type { Extension, ChangeSpec, SelectionRange } from '@codemirror/state';
+import type { Command } from '@codemirror/view';
+import { EditorView } from '@codemirror/view';
+
+const makeTextCodeBlock: Command = (view: EditorView) => {
+  const state = view.state;
+  const doc = state.doc;
+  const changes: ChangeSpec[] = [];
+  const newSelections: SelectionRange[] = [];
+
+  state.selection.ranges.forEach((range) => {
+    const startLine = doc.lineAt(range.from);
+    const endLine = doc.lineAt(range.to);
+    const selectedText = doc.sliceString(range.from, range.to, '');
+    const isAlreadyWrapped = selectedText.startsWith('```') && selectedText.endsWith('```');
+
+    const codeBlockMarkerLength = 4;
+
+    if (isAlreadyWrapped) {
+      const startMarkerEnd = startLine.from + codeBlockMarkerLength;
+      const endMarkerStart = endLine.to - codeBlockMarkerLength;
+
+      changes.push({
+        from: startLine.from,
+        to: startMarkerEnd,
+        insert: '',
+      });
+
+      changes.push({
+        from: endMarkerStart,
+        to: endLine.to,
+        insert: '',
+      });
+
+      newSelections.push(EditorSelection.range(startLine.from, endMarkerStart - codeBlockMarkerLength));
+    }
+    else {
+      // Add code block markers
+      changes.push({
+        from: startLine.from,
+        insert: '```\n',
+      });
+
+      changes.push({
+        from: endLine.to,
+        insert: '\n```',
+      });
+
+      if (selectedText.length === 0) {
+        newSelections.push(EditorSelection.cursor(startLine.from + codeBlockMarkerLength));
+      }
+      else {
+        newSelections.push(EditorSelection.range(startLine.from, endLine.to + codeBlockMarkerLength * 2));
+      }
+    }
+  });
+
+  view.dispatch({
+    changes,
+    selection: EditorSelection.create(newSelections),
+  });
+
+  return true;
+};
+
+const makeCodeBlockExtension: Extension = EditorView.domEventHandlers({
+  keydown: (event, view) => {
+
+    const isModKey = event.ctrlKey || event.metaKey;
+
+    if (event.code === 'KeyC' && event.shiftKey && event.altKey && isModKey) {
+      event.preventDefault();
+      makeTextCodeBlock(view);
+      return true;
+    }
+
+    return false;
+  },
+});
+
+export const useMakeCodeBlockExtension = (): Extension => {
+  return makeCodeBlockExtension;
+};

+ 17 - 0
packages/editor/src/client/services/use-codemirror-editor/utils/editor-shortcuts/make-text-code.ts

@@ -0,0 +1,17 @@
+import type { EditorView, KeyBinding } from '@codemirror/view';
+
+import { useInsertMarkdownElements } from '../insert-markdown-elements';
+
+import { generateAddMarkdownSymbolCommand } from './generate-add-markdown-symbol-command';
+
+
+export const useMakeTextCodeKeyBinding = (view?: EditorView): KeyBinding => {
+
+  const insertMarkdownElements = useInsertMarkdownElements(view);
+
+  const makeTextCodeCommand = generateAddMarkdownSymbolCommand(insertMarkdownElements, '`', '`');
+
+  const makeTextCodeKeyBinding = { key: 'mod-shift-c', run: makeTextCodeCommand };
+
+  return makeTextCodeKeyBinding;
+};

+ 17 - 0
packages/editor/src/client/services/use-codemirror-editor/utils/editor-shortcuts/make-text-italic.ts

@@ -0,0 +1,17 @@
+import type { EditorView, KeyBinding } from '@codemirror/view';
+
+import { useInsertMarkdownElements } from '../insert-markdown-elements';
+
+import { generateAddMarkdownSymbolCommand } from './generate-add-markdown-symbol-command';
+
+
+export const useMakeTextItalicKeyBinding = (view?: EditorView): KeyBinding => {
+
+  const insertMarkdownElements = useInsertMarkdownElements(view);
+
+  const makeTextItalicCommand = generateAddMarkdownSymbolCommand(insertMarkdownElements, '*', '*');
+
+  const makeTextItalicKeyBinding = { key: 'mod-shift-i', run: makeTextItalicCommand };
+
+  return makeTextItalicKeyBinding;
+};

+ 17 - 0
packages/editor/src/client/services/use-codemirror-editor/utils/editor-shortcuts/make-text-strikethrough.ts

@@ -0,0 +1,17 @@
+import type { EditorView, KeyBinding } from '@codemirror/view';
+
+import { useInsertMarkdownElements } from '../insert-markdown-elements';
+
+import { generateAddMarkdownSymbolCommand } from './generate-add-markdown-symbol-command';
+
+
+export const useMakeTextStrikethroughKeyBinding = (view?: EditorView): KeyBinding => {
+
+  const insertMarkdownElements = useInsertMarkdownElements(view);
+
+  const makeTextStrikethroughCommand = generateAddMarkdownSymbolCommand(insertMarkdownElements, '~~', '~~');
+
+  const makeTextStrikethroughKeyBinding = { key: 'mod-shift-x', run: makeTextStrikethroughCommand };
+
+  return makeTextStrikethroughKeyBinding;
+};

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

@@ -1,13 +1,23 @@
 import { useCallback } from 'react';
 
+import { Text } from '@codemirror/state';
 import type { EditorView } from '@codemirror/view';
 
-export type GetDoc = () => string;
+export type GetDoc = () => Text;
+export type GetDocString = () => string;
 
 export const useGetDoc = (view?: EditorView): GetDoc => {
 
   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]);
 
 };

+ 39 - 10
packages/editor/src/client/services/use-codemirror-editor/utils/insert-markdown-elements.ts

@@ -2,26 +2,55 @@ import { useCallback } from 'react';
 
 import type { EditorView } from '@codemirror/view';
 
-export type InsertMarkdowElements = (
+
+export type InsertMarkdownElements = (
   prefix: string,
   suffix: string,
 ) => void;
 
-export const useInsertMarkdownElements = (view?: EditorView): InsertMarkdowElements => {
+const removeSymbol = (text: string, prefix: string, suffix: string): string => {
+  let result = text;
+
+  if (result.startsWith(prefix)) {
+    result = result.slice(prefix.length);
+  }
+
+  if (result.endsWith(suffix)) {
+    result = result.slice(0, -suffix.length);
+  }
+
+  return result;
+};
+
+export const useInsertMarkdownElements = (view?: EditorView): InsertMarkdownElements => {
 
   return useCallback((prefix, suffix) => {
-    const selection = view?.state.sliceDoc(
-      view?.state.selection.main.from,
-      view?.state.selection.main.to,
-    );
+    if (view == null) return;
+
+    const from = view?.state.selection.main.from;
+    const to = view?.state.selection.main.to;
+
+    const selectedText = view?.state.sliceDoc(from, to);
     const cursorPos = view?.state.selection.main.head;
-    const insertText = view?.state.replaceSelection(prefix + selection + suffix);
 
-    if (insertText == null || cursorPos == null) {
+    let insertText: string;
+
+    if (selectedText?.startsWith(prefix) && selectedText?.endsWith(suffix)) {
+      insertText = removeSymbol(selectedText, prefix, suffix);
+    }
+    else {
+      insertText = prefix + selectedText + suffix;
+    }
+
+    const selection = (from === to) ? { anchor: from + prefix.length } : { anchor: from, head: from + insertText.length };
+
+    const transaction = view?.state.replaceSelection(insertText);
+
+    if (transaction == null || cursorPos == null) {
       return;
     }
-    view?.dispatch(insertText);
-    view?.dispatch({ selection: { anchor: cursorPos + prefix.length } });
+    view?.dispatch(transaction);
+    view?.dispatch({ selection });
     view?.focus();
   }, [view]);
 };

+ 143 - 16
packages/editor/src/client/services/use-codemirror-editor/utils/insert-prefix.ts

@@ -1,36 +1,163 @@
 import { useCallback } from 'react';
 
-import type { ChangeSpec } from '@codemirror/state';
+import type { ChangeSpec, Line, Text } from '@codemirror/state';
 import type { EditorView } from '@codemirror/view';
 
 export type InsertPrefix = (prefix: string, noSpaceIfPrefixExists?: boolean) => void;
 
+// https:// regex101.com/r/5ILXUX/1
+const LEADING_SPACES = /^\s*/;
+// https://regex101.com/r/ScAXzy/1
+const createPrefixPattern = (prefix: string) => new RegExp(`^\\s*(${prefix}+)\\s*`);
+
+const removePrefix = (text: string, prefix: string): string => {
+  if (text.startsWith(prefix)) {
+    return text.slice(prefix.length).trimStart();
+  }
+  return text;
+};
+
+const allLinesEmpty = (doc: Text, startLine: Line, endLine: Line) => {
+  for (let i = startLine.number; i <= endLine.number; i++) {
+    const line = doc.line(i);
+    if (line.text.trim() !== '') {
+      return false;
+    }
+  }
+  return true;
+};
+
+const allLinesHavePrefix = (doc: Text, startLine: Line, endLine: Line, prefix: string) => {
+  let hasNonEmptyLine = false;
+
+  for (let i = startLine.number; i <= endLine.number; i++) {
+    const line = doc.line(i);
+    const trimmedLine = line.text.trim();
+
+    if (trimmedLine !== '') {
+      hasNonEmptyLine = true;
+      if (!trimmedLine.startsWith(prefix)) {
+        return false;
+      }
+    }
+  }
+
+  return hasNonEmptyLine;
+};
+
 export const useInsertPrefix = (view?: EditorView): InsertPrefix => {
   return useCallback((prefix: string, noSpaceIfPrefixExists = false) => {
     if (view == null) {
       return;
     }
 
-    // get the line numbers of the selected range
     const { from, to } = view.state.selection.main;
-    const startLine = view.state.doc.lineAt(from);
-    const endLine = view.state.doc.lineAt(to);
+    const doc = view.state.doc;
+    const startLine = doc.lineAt(from);
+    const endLine = doc.lineAt(to);
+
+    const changes: ChangeSpec[] = [];
+    let totalLengthChange = 0;
+
+    const isPrefixRemoval = allLinesHavePrefix(doc, startLine, endLine, prefix);
+
+    if (allLinesEmpty(doc, startLine, endLine)) {
+      for (let i = startLine.number; i <= endLine.number; i++) {
+        const line = view.state.doc.line(i);
+        const leadingSpaces = line.text.match(LEADING_SPACES)?.[0] || '';
+        const insertText = `${leadingSpaces}${prefix} `;
+
+        const change = {
+          from: line.from,
+          to: line.to,
+          insert: insertText,
+        };
+
+        changes.push(change);
+        totalLengthChange += insertText.length - (line.to - line.from);
+      }
+
+      view.dispatch({ changes });
+      view.dispatch({
+        selection: {
+          anchor: from + totalLengthChange,
+          head: to + totalLengthChange,
+        },
+      });
+      view.focus();
+      return;
+    }
 
-    // Insert prefix for each line
-    const lines: ChangeSpec[] = [];
-    let insertTextLength = 0;
     for (let i = startLine.number; i <= endLine.number; i++) {
       const line = view.state.doc.line(i);
-      const insertText = noSpaceIfPrefixExists && line.text.startsWith(prefix)
-        ? prefix
-        : `${prefix} `;
-      insertTextLength += insertText.length;
-      lines.push({ from: line.from, insert: insertText });
+      const trimmedLine = line.text.trim();
+      const leadingSpaces = line.text.match(LEADING_SPACES)?.[0] || '';
+      const contentTrimmed = line.text.trimStart();
+
+      if (trimmedLine === '') {
+        continue;
+      }
+
+      let newLine = '';
+      let lengthChange = 0;
+
+      if (isPrefixRemoval) {
+        const prefixPattern = createPrefixPattern(prefix);
+        const contentStartMatch = line.text.match(prefixPattern);
+
+        if (contentStartMatch) {
+          if (noSpaceIfPrefixExists) {
+            const existingPrefixes = contentStartMatch[1];
+            const indentLevel = Math.floor(leadingSpaces.length / 2) * 2;
+            const newIndent = ' '.repeat(indentLevel);
+            newLine = `${newIndent}${existingPrefixes}${prefix} ${line.text.slice(contentStartMatch[0].length)}`;
+          }
+          else {
+            const indentLevel = Math.floor(leadingSpaces.length / 2) * 2;
+            const newIndent = ' '.repeat(indentLevel);
+            const prefixRemovedText = removePrefix(contentTrimmed, prefix);
+            newLine = `${newIndent}${prefixRemovedText}`;
+          }
+
+          lengthChange = newLine.length - (line.to - line.from);
+
+          changes.push({
+            from: line.from,
+            to: line.to,
+            insert: newLine,
+          });
+        }
+      }
+      else {
+        if (noSpaceIfPrefixExists && contentTrimmed.startsWith(prefix)) {
+          newLine = `${leadingSpaces}${prefix}${contentTrimmed}`;
+        }
+        else {
+          newLine = `${leadingSpaces}${prefix} ${contentTrimmed}`;
+        }
+
+        lengthChange = newLine.length - (line.to - line.from);
+
+        changes.push({
+          from: line.from,
+          to: line.to,
+          insert: newLine,
+        });
+      }
+
+      totalLengthChange += lengthChange;
     }
-    view.dispatch({ changes: lines });
 
-    // move the cursor to the end of the selected line
-    view.dispatch({ selection: { anchor: endLine.to + insertTextLength } });
-    view.focus();
+    if (changes.length > 0) {
+      view.dispatch({ changes });
+
+      view.dispatch({
+        selection: {
+          anchor: from,
+          head: to + totalLengthChange,
+        },
+      });
+      view.focus();
+    }
   }, [view]);
 };

+ 3 - 0
packages/editor/src/client/stores/use-editor-settings.ts

@@ -14,6 +14,8 @@ import {
   getEditorTheme, getKeymap, insertNewlineContinueMarkup, insertNewRowToMarkdownTable, isInTable,
 } from '../services-internal';
 
+import { useEditorShortcuts } from './use-editor-shortcuts';
+
 const useStyleActiveLine = (
     codeMirrorEditor?: UseCodeMirrorEditor,
     styleActiveLine?: boolean,
@@ -100,6 +102,7 @@ export const useEditorSettings = (
     editorSettings?: EditorSettings,
     onSave?: () => void,
 ): void => {
+  useEditorShortcuts(codeMirrorEditor, editorSettings?.keymapMode);
   useStyleActiveLine(codeMirrorEditor, editorSettings?.styleActiveLine);
   useEnterKeyHandler(codeMirrorEditor, editorSettings?.autoFormatMarkdownTable);
   useThemeExtension(codeMirrorEditor, editorSettings?.theme);

+ 75 - 0
packages/editor/src/client/stores/use-editor-shortcuts.ts

@@ -0,0 +1,75 @@
+import { useEffect } from 'react';
+
+import type { EditorView } from '@codemirror/view';
+import {
+  keymap, type KeyBinding,
+} from '@codemirror/view';
+
+import type { UseCodeMirrorEditor } from '../services';
+import { useAddMultiCursorKeyBindings } from '../services/use-codemirror-editor/utils/editor-shortcuts/add-multi-cursor';
+import { useInsertBlockquoteKeyBinding } from '../services/use-codemirror-editor/utils/editor-shortcuts/insert-blockquote';
+import { useInsertBulletListKeyBinding } from '../services/use-codemirror-editor/utils/editor-shortcuts/insert-bullet-list';
+import { useInsertLinkKeyBinding } from '../services/use-codemirror-editor/utils/editor-shortcuts/insert-link';
+import { useInsertNumberedKeyBinding } from '../services/use-codemirror-editor/utils/editor-shortcuts/insert-numbered-list';
+import { useMakeTextBoldKeyBinding } from '../services/use-codemirror-editor/utils/editor-shortcuts/make-text-bold';
+import { useMakeTextCodeKeyBinding } from '../services/use-codemirror-editor/utils/editor-shortcuts/make-text-code';
+import { useMakeCodeBlockExtension } from '../services/use-codemirror-editor/utils/editor-shortcuts/make-text-code-block';
+import { useMakeTextItalicKeyBinding } from '../services/use-codemirror-editor/utils/editor-shortcuts/make-text-italic';
+import { useMakeTextStrikethroughKeyBinding } from '../services/use-codemirror-editor/utils/editor-shortcuts/make-text-strikethrough';
+
+
+import type { KeyMapMode } from 'src/consts';
+
+const useKeyBindings = (view?: EditorView, keymapModeName?: KeyMapMode): KeyBinding[] => {
+
+  const makeTextBoldKeyBinding = useMakeTextBoldKeyBinding(view, keymapModeName);
+  const makeTextItalicKeyBinding = useMakeTextItalicKeyBinding(view);
+  const makeTextStrikethroughKeyBinding = useMakeTextStrikethroughKeyBinding(view);
+  const makeTextCodeCommand = useMakeTextCodeKeyBinding(view);
+  const insertNumberedKeyBinding = useInsertNumberedKeyBinding(view);
+  const insertBulletListKeyBinding = useInsertBulletListKeyBinding(view);
+  const insertBlockquoteKeyBinding = useInsertBlockquoteKeyBinding(view);
+  const InsertLinkKeyBinding = useInsertLinkKeyBinding(view);
+  const multiCursorKeyBindings = useAddMultiCursorKeyBindings();
+
+  const keyBindings: KeyBinding[] = [
+    makeTextBoldKeyBinding,
+    makeTextItalicKeyBinding,
+    makeTextStrikethroughKeyBinding,
+    makeTextCodeCommand,
+    insertNumberedKeyBinding,
+    insertBulletListKeyBinding,
+    insertBlockquoteKeyBinding,
+    InsertLinkKeyBinding,
+    ...multiCursorKeyBindings,
+  ];
+
+  return keyBindings;
+};
+
+export const useEditorShortcuts = (codeMirrorEditor?: UseCodeMirrorEditor, keymapModeName?: KeyMapMode): void => {
+
+  const keyBindings = useKeyBindings(codeMirrorEditor?.view, keymapModeName);
+
+  // Since key combinations of 4 or more keys cannot be implemented with CodeMirror's keybinding, they are implemented as Extensions.
+  const makeCodeBlockExtension = useMakeCodeBlockExtension();
+
+  useEffect(() => {
+    const cleanupFunction = codeMirrorEditor?.appendExtensions?.([makeCodeBlockExtension]);
+    return cleanupFunction;
+  }, [codeMirrorEditor, makeCodeBlockExtension]);
+
+  useEffect(() => {
+
+    if (keyBindings == null) {
+      return;
+    }
+
+    const keyboardShortcutsExtension = keymap.of(keyBindings);
+
+    const cleanupFunction = codeMirrorEditor?.appendExtensions?.(keyboardShortcutsExtension);
+    return cleanupFunction;
+
+  }, [codeMirrorEditor, keyBindings]);
+
+};

Fichier diff supprimé car celui-ci est trop grand
+ 280 - 343
pnpm-lock.yaml


Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff