From e3f6a3a769de2bfa69f557df500f3175937a8832 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Wed, 19 Nov 2025 10:41:44 -0300 Subject: [PATCH 1/4] feat: new ai-builder class - Introduced AI Builder for low-level primitives to create custom AI workflows. - Added support for Anthropic Claude in AI Builder. - Implemented core tools: insertContent, replaceContent, and readSelection. - Updated README.md to reflect new features and usage examples. - Improved package.json with updated dependencies and versioning. - Added content schema for structured document representation. --- package-lock.json | 853 +++++++----------- packages/ai/README.md | 341 ++----- packages/ai/package.json | 42 +- packages/ai/src/ai-builder/README.md | 193 ++++ packages/ai/src/ai-builder/content-schema.ts | 130 +++ packages/ai/src/ai-builder/executor.ts | 82 ++ packages/ai/src/ai-builder/index.ts | 51 ++ .../ai/src/ai-builder/providers/anthropic.ts | 94 ++ packages/ai/src/ai-builder/providers/index.ts | 1 + packages/ai/src/ai-builder/tools/index.ts | 34 + .../ai/src/ai-builder/tools/insertContent.ts | 99 ++ .../ai/src/ai-builder/tools/readSelection.ts | 45 + .../ai/src/ai-builder/tools/replaceContent.ts | 107 +++ packages/ai/src/ai-builder/types.ts | 97 ++ packages/ai/src/index.ts | 9 + 15 files changed, 1329 insertions(+), 849 deletions(-) create mode 100644 packages/ai/src/ai-builder/README.md create mode 100644 packages/ai/src/ai-builder/content-schema.ts create mode 100644 packages/ai/src/ai-builder/executor.ts create mode 100644 packages/ai/src/ai-builder/index.ts create mode 100644 packages/ai/src/ai-builder/providers/anthropic.ts create mode 100644 packages/ai/src/ai-builder/providers/index.ts create mode 100644 packages/ai/src/ai-builder/tools/index.ts create mode 100644 packages/ai/src/ai-builder/tools/insertContent.ts create mode 100644 packages/ai/src/ai-builder/tools/readSelection.ts create mode 100644 packages/ai/src/ai-builder/tools/replaceContent.ts create mode 100644 packages/ai/src/ai-builder/types.ts diff --git a/package-lock.json b/package-lock.json index c44201ea7..dea213317 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2654,6 +2654,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@standard-schema/spec": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", + "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", + "dev": true, + "license": "MIT" + }, "node_modules/@superdoc-dev/ai": { "resolved": "packages/ai", "link": true @@ -2832,17 +2839,57 @@ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "license": "MIT" }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.47.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.47.0.tgz", + "integrity": "sha512-fe0rz9WJQ5t2iaLfdbDc9T80GJy0AeO453q8C3YCilnGozvOyCG5t+EZtg7j7D88+c3FipfP/x+wzGnh1xp8ZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.47.0", + "@typescript-eslint/type-utils": "8.47.0", + "@typescript-eslint/utils": "8.47.0", + "@typescript-eslint/visitor-keys": "8.47.0", + "graphemer": "^1.4.0", + "ignore": "^7.0.0", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.47.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/@typescript-eslint/parser": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.4.tgz", - "integrity": "sha512-tK3GPFWbirvNgsNKto+UmB/cRtn6TZfyw0D6IKrW55n6Vbs7KJoZtI//kpTKzE/DUmmnAFD8/Ca46s7Obs92/w==", + "version": "8.47.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.47.0.tgz", + "integrity": "sha512-lJi3PfxVmo0AkEY93ecfN+r8SofEqZNGByvHAI3GBLrvt1Cw6H5k1IM02nSzu0RfUafr2EvFSw0wAsZgubNplQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.46.4", - "@typescript-eslint/types": "8.46.4", - "@typescript-eslint/typescript-estree": "8.46.4", - "@typescript-eslint/visitor-keys": "8.46.4", + "@typescript-eslint/scope-manager": "8.47.0", + "@typescript-eslint/types": "8.47.0", + "@typescript-eslint/typescript-estree": "8.47.0", + "@typescript-eslint/visitor-keys": "8.47.0", "debug": "^4.3.4" }, "engines": { @@ -2858,14 +2905,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.46.4.tgz", - "integrity": "sha512-nPiRSKuvtTN+no/2N1kt2tUh/HoFzeEgOm9fQ6XQk4/ApGqjx0zFIIaLJ6wooR1HIoozvj2j6vTi/1fgAz7UYQ==", + "version": "8.47.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.47.0.tgz", + "integrity": "sha512-2X4BX8hUeB5JcA1TQJ7GjcgulXQ+5UkNb0DL8gHsHUHdFoiCTJoYLTpib3LtSDPZsRET5ygN4qqIWrHyYIKERA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.46.4", - "@typescript-eslint/types": "^8.46.4", + "@typescript-eslint/tsconfig-utils": "^8.47.0", + "@typescript-eslint/types": "^8.47.0", "debug": "^4.3.4" }, "engines": { @@ -2880,14 +2927,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.46.4.tgz", - "integrity": "sha512-tMDbLGXb1wC+McN1M6QeDx7P7c0UWO5z9CXqp7J8E+xGcJuUuevWKxuG8j41FoweS3+L41SkyKKkia16jpX7CA==", + "version": "8.47.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.47.0.tgz", + "integrity": "sha512-a0TTJk4HXMkfpFkL9/WaGTNuv7JWfFTQFJd6zS9dVAjKsojmv9HT55xzbEpnZoY+VUb+YXLMp+ihMLz/UlZfDg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.46.4", - "@typescript-eslint/visitor-keys": "8.46.4" + "@typescript-eslint/types": "8.47.0", + "@typescript-eslint/visitor-keys": "8.47.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2898,11 +2945,35 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.4.tgz", - "integrity": "sha512-+/XqaZPIAk6Cjg7NWgSGe27X4zMGqrFqZ8atJsX3CWxH/jACqWnrWI68h7nHQld0y+k9eTTjb9r+KU4twLoo9A==", + "version": "8.47.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.47.0.tgz", + "integrity": "sha512-ybUAvjy4ZCL11uryalkKxuT3w3sXJAuWhOoGS3T/Wu+iUu1tGJmk5ytSY8gbdACNARmcYEB0COksD2j6hfGK2g==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.47.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.47.0.tgz", + "integrity": "sha512-QC9RiCmZ2HmIdCEvhd1aJELBlD93ErziOXXlHEZyuBo3tBiAZieya0HLIxp+DoDWlsQqDawyKuNEhORyku+P8A==", "dev": true, "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.47.0", + "@typescript-eslint/typescript-estree": "8.47.0", + "@typescript-eslint/utils": "8.47.0", + "debug": "^4.3.4", + "ts-api-utils": "^2.1.0" + }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -2911,13 +2982,14 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/types": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.4.tgz", - "integrity": "sha512-USjyxm3gQEePdUwJBFjjGNG18xY9A2grDVGuk7/9AkjIF1L+ZrVnwR5VAU5JXtUnBL/Nwt3H31KlRDaksnM7/w==", + "version": "8.47.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.47.0.tgz", + "integrity": "sha512-nHAE6bMKsizhA2uuYZbEbmp5z2UpffNrPEqiKIeN7VsV6UY/roxanWfoRrf6x/k9+Obf+GQdkm0nPU+vnMXo9A==", "dev": true, "license": "MIT", "engines": { @@ -2929,16 +3001,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.4.tgz", - "integrity": "sha512-7oV2qEOr1d4NWNmpXLR35LvCfOkTNymY9oyW+lUHkmCno7aOmIf/hMaydnJBUTBMRCOGZh8YjkFOc8dadEoNGA==", + "version": "8.47.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.47.0.tgz", + "integrity": "sha512-k6ti9UepJf5NpzCjH31hQNLHQWupTRPhZ+KFF8WtTuTpy7uHPfeg2NM7cP27aCGajoEplxJDFVCEm9TGPYyiVg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.46.4", - "@typescript-eslint/tsconfig-utils": "8.46.4", - "@typescript-eslint/types": "8.46.4", - "@typescript-eslint/visitor-keys": "8.46.4", + "@typescript-eslint/project-service": "8.47.0", + "@typescript-eslint/tsconfig-utils": "8.47.0", + "@typescript-eslint/types": "8.47.0", + "@typescript-eslint/visitor-keys": "8.47.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", @@ -2983,14 +3055,38 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/@typescript-eslint/utils": { + "version": "8.47.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.47.0.tgz", + "integrity": "sha512-g7XrNf25iL4TJOiPqatNuaChyqt49a/onq5YsJ9+hXeugK+41LVg7AxikMfM02PC6jbNtZLCJj6AUcQXJS/jGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.47.0", + "@typescript-eslint/types": "8.47.0", + "@typescript-eslint/typescript-estree": "8.47.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.4.tgz", - "integrity": "sha512-/++5CYLQqsO9HFGLI7APrxBJYo+5OCMpViuhV8q5/Qa3o5mMrF//eQHks+PXcsAVaLdn817fMuS7zqoXNNZGaw==", + "version": "8.47.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.47.0.tgz", + "integrity": "sha512-SIV3/6eftCy1bNzCQoPmbWsRLujS8t5iDIZ4spZOBHqrM+yfX2ogg8Tt3PDTAVKw3sSCiUgg30uOAvK2r9zGjQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.46.4", + "@typescript-eslint/types": "8.47.0", "eslint-visitor-keys": "^4.2.1" }, "engines": { @@ -8223,6 +8319,13 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, "node_modules/gunzip-maybe": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/gunzip-maybe/-/gunzip-maybe-1.4.2.tgz", @@ -20628,19 +20731,20 @@ }, "packages/ai": { "name": "@superdoc-dev/ai", - "version": "0.1.3", + "version": "0.1.31", "license": "AGPL-3.0", "devDependencies": { - "@types/node": "^20.0.0", - "@typescript-eslint/eslint-plugin": "^6.0.0", - "@typescript-eslint/parser": "^6.0.0", - "eslint": "^8.0.0", - "prosemirror-model": "^1.25.2", - "prosemirror-state": "^1.4.3", - "prosemirror-view": "^1.33.8", - "superdoc": "^0.28.0", - "typescript": "^5.0.0", - "vitest": "^3.2.4" + "@types/node": "^24.10.1", + "@typescript-eslint/eslint-plugin": "^8.47.0", + "@typescript-eslint/parser": "^8.47.0", + "eslint": "^9.39.1", + "prosemirror-model": "^1.25.4", + "prosemirror-state": "^1.4.4", + "prosemirror-view": "^1.41.3", + "superdoc": "^0.29.0", + "tsup": "^8.5.1", + "typescript": "^5.9.3", + "vitest": "^4.0.10" }, "peerDependencies": { "prosemirror-model": "^1.25.2", @@ -20649,599 +20753,250 @@ "superdoc": "*" } }, - "packages/ai/node_modules/@eslint/eslintrc": { - "version": "2.1.4", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^9.6.0", - "globals": "^13.19.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "packages/ai/node_modules/@eslint/js": { - "version": "8.57.1", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - } - }, "packages/ai/node_modules/@types/node": { - "version": "20.19.24", + "version": "24.10.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", + "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~6.21.0" + "undici-types": "~7.16.0" } }, - "packages/ai/node_modules/@typescript-eslint/eslint-plugin": { - "version": "6.21.0", + "packages/ai/node_modules/@vitest/expect": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.10.tgz", + "integrity": "sha512-3QkTX/lK39FBNwARCQRSQr0TP9+ywSdxSX+LgbJ2M1WmveXP72anTbnp2yl5fH+dU6SUmBzNMrDHs80G8G2DZg==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/regexpp": "^4.5.1", - "@typescript-eslint/scope-manager": "6.21.0", - "@typescript-eslint/type-utils": "6.21.0", - "@typescript-eslint/utils": "6.21.0", - "@typescript-eslint/visitor-keys": "6.21.0", - "debug": "^4.3.4", - "graphemer": "^1.4.0", - "ignore": "^5.2.4", - "natural-compare": "^1.4.0", - "semver": "^7.5.4", - "ts-api-utils": "^1.0.1" - }, - "engines": { - "node": "^16.0.0 || >=18.0.0" + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.10", + "@vitest/utils": "4.0.10", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "@typescript-eslint/parser": "^6.0.0 || ^6.0.0-alpha", - "eslint": "^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "url": "https://opencollective.com/vitest" } }, - "packages/ai/node_modules/@typescript-eslint/parser": { - "version": "6.21.0", + "packages/ai/node_modules/@vitest/mocker": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.10.tgz", + "integrity": "sha512-e2OfdexYkjkg8Hh3L9NVEfbwGXq5IZbDovkf30qW2tOh7Rh9sVtmSr2ztEXOFbymNxS4qjzLXUQIvATvN4B+lg==", "dev": true, - "license": "BSD-2-Clause", + "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "6.21.0", - "@typescript-eslint/types": "6.21.0", - "@typescript-eslint/typescript-estree": "6.21.0", - "@typescript-eslint/visitor-keys": "6.21.0", - "debug": "^4.3.4" - }, - "engines": { - "node": "^16.0.0 || >=18.0.0" + "@vitest/spy": "4.0.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "eslint": "^7.0.0 || ^8.0.0" + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" }, "peerDependenciesMeta": { - "typescript": { + "msw": { + "optional": true + }, + "vite": { "optional": true } } }, - "packages/ai/node_modules/@typescript-eslint/scope-manager": { - "version": "6.21.0", + "packages/ai/node_modules/@vitest/pretty-format": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.10.tgz", + "integrity": "sha512-99EQbpa/zuDnvVjthwz5bH9o8iPefoQZ63WV8+bsRJZNw3qQSvSltfut8yu1Jc9mqOYi7pEbsKxYTi/rjaq6PA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "6.21.0", - "@typescript-eslint/visitor-keys": "6.21.0" - }, - "engines": { - "node": "^16.0.0 || >=18.0.0" + "tinyrainbow": "^3.0.3" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "url": "https://opencollective.com/vitest" } }, - "packages/ai/node_modules/@typescript-eslint/type-utils": { - "version": "6.21.0", + "packages/ai/node_modules/@vitest/runner": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.10.tgz", + "integrity": "sha512-EXU2iSkKvNwtlL8L8doCpkyclw0mc/t4t9SeOnfOFPyqLmQwuceMPA4zJBa6jw0MKsZYbw7kAn+gl7HxrlB8UQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "6.21.0", - "@typescript-eslint/utils": "6.21.0", - "debug": "^4.3.4", - "ts-api-utils": "^1.0.1" - }, - "engines": { - "node": "^16.0.0 || >=18.0.0" + "@vitest/utils": "4.0.10", + "pathe": "^2.0.3" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "url": "https://opencollective.com/vitest" } }, - "packages/ai/node_modules/@typescript-eslint/types": { - "version": "6.21.0", + "packages/ai/node_modules/@vitest/snapshot": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.10.tgz", + "integrity": "sha512-2N4X2ZZl7kZw0qeGdQ41H0KND96L3qX1RgwuCfy6oUsF2ISGD/HpSbmms+CkIOsQmg2kulwfhJ4CI0asnZlvkg==", "dev": true, "license": "MIT", - "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "packages/ai/node_modules/@typescript-eslint/typescript-estree": { - "version": "6.21.0", - "dev": true, - "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/types": "6.21.0", - "@typescript-eslint/visitor-keys": "6.21.0", - "debug": "^4.3.4", - "globby": "^11.1.0", - "is-glob": "^4.0.3", - "minimatch": "9.0.3", - "semver": "^7.5.4", - "ts-api-utils": "^1.0.1" - }, - "engines": { - "node": "^16.0.0 || >=18.0.0" + "@vitest/pretty-format": "4.0.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "url": "https://opencollective.com/vitest" } }, - "packages/ai/node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.3", + "packages/ai/node_modules/@vitest/spy": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.10.tgz", + "integrity": "sha512-AsY6sVS8OLb96GV5RoG8B6I35GAbNrC49AO+jNRF9YVGb/g9t+hzNm1H6kD0NDp8tt7VJLs6hb7YMkDXqu03iw==", "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, + "license": "MIT", "funding": { - "url": "https://github.com/sponsors/isaacs" + "url": "https://opencollective.com/vitest" } }, - "packages/ai/node_modules/@typescript-eslint/utils": { - "version": "6.21.0", + "packages/ai/node_modules/@vitest/utils": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.10.tgz", + "integrity": "sha512-kOuqWnEwZNtQxMKg3WmPK1vmhZu9WcoX69iwWjVz+jvKTsF1emzsv3eoPcDr6ykA3qP2bsCQE7CwqfNtAVzsmg==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.4.0", - "@types/json-schema": "^7.0.12", - "@types/semver": "^7.5.0", - "@typescript-eslint/scope-manager": "6.21.0", - "@typescript-eslint/types": "6.21.0", - "@typescript-eslint/typescript-estree": "6.21.0", - "semver": "^7.5.4" - }, - "engines": { - "node": "^16.0.0 || >=18.0.0" + "@vitest/pretty-format": "4.0.10", + "tinyrainbow": "^3.0.3" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^7.0.0 || ^8.0.0" + "url": "https://opencollective.com/vitest" } }, - "packages/ai/node_modules/@typescript-eslint/visitor-keys": { - "version": "6.21.0", + "packages/ai/node_modules/chai": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.1.tgz", + "integrity": "sha512-p4Z49OGG5W/WBCPSS/dH3jQ73kD6tiMmUM+bckNK6Jr5JHMG3k9bg/BvKR8lKmtVBKmOiuVaV2ws8s9oSbwysg==", "dev": true, "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "6.21.0", - "eslint-visitor-keys": "^3.4.1" - }, "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "node": ">=18" } }, - "packages/ai/node_modules/ajv": { - "version": "6.12.6", + "packages/ai/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" + "engines": { + "node": ">=12" }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" + "url": "https://github.com/sponsors/jonschlinkert" } }, - "packages/ai/node_modules/ansi-regex": { - "version": "5.0.1", + "packages/ai/node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } + "license": "MIT" }, - "packages/ai/node_modules/ansi-styles": { - "version": "4.3.0", + "packages/ai/node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", "dev": true, "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "node": ">=14.0.0" } }, - "packages/ai/node_modules/brace-expansion": { - "version": "2.0.2", + "packages/ai/node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } + "license": "MIT" }, - "packages/ai/node_modules/chalk": { - "version": "4.1.2", + "packages/ai/node_modules/vitest": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.10.tgz", + "integrity": "sha512-2Fqty3MM9CDwOVet/jaQalYlbcjATZwPYGcqpiYQqgQ/dLC7GuHdISKgTYIVF/kaishKxLzleKWWfbSDklyIKg==", "dev": true, "license": "MIT", "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "packages/ai/node_modules/eslint": { - "version": "8.57.1", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", - "@eslint-community/regexpp": "^4.6.1", - "@eslint/eslintrc": "^2.1.4", - "@eslint/js": "8.57.1", - "@humanwhocodes/config-array": "^0.13.0", - "@humanwhocodes/module-importer": "^1.0.1", - "@nodelib/fs.walk": "^1.2.8", - "@ungap/structured-clone": "^1.2.0", - "ajv": "^6.12.4", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.2", - "debug": "^4.3.2", - "doctrine": "^3.0.0", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^7.2.2", - "eslint-visitor-keys": "^3.4.3", - "espree": "^9.6.1", - "esquery": "^1.4.2", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^6.0.1", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "globals": "^13.19.0", - "graphemer": "^1.4.0", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "is-path-inside": "^3.0.3", - "js-yaml": "^4.1.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "levn": "^0.4.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3", - "strip-ansi": "^6.0.1", - "text-table": "^0.2.0" + "@vitest/expect": "4.0.10", + "@vitest/mocker": "4.0.10", + "@vitest/pretty-format": "4.0.10", + "@vitest/runner": "4.0.10", + "@vitest/snapshot": "4.0.10", + "@vitest/spy": "4.0.10", + "@vitest/utils": "4.0.10", + "debug": "^4.4.3", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" }, "bin": { - "eslint": "bin/eslint.js" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "packages/ai/node_modules/eslint-scope": { - "version": "7.2.2", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "packages/ai/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "packages/ai/node_modules/espree": { - "version": "9.6.1", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.9.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^3.4.1" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "packages/ai/node_modules/file-entry-cache": { - "version": "6.0.1", - "dev": true, - "license": "MIT", - "dependencies": { - "flat-cache": "^3.0.4" - }, - "engines": { - "node": "^10.12.0 || >=12.0.0" - } - }, - "packages/ai/node_modules/find-up": { - "version": "5.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "packages/ai/node_modules/flat-cache": { - "version": "3.2.0", - "dev": true, - "license": "MIT", - "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.3", - "rimraf": "^3.0.2" - }, - "engines": { - "node": "^10.12.0 || >=12.0.0" - } - }, - "packages/ai/node_modules/globals": { - "version": "13.24.0", - "dev": true, - "license": "MIT", - "dependencies": { - "type-fest": "^0.20.2" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "packages/ai/node_modules/globby": { - "version": "11.1.0", - "dev": true, - "license": "MIT", - "dependencies": { - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.2.9", - "ignore": "^5.2.0", - "merge2": "^1.4.1", - "slash": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "packages/ai/node_modules/json-schema-traverse": { - "version": "0.4.1", - "dev": true, - "license": "MIT" - }, - "packages/ai/node_modules/locate-path": { - "version": "6.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "packages/ai/node_modules/p-limit": { - "version": "3.1.0", - "dev": true, - "license": "MIT", - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "packages/ai/node_modules/p-locate": { - "version": "5.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^3.0.2" + "vitest": "vitest.mjs" }, "engines": { - "node": ">=10" + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "packages/ai/node_modules/path-exists": { - "version": "4.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "packages/ai/node_modules/strip-ansi": { - "version": "6.0.1", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "packages/ai/node_modules/superdoc": { - "version": "0.28.6", - "dev": true, - "license": "AGPL-3.0", - "dependencies": { - "buffer-crc32": "^1.0.0", - "eventemitter3": "^5.0.1", - "jsdom": "^25.0.1", - "naive-ui": "^2.43.1", - "pinia": "^2.1.7", - "rollup-plugin-copy": "^3.5.0", - "uuid": "^9.0.1", - "vue": "^3.5.20", - "y-websocket": "^3.0.0" - }, - "peerDependencies": { - "@hocuspocus/provider": "^2.13.6", - "pdfjs-dist": ">=4.3.136 <=4.6.82", - "y-prosemirror": "^1.3.7", - "yjs": "13.6.19" - } - }, - "packages/ai/node_modules/ts-api-utils": { - "version": "1.4.3", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16" + "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "typescript": ">=4.2.0" - } - }, - "packages/ai/node_modules/type-fest": { - "version": "0.20.2", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "packages/ai/node_modules/uuid": { - "version": "9.0.1", - "dev": true, - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, - "packages/ai/node_modules/yocto-queue": { - "version": "0.1.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.10", + "@vitest/browser-preview": "4.0.10", + "@vitest/browser-webdriverio": "4.0.10", + "@vitest/ui": "4.0.10", + "happy-dom": "*", + "jsdom": "*" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } } }, "packages/collaboration-yjs": { diff --git a/packages/ai/README.md b/packages/ai/README.md index 51805b8e0..221992bbe 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -4,14 +4,12 @@ ## Features -- 🤖 **Multiple AI Providers**: Built-in support for OpenAI, Anthropic Claude, and custom HTTP endpoints -- 🔍 **Smart Content Finding**: Natural language search across documents -- ✍️ **Intelligent Editing**: AI-powered content replacement, suggestions, and generation -- 💬 **Comment Integration**: Automatically insert AI-generated comments -- 📝 **Track Changes**: AI suggestions with full revision history -- 🎨 **Content Highlighting**: Smart text highlighting based on queries -- 🌊 **Streaming Support**: Real-time AI responses with streaming -- 📦 **TypeScript First**: Full type safety and excellent IDE support +- **AI Actions**: High-level API for common document operations (find, replace, insert, comment) +- **AI Builder**: Low-level primitives for custom AI workflows +- **Anthropic Support**: Built-in Anthropic Claude integration +- **Comment Integration**: Automatically insert AI-generated comments +- **Track Changes**: AI suggestions with full revision history +- **TypeScript First**: Full type safety and excellent IDE support ## Installation @@ -21,19 +19,21 @@ npm install @superdoc-dev/ai ## Quick Start +### Using AI Actions (High-Level API) + ```typescript import { AIActions } from '@superdoc-dev/ai'; -// Initialize with OpenAI +// Initialize with Anthropic const ai = new AIActions(superdoc, { user: { displayName: 'AI Assistant', userId: 'ai-bot-001', }, provider: { - type: 'openai', - apiKey: process.env.OPENAI_API_KEY, - model: 'gpt-4', + type: 'anthropic', + apiKey: process.env.ANTHROPIC_API_KEY, + model: 'claude-sonnet-4-5', }, onReady: ({ aiActions }) => { console.log('AI is ready!'); @@ -56,194 +56,58 @@ await ai.action.insertTrackedChange('improve the introduction'); await ai.action.insertContent('write a conclusion paragraph'); ``` -## API Reference - -### AIActions Class - -The main class for AI integration. - -#### Constructor - -```typescript -new AIActions(superdoc: SuperDocInstance, options: AIActionsOptions) -``` - -**Options:** - -- `user` (required): User/bot information - - `displayName`: Display name for AI-generated changes - - `userId?`: Optional user identifier - - `profileUrl?`: Optional profile image URL -- `provider` (required): AI provider configuration or instance -- `systemPrompt?`: Custom system prompt for AI context -- `enableLogging?`: Enable debug logging (default: false) -- Callbacks: - - `onReady?`: Called when AI is initialized - - `onStreamingStart?`: Called when streaming begins - - `onStreamingPartialResult?`: Called for each streaming chunk - - `onStreamingEnd?`: Called when streaming completes - - `onError?`: Called when an error occurs - -#### Methods - -##### `waitUntilReady()` - -Waits for AI initialization to complete. +### Using AI Builder (Low-Level API) ```typescript -await ai.waitUntilReady(); -``` - -##### `getIsReady()` +import { executeTool, anthropicTools } from '@superdoc-dev/ai'; +import Anthropic from '@anthropic-ai/sdk'; -Checks if AI is ready. +// Get tool definitions +const tools = anthropicTools(editor.extensionManager.extensions); -```typescript -const ready = ai.getIsReady(); // boolean -``` - -##### `getCompletion(prompt, options?)` - -Get a complete AI response. - -```typescript -const response = await ai.getCompletion('Summarize this document', { - temperature: 0.7, - maxTokens: 500, +// Use with Anthropic SDK +const anthropic = new Anthropic({ apiKey: '...' }); +const response = await anthropic.beta.messages.create({ + model: 'claude-sonnet-4-5', + betas: ['structured-outputs-2025-11-13'], + tools, + messages: [{ role: 'user', content: 'Add a paragraph saying hello' }], }); -``` -##### `streamCompletion(prompt, options?)` - -Stream AI responses in real-time. - -```typescript -const result = await ai.streamCompletion('Generate introduction'); +// Execute tool calls +for (const toolUse of response.content.filter((c) => c.type === 'tool_use')) { + await executeTool(toolUse.name, toolUse.input, editor); +} ``` -##### `getDocumentContext()` - -Get current document text. - -```typescript -const context = ai.getDocumentContext(); -``` +## API Overview ### AI Actions -All actions are available via `ai.action.*`. - -#### `find(query)` - -Find the first occurrence of content matching the query. - -```typescript -const result = await ai.action.find('GDPR compliance section'); -// Returns: { success: boolean, results: FoundMatch[] } -``` - -#### `findAll(query)` - -Find all occurrences of content matching the query. - -```typescript -const result = await ai.action.findAll('privacy policy'); -``` - -#### `highlight(query, color?)` - -Find and highlight content. - -```typescript -await ai.action.highlight('important terms', '#FFFF00'); -``` - -#### `replace(instruction)` - -Replace the first occurrence based on instruction. - -```typescript -await ai.action.replace('change "data" to "information" in the first paragraph'); -``` - -#### `replaceAll(instruction)` - -Replace all occurrences based on instruction. - -```typescript -await ai.action.replaceAll('update dates to 2025'); -``` - -#### `insertTrackedChange(instruction)` - -Insert a single tracked change. - -```typescript -await ai.action.insertTrackedChange('improve clarity of terms and conditions'); -``` - -#### `insertTrackedChanges(instruction)` - -Insert multiple tracked changes. - -```typescript -await ai.action.insertTrackedChanges('fix all grammatical errors'); -``` - -#### `insertComment(instruction)` - -Insert a single comment. - -```typescript -await ai.action.insertComment('suggest improvements to introduction'); -``` - -#### `insertComments(instruction)` - -Insert multiple comments. - -```typescript -await ai.action.insertComments('review all legal terms'); -``` - -#### `summarize(instruction)` - -Generate a summary. +High-level API for common document operations: -```typescript -const result = await ai.action.summarize('create executive summary'); -// onStreamingPartialResult receives partial updates when the provider allows streaming. -``` +- `find(query)` - Find content matching a query +- `findAll(query)` - Find all occurrences +- `highlight(query, color?)` - Find and highlight +- `replace(instruction)` - Replace first occurrence +- `replaceAll(instruction)` - Replace all occurrences +- `insertTrackedChange(instruction)` - Insert single tracked change +- `insertTrackedChanges(instruction)` - Insert multiple tracked changes +- `insertComment(instruction)` - Insert single comment +- `insertComments(instruction)` - Insert multiple comments +- `summarize(instruction)` - Generate summary +- `insertContent(instruction)` - Generate and insert content -#### `insertContent(instruction)` +### AI Builder -Generate and insert new content. +Low-level primitives for custom workflows: -```typescript -await ai.action.insertContent('write a conclusion paragraph'); -``` +- `anthropicTools(extensions, options?)` - Generate Anthropic tool definitions +- `executeTool(toolName, params, editor)` - Execute a tool call +- `generateContentSchema(extensions, options?)` - Generate content schema +- Core types and utilities -When the provider configuration leaves `streamResults` enabled (default), generated content streams into the document incrementally instead of waiting for the full response. - -## AI Providers - -### OpenAI - -```typescript -const ai = new AIActions(superdoc, { - user: { displayName: 'AI' }, - provider: { - type: 'openai', - apiKey: 'sk-...', - model: 'gpt-4', - baseURL: 'https://api.openai.com/v1', // optional - organizationId: 'org-...', // optional - temperature: 0.7, // optional - maxTokens: 2000, // optional - streamResults: false, // optional (applies to AI insert/summarize actions; default true) - }, -}); -``` +## Provider Configuration ### Anthropic Claude @@ -253,76 +117,32 @@ const ai = new AIActions(superdoc, { provider: { type: 'anthropic', apiKey: 'sk-ant-...', - model: 'claude-3-opus-20240229', - apiVersion: '2023-06-01', // optional - baseURL: 'https://api.anthropic.com', // optional + model: 'claude-sonnet-4-5', temperature: 0.7, // optional maxTokens: 2000, // optional - streamResults: false, // optional (applies to AI insert/summarize actions; default true) - }, -}); -``` - -### Custom HTTP Provider - -```typescript -const ai = new AIActions(superdoc, { - user: { displayName: 'AI' }, - provider: { - type: 'http', - url: 'https://your-ai-api.com/complete', - streamUrl: 'https://your-ai-api.com/stream', // optional - headers: { - Authorization: 'Bearer token', - 'X-Custom-Header': 'value', - }, - method: 'POST', // default - streamResults: true, // optional (used by insertContent/summarize; default true) - buildRequestBody: (context) => ({ - messages: context.messages, - stream: context.stream, - // custom fields - }), - parseCompletion: (payload) => { - // Extract text from response - return payload.result; - }, }, }); ``` -### Custom Provider Instance +## Advanced Usage -Implement the `AIProvider` interface: +### Custom System Prompt ```typescript -const customProvider: AIProvider = { - streamResults: true, - async *streamCompletion(messages, options) { - // Yield chunks - yield 'chunk1'; - yield 'chunk2'; - }, - async getCompletion(messages, options) { - // Return complete response - return 'response'; - }, -}; - const ai = new AIActions(superdoc, { - user: { displayName: 'AI' }, - provider: customProvider, + user: { displayName: 'Legal AI' }, + provider: { type: 'anthropic', apiKey: '...', model: 'claude-sonnet-4-5' }, + systemPrompt: `You are a legal document assistant. + Focus on accuracy, clarity, and compliance.`, }); ``` -## Advanced Usage - ### With Callbacks ```typescript const ai = new AIActions(superdoc, { user: { displayName: 'AI' }, - provider: { type: 'openai', apiKey: '...', model: 'gpt-4' }, + provider: { type: 'anthropic', apiKey: '...', model: 'claude-sonnet-4-5' }, enableLogging: true, onReady: () => console.log('Ready!'), onStreamingStart: () => console.log('Streaming started'), @@ -338,48 +158,6 @@ const ai = new AIActions(superdoc, { }); ``` -### Custom System Prompt - -```typescript -const ai = new AIActions(superdoc, { - user: { displayName: 'Legal AI' }, - provider: { type: 'openai', apiKey: '...', model: 'gpt-4' }, - systemPrompt: `You are a legal document assistant. - Focus on accuracy, clarity, and compliance. - Always cite relevant regulations when applicable.`, -}); -``` - -### Abort Streaming - -```typescript -const controller = new AbortController(); - -ai.streamCompletion('Long task', { - signal: controller.signal, -}); - -// Later... -controller.abort(); -``` - -### Provider-Specific Options - -```typescript -await ai.getCompletion('prompt', { - temperature: 0.5, - maxTokens: 1000, - stop: ['\n\n'], - providerOptions: { - // OpenAI specific - top_p: 0.9, - frequency_penalty: 0.5, - // or Anthropic specific - top_k: 40, - }, -}); -``` - ## Error Handling ```typescript @@ -395,12 +173,6 @@ try { } ``` -## Testing - -```bash -npm test -``` - ## License AGPL-3.0 - see [LICENSE](../../LICENSE) for details. @@ -410,7 +182,6 @@ AGPL-3.0 - see [LICENSE](../../LICENSE) for details. - 📖 [Documentation](https://superdoc.dev/docs/ai) - 💬 [Discord Community](https://discord.gg/superdoc) - 🐛 [Issue Tracker](https://github.com/harbour-enterprises/superdoc/issues) -- 📧 [Email Support](mailto:support@superdoc.dev) ## Changelog diff --git a/packages/ai/package.json b/packages/ai/package.json index 539bbadd7..a8d47f87e 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -2,11 +2,18 @@ "name": "@superdoc-dev/ai", "version": "0.1.4", "description": "AI integration package for SuperDoc", + "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, "scripts": { - "build": "tsc", - "dev": "tsc --watch", + "build": "tsup src/index.ts --format esm --dts", + "dev": "tsup src/index.ts --format esm --dts --watch", "test": "vitest run", "lint": "eslint src --ext .ts", "prepublishOnly": "npm run build" @@ -14,30 +21,35 @@ "keywords": [ "superdoc", "ai", + "ai-builder", + "ai-actions", "document", "collaboration", "llm", "openai", - "anthropic" + "anthropic", + "tools", + "structured-outputs" ], "license": "AGPL-3.0", "peerDependencies": { - "superdoc": "*", "prosemirror-model": "^1.25.2", "prosemirror-state": "^1.4.3", - "prosemirror-view": "^1.33.8" + "prosemirror-view": "^1.33.8", + "superdoc": "*" }, "devDependencies": { - "superdoc": "^0.28.0", - "prosemirror-model": "^1.25.2", - "prosemirror-state": "^1.4.3", - "prosemirror-view": "^1.33.8", - "@types/node": "^20.0.0", - "typescript": "^5.0.0", - "eslint": "^8.0.0", - "@typescript-eslint/eslint-plugin": "^6.0.0", - "@typescript-eslint/parser": "^6.0.0", - "vitest": "^3.2.4" + "@types/node": "^24.10.1", + "@typescript-eslint/eslint-plugin": "^8.47.0", + "@typescript-eslint/parser": "^8.47.0", + "eslint": "^9.39.1", + "prosemirror-model": "^1.25.4", + "prosemirror-state": "^1.4.4", + "prosemirror-view": "^1.41.3", + "superdoc": "^0.30.0-next.10", + "tsup": "^8.5.1", + "typescript": "^5.9.3", + "vitest": "^4.0.10" }, "files": [ "dist", diff --git a/packages/ai/src/ai-builder/README.md b/packages/ai/src/ai-builder/README.md new file mode 100644 index 000000000..ae5a5d1e7 --- /dev/null +++ b/packages/ai/src/ai-builder/README.md @@ -0,0 +1,193 @@ +# SuperDoc AI Builder + +Low-level primitives for building custom AI workflows with SuperDoc. + +## Overview + +AI Builder provides the foundational components for creating AI-powered document editing experiences. Unlike AI Actions which offers pre-built operations, AI Builder gives you the tools to build custom workflows tailored to your specific needs. + +**Alpha Status:** Currently supports Anthropic Claude only. + +## Architecture + +``` +ai-builder/ +├── types.ts # Core type definitions +├── executor.ts # Tool execution primitive (executeTool) +├── tools/ # Core tool implementations +│ ├── insertContent.ts +│ └── replaceContent.ts +├── providers/ # Provider-specific tool schemas +│ └── anthropic.ts # Anthropic Claude support +└── schema-generator/ # Schema generation from extensions + └── from-extensions.ts +``` + +## Quick Start + +```typescript +import { executeTool, anthropicTools } from '@superdoc-dev/ai'; +import Anthropic from '@anthropic-ai/sdk'; + +// Get tool definitions +const tools = anthropicTools(editor.extensionManager.extensions); + +// Use with Anthropic SDK +const anthropic = new Anthropic({ apiKey: '...' }); +const response = await anthropic.beta.messages.create({ + model: 'claude-sonnet-4-5', + betas: ['structured-outputs-2025-11-13'], + tools, + messages: [{ role: 'user', content: 'Add a paragraph saying hello' }], +}); + +// Execute tool calls +for (const toolUse of response.content.filter((c) => c.type === 'tool_use')) { + await executeTool(toolUse.name, toolUse.input, editor); +} +``` + +## Core Concepts + +### Tools + +Tools are the basic operations that AI can perform on documents: + +- **insertContent** - Insert content at selection, documentStart, or documentEnd +- **replaceContent** - Replace content in a specific range + +Each tool executes with type-safe parameters and returns a structured result. + +### Tool Execution + +```typescript +import { executeTool } from '@superdoc-dev/ai'; + +// Execute a single tool +const result = await executeTool( + 'insertContent', + { + position: 'selection', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Hello' }] }], + }, + editor, +); + +if (result.success) { + console.log('Content inserted successfully'); +} +``` + +### Schema Generation + +Generate tool definitions compatible with Anthropic Claude: + +```typescript +import { anthropicTools } from '@superdoc-dev/ai'; + +// Get tool definitions from extensions +const tools = anthropicTools(editor.extensionManager.extensions, { + excludedNodes: ['bulletList', 'orderedList'], + excludedMarks: [], + strict: true, +}); +``` + +## Available Tools + +### insertContent + +Insert content at a specific position. + +**Parameters:** + +- `position`: 'selection' | 'documentStart' | 'documentEnd' +- `content`: Array of ProseMirror nodes + +```typescript +await executeTool( + 'insertContent', + { + position: 'selection', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'Hello World' }], + }, + ], + }, + editor, +); +``` + +### replaceContent + +Replace content in a specific range. + +**Parameters:** + +- `from`: Start position (number) +- `to`: End position (number) +- `content`: Array of ProseMirror nodes + +```typescript +await executeTool( + 'replaceContent', + { + from: 0, + to: 100, + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'New content' }], + }, + ], + }, + editor, +); +``` + +## Anthropic Integration + +AI Builder is optimized for Anthropic Claude with structured outputs: + +```typescript +import { anthropicTools, executeTool } from '@superdoc-dev/ai'; +import Anthropic from '@anthropic-ai/sdk'; + +const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY }); +const tools = anthropicTools(editor.extensionManager.extensions); + +async function processUserRequest(userMessage: string) { + const response = await anthropic.beta.messages.create({ + model: 'claude-sonnet-4-5', + betas: ['structured-outputs-2025-11-13'], + tools, + messages: [{ role: 'user', content: userMessage }], + }); + + for (const block of response.content) { + if (block.type === 'tool_use') { + const result = await executeTool(block.name, block.input, editor); + console.log(`Tool ${block.name}:`, result.success ? 'Success' : 'Failed'); + } + } +} + +await processUserRequest('Add a paragraph saying "Hello World"'); +``` + +## Comparison with AI Actions + +| Feature | AI Builder | AI Actions | +| --------------- | -------------------- | -------------------- | +| **Use Case** | Custom workflows | Pre-built operations | +| **Complexity** | Low-level primitives | High-level methods | +| **Flexibility** | Maximum | Fixed operations | +| **Setup** | More code required | Minimal setup | +| **Best For** | Advanced use cases | Quick integration | + +## Related + +- [AI Actions](../ai-actions.ts) - High-level AI operations +- [SuperDoc Docs](https://docs.superdoc.dev/ai/ai-builder/overview) diff --git a/packages/ai/src/ai-builder/content-schema.ts b/packages/ai/src/ai-builder/content-schema.ts new file mode 100644 index 000000000..140eba207 --- /dev/null +++ b/packages/ai/src/ai-builder/content-schema.ts @@ -0,0 +1,130 @@ +/** + * Hardcoded content schema for SuperDoc AI + * + * JSON Schema that describes SuperDoc's document structure for LLMs. + * + * Structure: + * - Document = array of paragraphs + * - Paragraph = contains text nodes with optional marks (bold, italic, etc.) + * - Supports lists via numberingProperties attribute + * - Supports headings via styleId attribute + */ + +/** + * The content schema for SuperDoc documents + * + * This is a hardcoded schema. Future versions may generate this dynamically. + */ +export const CONTENT_SCHEMA = { + type: 'array', + description: 'Array of paragraph nodes that make up the document content', + items: { + additionalProperties: false, + type: 'object', + required: ['type', 'content'], + properties: { + type: { + type: 'string', + const: 'paragraph', + description: 'Paragraph node. For headings, use styleId attribute (e.g., "Heading1"). For lists, use numberingProperties.' + }, + content: { + type: 'array', + description: 'Array of text nodes and line breaks', + items: { + oneOf: [ + { + type: 'object', + required: ['type', 'text'], + properties: { + type: { + type: 'string', + const: 'text', + description: 'Text content node' + }, + text: { + type: 'string', + description: 'The actual text content' + }, + marks: { + type: 'array', + description: 'Optional formatting marks (bold, italic, etc.)', + items: { + type: 'object', + required: ['type'], + properties: { + type: { + type: 'string', + enum: ['bold', 'italic', 'underline', 'strike', 'link', 'highlight', 'textStyle'], + description: 'Type of formatting mark' + }, + attrs: { + type: 'object', + description: 'Mark attributes (e.g., href for links, color for highlights)' + } + } + } + } + } + }, + { + type: 'object', + required: ['type'], + properties: { + type: { + type: 'string', + const: 'hardBreak', + description: 'Line break (Shift+Enter)' + } + } + } + ] + } + }, + attrs: { + type: 'object', + description: 'Paragraph attributes for styling and structure', + properties: { + styleId: { + type: 'string', + description: 'Word style ID for headings (e.g., "Heading1", "Heading2", etc.) or other styles' + }, + textAlign: { + type: 'string', + enum: ['left', 'center', 'right', 'justify'], + description: 'Text alignment' + }, + lineHeight: { + oneOf: [ + { type: 'string' }, + { type: 'number' } + ], + description: 'Line height (e.g., "1.5" or 1.5)' + }, + textIndent: { + oneOf: [ + { type: 'string' }, + { type: 'number' } + ], + description: 'First-line indentation' + }, + numberingProperties: { + type: 'object', + description: 'List properties. Use numId=1 for bullet lists, numId=2 for numbered lists', + required: ['numId', 'ilvl'], + properties: { + numId: { + type: 'number', + description: 'Numbering definition ID: 1 for bullets, 2 for numbered lists' + }, + ilvl: { + type: 'number', + description: 'Indentation level (0-8, where 0 is top level)' + } + } + } + } + } + } + } +} as const; diff --git a/packages/ai/src/ai-builder/executor.ts b/packages/ai/src/ai-builder/executor.ts new file mode 100644 index 000000000..b4f18c6e1 --- /dev/null +++ b/packages/ai/src/ai-builder/executor.ts @@ -0,0 +1,82 @@ +import type { Editor } from '../types'; +import type { ToolResult, ExecuteToolOptions } from './types'; +import { getTool } from './tools'; + +/** + * Execute a tool by name with given parameters. + * This is the primary way to run AI-generated tool calls. + * + * @param toolName - Name of the tool to execute + * @param params - Parameters to pass to the tool + * @param editor - SuperDoc editor instance + * @param options - Optional execution options + * @returns Tool execution result + * + * @example + * ```typescript + * const result = await executeTool('insertContent', { + * position: 'selection', + * content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Hello' }] }] + * }, editor); + * + * if (result.success) { + * console.log('Content inserted successfully'); + * } + * ``` + */ +export async function executeTool( + toolName: string, + params: any, + editor: Editor, + options?: ExecuteToolOptions +): Promise { + try { + // Check for cancellation + if (options?.signal?.aborted) { + return { + success: false, + error: 'Tool execution was cancelled', + docChanged: false + }; + } + + // Get the tool + const tool = getTool(toolName); + if (!tool) { + return { + success: false, + error: `Unknown tool: ${toolName}`, + docChanged: false + }; + } + + // Validate params if requested + if (options?.validate) { + // Basic validation - could be enhanced with JSON Schema validation + if (params === undefined || params === null) { + return { + success: false, + error: 'Tool parameters are required', + docChanged: false + }; + } + } + + // Execute the tool + const result = await tool.execute(editor, params); + + // Report progress if callback provided + if (options?.onProgress) { + options.onProgress(100); + } + + return result; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error during tool execution', + docChanged: false + }; + } +} + diff --git a/packages/ai/src/ai-builder/index.ts b/packages/ai/src/ai-builder/index.ts new file mode 100644 index 000000000..5eba25e41 --- /dev/null +++ b/packages/ai/src/ai-builder/index.ts @@ -0,0 +1,51 @@ +/** + * SuperDoc AI Builder - Low-level primitives for building custom AI workflows + * + * @module ai-builder + * + * AI Builder provides the foundational components for creating AI-powered + * document editing experiences. It offers: + * + * - **Tools**: Core document operations (insert, replace) + * - **Executor**: Primitive for running tool calls (executeTool) + * - **Provider**: Anthropic tool schemas + * - **Schema Generator**: Generate schemas from SuperDoc extensions + * + * @example + * ```typescript + * import { executeTool, anthropicTools } from '@superdoc-dev/ai/ai-builder'; + * import Anthropic from '@anthropic-ai/sdk'; + * + * // Get tool definitions + * const tools = anthropicTools(editor.extensionManager.extensions); + * + * // Use with Anthropic SDK + * const anthropic = new Anthropic({ apiKey: '...' }); + * const response = await anthropic.beta.messages.create({ + * model: 'claude-sonnet-4-5', + * tools, + * messages: [...] + * }); + * + * // Execute tool calls + * for (const toolUse of response.content.filter(c => c.type === 'tool_use')) { + * await executeTool(toolUse.name, toolUse.input, editor); + * } + * ``` + */ + +// Core types +export type * from './types'; + +// Tools +export * from './tools/index'; + +// Executor +export { executeTool } from './executor'; + +// Providers +export { anthropicTools } from './providers/anthropic'; +export * from './providers/index'; + +// Content schema +export { CONTENT_SCHEMA } from './content-schema'; diff --git a/packages/ai/src/ai-builder/providers/anthropic.ts b/packages/ai/src/ai-builder/providers/anthropic.ts new file mode 100644 index 000000000..9eabbbf57 --- /dev/null +++ b/packages/ai/src/ai-builder/providers/anthropic.ts @@ -0,0 +1,94 @@ +import type { AnthropicTool, ToolDefinitionsOptions } from '../types'; +import { CONTENT_SCHEMA } from '../content-schema'; + +/** + * Generate Anthropic-compatible tool definitions from SuperDoc extensions. + * + * Returns an array of tool objects compatible with Anthropic's Messages API + * and structured outputs (strict mode). + * + * @param extensions - Array of SuperDoc extensions + * @param options - Tool definition options + * @returns Array of Anthropic tool definitions + * + * @example + * ```typescript + * import { anthropicTools } from '@superdoc-dev/ai/ai-builder/providers'; + * import Anthropic from '@anthropic-ai/sdk'; + * + * const tools = anthropicTools(editor.extensionManager.extensions, { + * excludedNodes: ['table'], + * excludedMarks: ['strike', 'underline'], + * strict: true + * }); + * + * const anthropic = new Anthropic({ apiKey: '...' }); + * const response = await anthropic.beta.messages.create({ + * model: 'claude-sonnet-4-5', + * betas: ['structured-outputs-2025-11-13'], + * tools, + * messages: [...] + * }); + * ``` + */ +export function anthropicTools( + extensions: any[] = [], + options?: ToolDefinitionsOptions +): AnthropicTool[] { + const { + enabledTools + } = options || {}; + + // Define all available tools + const allTools: AnthropicTool[] = [ + { + name: 'insertContent', + description: 'Insert new content into the document. Position can be "selection" (at cursor), "documentStart", or "documentEnd". Content should be an array of paragraph blocks.', + input_schema: { + type: 'object', + properties: { + position: { + type: 'string', + enum: ['selection', 'documentStart', 'documentEnd'], + description: 'Where to insert the content' + }, + content: CONTENT_SCHEMA + }, + required: ['position', 'content'], + additionalProperties: false + } + }, + { + name: 'replaceContent', + description: 'Replace content in a specific range of the document. Specify from and to positions (character offsets) and provide an array of paragraph blocks to replace with.', + input_schema: { + type: 'object', + properties: { + from: { + type: 'integer', + description: 'Start position (character offset)' + }, + to: { + type: 'integer', + description: 'End position (character offset)' + }, + content: CONTENT_SCHEMA + }, + required: ['from', 'to', 'content'], + additionalProperties: false + } + } + ]; + + // Filter tools if enabledTools is specified + if (enabledTools && enabledTools.length > 0) { + return allTools.filter(tool => enabledTools.includes(tool.name)); + } + + return allTools; +} + +/** + * Alias for anthropicTools for consistency + */ +export const toolDefinitions = anthropicTools; diff --git a/packages/ai/src/ai-builder/providers/index.ts b/packages/ai/src/ai-builder/providers/index.ts new file mode 100644 index 000000000..9dec5d384 --- /dev/null +++ b/packages/ai/src/ai-builder/providers/index.ts @@ -0,0 +1 @@ +export { anthropicTools, toolDefinitions as anthropicToolDefinitions } from './anthropic'; \ No newline at end of file diff --git a/packages/ai/src/ai-builder/tools/index.ts b/packages/ai/src/ai-builder/tools/index.ts new file mode 100644 index 000000000..38e6b8944 --- /dev/null +++ b/packages/ai/src/ai-builder/tools/index.ts @@ -0,0 +1,34 @@ +export { readSelection } from './readSelection'; +export { insertContent } from './insertContent'; +export { replaceContent } from './replaceContent'; + +export type { InsertContentParams } from './insertContent'; +export type { ReplaceContentParams } from './replaceContent'; + +import { readSelection } from './readSelection'; +import { insertContent } from './insertContent'; +import { replaceContent } from './replaceContent'; +import type { SuperDocTool } from '../types'; + +/** + * All available SuperDoc AI tools + */ +export const ALL_TOOLS: Record = { + readSelection, + insertContent, + replaceContent +}; + +/** + * Get a tool by name + */ +export function getTool(name: string): SuperDocTool | undefined { + return ALL_TOOLS[name]; +} + +/** + * Get all tool names + */ +export function getToolNames(): string[] { + return Object.keys(ALL_TOOLS); +} diff --git a/packages/ai/src/ai-builder/tools/insertContent.ts b/packages/ai/src/ai-builder/tools/insertContent.ts new file mode 100644 index 000000000..2373c6f75 --- /dev/null +++ b/packages/ai/src/ai-builder/tools/insertContent.ts @@ -0,0 +1,99 @@ +import type { Editor } from '../../types'; +import type { SuperDocTool, ToolResult } from '../types'; + +/** + * Params for insertContent tool + */ +export interface InsertContentParams { + /** Where to insert: 'selection' (at cursor), 'documentStart', or 'documentEnd' */ + position: 'selection' | 'documentStart' | 'documentEnd'; + /** Array of content nodes to insert (ProseMirror JSON format) */ + content: any[]; +} + +/** + * Tool for inserting content at specified positions in the document. + * Supports inserting at cursor position, document start, or document end. + */ +export const insertContent: SuperDocTool = { + name: 'insertContent', + description: 'Insert new content into the document. Position can be "selection" (at cursor), "documentStart", or "documentEnd". Content should be an array of paragraph blocks in ProseMirror JSON format.', + category: 'write', + + async execute(editor: Editor, params: InsertContentParams): Promise { + try { + const { position, content } = params; + + if (!content || !Array.isArray(content)) { + return { + success: false, + error: 'Content must be an array of nodes', + docChanged: false + }; + } + + const { state } = editor; + if (!state) { + return { + success: false, + error: 'Editor state not available', + docChanged: false + }; + } + + let insertPos: number; + switch (position) { + case 'selection': + insertPos = state.selection.from; + break; + case 'documentStart': + insertPos = 0; + break; + case 'documentEnd': + insertPos = state.doc.content.size; + break; + default: + return { + success: false, + error: `Invalid position: ${position}`, + docChanged: false + }; + } + + // Use editor's insertContentAt command + // For single nodes, pass directly; for multiple, wrap in an array + let insertSuccess: boolean; + + if (Array.isArray(content) && content.length === 1) { + // Single node: pass directly + insertSuccess = editor.commands.insertContentAt(insertPos, content[0]); + } else { + // Multiple nodes or array: pass as-is + insertSuccess = editor.commands.insertContentAt(insertPos, content); + } + + const success = insertSuccess; + + if (!success) { + return { + success: false, + error: 'Failed to insert content', + docChanged: false + }; + } + + return { + success: true, + data: { insertedAt: insertPos }, + docChanged: true, + message: `Inserted ${content.length} node(s) at ${position}` + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + docChanged: false + }; + } + } +}; diff --git a/packages/ai/src/ai-builder/tools/readSelection.ts b/packages/ai/src/ai-builder/tools/readSelection.ts new file mode 100644 index 000000000..149fd5d37 --- /dev/null +++ b/packages/ai/src/ai-builder/tools/readSelection.ts @@ -0,0 +1,45 @@ +import type { Editor } from '../../types'; +import type { SuperDocTool, ToolResult } from '../types'; + +/** + * Tool for reading the currently selected content in the document. + * Returns the selection range and the JSON representation of the selected content. + */ +export const readSelection: SuperDocTool = { + name: 'readSelection', + description: 'Read the currently selected content in the document. Returns the selection range (from/to positions) and the JSON representation of the selected content.', + category: 'read', + + async execute(editor: Editor): Promise { + try { + const { state } = editor; + if (!state) { + return { + success: false, + error: 'Editor state not available', + docChanged: false + }; + } + + const { from, to } = state.selection; + const selectedContent = state.doc.cut(from, to); + + return { + success: true, + data: { + from, + to, + content: selectedContent.toJSON() + }, + docChanged: false, + message: `Selection from position ${from} to ${to}` + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + docChanged: false + }; + } + } +}; diff --git a/packages/ai/src/ai-builder/tools/replaceContent.ts b/packages/ai/src/ai-builder/tools/replaceContent.ts new file mode 100644 index 000000000..bf44b1279 --- /dev/null +++ b/packages/ai/src/ai-builder/tools/replaceContent.ts @@ -0,0 +1,107 @@ +import type { Editor } from '../../types'; +import type { SuperDocTool, ToolResult } from '../types'; + +/** + * Params for replaceContent tool + */ +export interface ReplaceContentParams { + /** Start position (character offset) */ + from: number; + /** End position (character offset) */ + to: number; + /** Array of content nodes to replace with (ProseMirror JSON format) */ + content: any[]; +} + +/** + * Tool for replacing content in a specific range of the document. + * Removes content from 'from' to 'to' positions and inserts new content. + */ +export const replaceContent: SuperDocTool = { + name: 'replaceContent', + description: 'Replace content in a specific range of the document. Specify from and to positions (character offsets) and provide an array of paragraph blocks to replace with.', + category: 'write', + + async execute(editor: Editor, params: ReplaceContentParams): Promise { + try { + const { from, to, content } = params; + + if (typeof from !== 'number' || typeof to !== 'number') { + return { + success: false, + error: 'From and to must be numbers', + docChanged: false + }; + } + + if (from < 0 || to < from) { + return { + success: false, + error: 'Invalid range: from must be >= 0 and to must be >= from', + docChanged: false + }; + } + + if (!content || !Array.isArray(content)) { + return { + success: false, + error: 'Content must be an array of nodes', + docChanged: false + }; + } + + const { state } = editor; + if (!state) { + return { + success: false, + error: 'Editor state not available', + docChanged: false + }; + } + + // Clamp positions to valid document range + const docSize = state.doc.content.size; + const validFrom = Math.max(0, Math.min(from, docSize)); + const validTo = Math.max(0, Math.min(to, docSize)); + + // For full document replacement, use setContent + if (validFrom === 0 && validTo === docSize) { + const success = editor.commands.setContent({ type: 'doc', content }); + + return { + success, + data: { replacedRange: { from: validFrom, to: validTo } }, + docChanged: success, + message: success ? 'Replaced entire document' : 'Failed to replace document' + }; + } + + // For partial replacement, use insertContentAt + const success = editor.commands.insertContentAt( + { from: validFrom, to: validTo }, + { type: 'doc', content } + ); + + if (!success) { + return { + success: false, + error: 'Failed to replace content', + docChanged: false + }; + } + + return { + success: true, + data: { replacedRange: { from: validFrom, to: validTo } }, + docChanged: true, + message: `Replaced content from position ${validFrom} to ${validTo}` + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + docChanged: false + }; + } + } +}; diff --git a/packages/ai/src/ai-builder/types.ts b/packages/ai/src/ai-builder/types.ts new file mode 100644 index 000000000..96e62895d --- /dev/null +++ b/packages/ai/src/ai-builder/types.ts @@ -0,0 +1,97 @@ +import type { Editor } from '../types'; + +/** + * Result of executing a tool + */ +export interface ToolResult { + /** Whether the tool executed successfully */ + success: boolean; + /** Data returned by the tool */ + data?: any; + /** Error message if execution failed */ + error?: string; + /** Whether the document was modified */ + docChanged: boolean; + /** Optional message to send back to the AI */ + message?: string; +} + +/** + * Category of tool operation + */ +export type ToolCategory = 'read' | 'write' | 'navigate' | 'analyze'; + +/** + * Core tool interface that all SuperDoc AI tools must implement + */ +export interface SuperDocTool { + /** Unique identifier for the tool */ + name: string; + /** Human-readable description of what the tool does */ + description: string; + /** Category of operation */ + category: ToolCategory; + /** Execute the tool with given parameters */ + execute: (editor: Editor, params: any) => Promise; +} + +/** + * Options for filtering which tools and features to include + */ +export interface ToolDefinitionsOptions { + /** List of tool names to enable (if undefined, all are enabled) */ + enabledTools?: string[]; + /** Node types to exclude (all others from extensions are included) */ + excludedNodes?: string[]; + /** Mark types to exclude (all others from extensions are included) */ + excludedMarks?: string[]; + /** Attribute names to exclude */ + excludedAttrs?: string[]; + /** Whether to use strict mode (for providers that support it) */ + strict?: boolean; +} + +/** + * Options for tool execution + */ +export interface ExecuteToolOptions { + /** Whether to validate params before execution */ + validate?: boolean; + /** Callback for progress updates during execution */ + onProgress?: (progress: number) => void; + /** Abort signal for cancellation */ + signal?: AbortSignal; +} + +/** + * Generic tool schema format (provider-agnostic) + */ +export interface GenericToolSchema { + name: string; + description: string; + parameters: { + type: 'object'; + properties: Record; + required?: string[]; + additionalProperties?: boolean; + }; +} + +/** + * Anthropic-specific tool format + */ +export interface AnthropicTool { + name: string; + description: string; + input_schema: { + type: 'object'; + properties: Record; + required?: string[]; + additionalProperties?: boolean; + }; +} + +/** + * Union of all provider-specific tool formats + */ +export type ProviderToolDefinition = AnthropicTool | GenericToolSchema; diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index a7807514a..5309ded6d 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -1,7 +1,16 @@ +// AI Actions - High-level AI operations export { AIActions } from './ai-actions'; export { AIActionsService } from './ai-actions-service'; export { EditorAdapter } from './editor-adapter'; +// AI Builder - Low-level primitives for custom AI workflows +export * as AIBuilder from './ai-builder/index'; +export { + executeTool, + anthropicTools +} from './ai-builder/index'; + +// Shared types export * from './types'; export * from './utils'; From 54269d63ee366dc304be100a2e7ce98b28466a52 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Wed, 19 Nov 2025 10:55:46 -0300 Subject: [PATCH 2/4] feat: add searchDocument tool for text searching in documents --- .../ai/src/ai-builder/providers/anthropic.ts | 27 ++++ packages/ai/src/ai-builder/tools/index.ts | 6 +- .../ai/src/ai-builder/tools/searchDocument.ts | 138 ++++++++++++++++++ 3 files changed, 170 insertions(+), 1 deletion(-) create mode 100644 packages/ai/src/ai-builder/tools/searchDocument.ts diff --git a/packages/ai/src/ai-builder/providers/anthropic.ts b/packages/ai/src/ai-builder/providers/anthropic.ts index 9eabbbf57..9dd051162 100644 --- a/packages/ai/src/ai-builder/providers/anthropic.ts +++ b/packages/ai/src/ai-builder/providers/anthropic.ts @@ -41,6 +41,33 @@ export function anthropicTools( // Define all available tools const allTools: AnthropicTool[] = [ + { + name: 'searchDocument', + description: 'Search for text or patterns in the document. Returns matches with their positions (from/to character offsets). Use this before replaceContent to find exact positions.', + input_schema: { + type: 'object', + properties: { + query: { + type: 'string', + description: 'The text or pattern to search for' + }, + caseSensitive: { + type: 'boolean', + description: 'Whether the search should be case-sensitive (default: false)' + }, + regex: { + type: 'boolean', + description: 'Whether to treat query as a regular expression (default: false)' + }, + findAll: { + type: 'boolean', + description: 'Whether to return all matches or just the first one (default: true)' + } + }, + required: ['query'], + additionalProperties: false + } + }, { name: 'insertContent', description: 'Insert new content into the document. Position can be "selection" (at cursor), "documentStart", or "documentEnd". Content should be an array of paragraph blocks.', diff --git a/packages/ai/src/ai-builder/tools/index.ts b/packages/ai/src/ai-builder/tools/index.ts index 38e6b8944..9e52d9986 100644 --- a/packages/ai/src/ai-builder/tools/index.ts +++ b/packages/ai/src/ai-builder/tools/index.ts @@ -1,13 +1,16 @@ export { readSelection } from './readSelection'; export { insertContent } from './insertContent'; export { replaceContent } from './replaceContent'; +export { searchDocument } from './searchDocument'; export type { InsertContentParams } from './insertContent'; export type { ReplaceContentParams } from './replaceContent'; +export type { SearchDocumentParams, SearchMatch } from './searchDocument'; import { readSelection } from './readSelection'; import { insertContent } from './insertContent'; import { replaceContent } from './replaceContent'; +import { searchDocument } from './searchDocument'; import type { SuperDocTool } from '../types'; /** @@ -16,7 +19,8 @@ import type { SuperDocTool } from '../types'; export const ALL_TOOLS: Record = { readSelection, insertContent, - replaceContent + replaceContent, + searchDocument }; /** diff --git a/packages/ai/src/ai-builder/tools/searchDocument.ts b/packages/ai/src/ai-builder/tools/searchDocument.ts new file mode 100644 index 000000000..de5ac6726 --- /dev/null +++ b/packages/ai/src/ai-builder/tools/searchDocument.ts @@ -0,0 +1,138 @@ +import type { Editor } from '../../types'; +import type { SuperDocTool, ToolResult } from '../types'; + +/** + * Params for searchDocument tool + */ +export interface SearchDocumentParams { + /** The text or pattern to search for */ + query: string; + /** Whether the search should be case-sensitive (default: false) */ + caseSensitive?: boolean; + /** Whether to treat query as a regular expression (default: false) */ + regex?: boolean; + /** Whether to return all matches or just the first one (default: true) */ + findAll?: boolean; +} + +/** + * Search result containing match information + */ +export interface SearchMatch { + /** The matched text */ + text: string; + /** Start position in the document */ + from: number; + /** End position in the document */ + to: number; +} + +/** + * Tool for searching text in the document. + * Returns positions of matches that can be used with other tools like replaceContent. + * + * @example + * // Search for all occurrences of "privacy" + * const result = await executeTool('searchDocument', { + * query: 'privacy', + * caseSensitive: false, + * findAll: true + * }, editor); + * // Returns: { matches: [{ text: 'privacy', from: 100, to: 107 }, ...] } + * + * // Then use with replaceContent: + * await executeTool('replaceContent', { + * from: result.data.matches[0].from, + * to: result.data.matches[0].to, + * content: [{ type: 'paragraph', content: [{ type: 'text', text: 'confidentiality' }] }] + * }, editor); + */ +export const searchDocument: SuperDocTool = { + name: 'searchDocument', + description: + 'Search for text or patterns in the document. Returns an array of matches with their positions (from/to character offsets). Use this before replaceContent to find exact positions to replace.', + category: 'read', + + async execute(editor: Editor, params: SearchDocumentParams): Promise { + try { + const { query, caseSensitive = false, regex = false, findAll = true } = params; + + if (!query) { + return { + success: false, + error: 'Query parameter is required', + docChanged: false, + }; + } + + // Check if editor has search command + if (!editor.commands?.search) { + return { + success: false, + error: 'Search command not available in editor', + docChanged: false, + }; + } + + // Create search pattern + let pattern: string | RegExp; + if (regex) { + try { + pattern = new RegExp(query, caseSensitive ? '' : 'i'); + } catch (error) { + return { + success: false, + error: `Invalid regular expression: ${error instanceof Error ? error.message : 'Unknown error'}`, + docChanged: false, + }; + } + } else { + pattern = query; + } + + // Execute search + const rawMatches = editor.commands.search(pattern); + + if (!rawMatches || !Array.isArray(rawMatches)) { + return { + success: false, + error: 'Search command returned invalid results', + docChanged: false, + }; + } + + // Format results + const matches: SearchMatch[] = rawMatches.map((match) => ({ + text: match.text, + from: match.from, + to: match.to, + })); + + // Filter case sensitivity if needed (for non-regex searches) + let filteredMatches = matches; + if (!regex && caseSensitive) { + filteredMatches = matches.filter((match) => match.text === query); + } + + // Return only first match if findAll is false + const finalMatches = findAll ? filteredMatches : filteredMatches.slice(0, 1); + + return { + success: true, + data: { + matches: finalMatches, + count: finalMatches.length, + query, + }, + docChanged: false, + message: `Found ${finalMatches.length} match(es) for "${query}"`, + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Search failed', + docChanged: false, + }; + } + }, +}; From 662592137c794723c32fd128a3331f650549ae3a Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Wed, 19 Nov 2025 12:52:38 -0300 Subject: [PATCH 3/4] feat: implement enrichParagraphNodes function to add default spacing attributes to paragraph nodes --- .../__tests__/helpers/enrichContent.test.ts | 122 ++++++++++++++++++ .../src/ai-builder/helpers/enrichContent.ts | 58 +++++++++ .../ai/src/ai-builder/tools/insertContent.ts | 12 +- .../ai/src/ai-builder/tools/replaceContent.ts | 8 +- 4 files changed, 194 insertions(+), 6 deletions(-) create mode 100644 packages/ai/src/ai-builder/__tests__/helpers/enrichContent.test.ts create mode 100644 packages/ai/src/ai-builder/helpers/enrichContent.ts diff --git a/packages/ai/src/ai-builder/__tests__/helpers/enrichContent.test.ts b/packages/ai/src/ai-builder/__tests__/helpers/enrichContent.test.ts new file mode 100644 index 000000000..7dadab568 --- /dev/null +++ b/packages/ai/src/ai-builder/__tests__/helpers/enrichContent.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect } from 'vitest'; +import { enrichParagraphNodes } from '../../helpers/enrichContent'; + +describe('enrichParagraphNodes', () => { + it('should add default spacing to paragraph nodes without spacing', () => { + const input = [ + { type: 'paragraph', content: [{ type: 'text', text: 'Hello' }] } + ]; + + const result = enrichParagraphNodes(input); + + expect(result[0].attrs).toBeDefined(); + expect(result[0].attrs.spacing).toEqual({ + after: null, + before: null, + line: null, + lineRule: 'auto' + }); + }); + + it('should preserve existing spacing attributes', () => { + const input = [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'Hello' }], + attrs: { + spacing: { after: 100, before: 50, line: 120, lineRule: 'exact' } + } + } + ]; + + const result = enrichParagraphNodes(input); + + expect(result[0].attrs.spacing).toEqual({ + after: 100, + before: 50, + line: 120, + lineRule: 'exact' + }); + }); + + it('should preserve other attrs while adding spacing', () => { + const input = [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'Hello' }], + attrs: { styleId: 'Heading1' } + } + ]; + + const result = enrichParagraphNodes(input); + + expect(result[0].attrs.styleId).toBe('Heading1'); + expect(result[0].attrs.spacing).toEqual({ + after: null, + before: null, + line: null, + lineRule: 'auto' + }); + }); + + it('should not affect non-paragraph nodes', () => { + const input = [ + { type: 'heading', level: 1, content: [{ type: 'text', text: 'Title' }] }, + { type: 'paragraph', content: [{ type: 'text', text: 'Content' }] } + ]; + + const result = enrichParagraphNodes(input); + + // Heading should remain unchanged + expect(result[0].type).toBe('heading'); + expect(result[0].attrs).toBeUndefined(); + + // Paragraph should have spacing added + expect(result[1].type).toBe('paragraph'); + expect(result[1].attrs.spacing).toBeDefined(); + }); + + it('should handle empty arrays', () => { + const result = enrichParagraphNodes([]); + expect(result).toEqual([]); + }); + + it('should handle non-array input gracefully', () => { + const result = enrichParagraphNodes(null as any); + expect(result).toBeNull(); + }); + + it('should not mutate original nodes', () => { + const input = [ + { type: 'paragraph', content: [{ type: 'text', text: 'Hello' }] } + ]; + + const original = JSON.parse(JSON.stringify(input)); + const result = enrichParagraphNodes(input); + + // Original should be unchanged + expect(input).toEqual(original); + + // Result should have spacing + expect(result[0].attrs.spacing).toBeDefined(); + }); + + it('should handle multiple paragraph nodes', () => { + const input = [ + { type: 'paragraph', content: [{ type: 'text', text: 'First' }] }, + { type: 'paragraph', content: [{ type: 'text', text: 'Second' }] }, + { type: 'paragraph', content: [{ type: 'text', text: 'Third' }] } + ]; + + const result = enrichParagraphNodes(input); + + result.forEach(node => { + expect(node.attrs.spacing).toEqual({ + after: null, + before: null, + line: null, + lineRule: 'auto' + }); + }); + }); +}); diff --git a/packages/ai/src/ai-builder/helpers/enrichContent.ts b/packages/ai/src/ai-builder/helpers/enrichContent.ts new file mode 100644 index 000000000..4b9217270 --- /dev/null +++ b/packages/ai/src/ai-builder/helpers/enrichContent.ts @@ -0,0 +1,58 @@ +/** + * Default paragraph spacing attributes matching super-editor defaults + * + * These values match getDefaultSpacing() from: + * packages/super-editor/src/extensions/paragraph/helpers/getDefaultSpacing.js + */ +const DEFAULT_SPACING = { + after: null, + before: null, + line: null, + lineRule: 'auto', +}; + +/** + * Add default spacing attributes to paragraph nodes if not already present + * + * This ensures all AI-generated content has consistent paragraph formatting + * matching the defaults used in the paragraph extension. Without spacing + * attributes, paragraphs render with zero margins and no visual line breaks. + * + * @param nodes - Array of content nodes (typically from AI) + * @returns Array of nodes with spacing attributes enriched + * + * @example + * ```typescript + * const enriched = enrichParagraphNodes([ + * { type: 'paragraph', content: [{ type: 'text', text: 'Hello' }] } + * ]); + * // Returns nodes with default spacing added to attrs + * ``` + */ +export function enrichParagraphNodes(nodes: any[]): any[] { + if (!Array.isArray(nodes)) { + return nodes; + } + + return nodes.map(node => { + // Only process paragraph nodes + if (node?.type !== 'paragraph') { + return node; + } + + // Create a copy to avoid mutating the original + const enrichedNode = { ...node }; + + // Initialize attrs if not present + if (!enrichedNode.attrs) { + enrichedNode.attrs = {}; + } + + // Add default spacing if not already set + if (!enrichedNode.attrs.spacing) { + enrichedNode.attrs.spacing = { ...DEFAULT_SPACING }; + } + + return enrichedNode; + }); +} diff --git a/packages/ai/src/ai-builder/tools/insertContent.ts b/packages/ai/src/ai-builder/tools/insertContent.ts index 2373c6f75..852ee73b8 100644 --- a/packages/ai/src/ai-builder/tools/insertContent.ts +++ b/packages/ai/src/ai-builder/tools/insertContent.ts @@ -1,5 +1,6 @@ import type { Editor } from '../../types'; import type { SuperDocTool, ToolResult } from '../types'; +import { enrichParagraphNodes } from '../helpers/enrichContent'; /** * Params for insertContent tool @@ -32,6 +33,9 @@ export const insertContent: SuperDocTool = { }; } + // Automatically add default spacing attributes to paragraph nodes + const enrichedContent = enrichParagraphNodes(content); + const { state } = editor; if (!state) { return { @@ -64,12 +68,12 @@ export const insertContent: SuperDocTool = { // For single nodes, pass directly; for multiple, wrap in an array let insertSuccess: boolean; - if (Array.isArray(content) && content.length === 1) { + if (Array.isArray(enrichedContent) && enrichedContent.length === 1) { // Single node: pass directly - insertSuccess = editor.commands.insertContentAt(insertPos, content[0]); + insertSuccess = editor.commands.insertContentAt(insertPos, enrichedContent[0]); } else { // Multiple nodes or array: pass as-is - insertSuccess = editor.commands.insertContentAt(insertPos, content); + insertSuccess = editor.commands.insertContentAt(insertPos, enrichedContent); } const success = insertSuccess; @@ -86,7 +90,7 @@ export const insertContent: SuperDocTool = { success: true, data: { insertedAt: insertPos }, docChanged: true, - message: `Inserted ${content.length} node(s) at ${position}` + message: `Inserted ${enrichedContent.length} node(s) at ${position}` }; } catch (error) { return { diff --git a/packages/ai/src/ai-builder/tools/replaceContent.ts b/packages/ai/src/ai-builder/tools/replaceContent.ts index bf44b1279..53d93dc3f 100644 --- a/packages/ai/src/ai-builder/tools/replaceContent.ts +++ b/packages/ai/src/ai-builder/tools/replaceContent.ts @@ -1,5 +1,6 @@ import type { Editor } from '../../types'; import type { SuperDocTool, ToolResult } from '../types'; +import { enrichParagraphNodes } from '../helpers/enrichContent'; /** * Params for replaceContent tool @@ -50,6 +51,9 @@ export const replaceContent: SuperDocTool = { }; } + // Automatically add default spacing attributes to paragraph nodes + const enrichedContent = enrichParagraphNodes(content); + const { state } = editor; if (!state) { return { @@ -66,7 +70,7 @@ export const replaceContent: SuperDocTool = { // For full document replacement, use setContent if (validFrom === 0 && validTo === docSize) { - const success = editor.commands.setContent({ type: 'doc', content }); + const success = editor.commands.setContent({ type: 'doc', content: enrichedContent }); return { success, @@ -79,7 +83,7 @@ export const replaceContent: SuperDocTool = { // For partial replacement, use insertContentAt const success = editor.commands.insertContentAt( { from: validFrom, to: validTo }, - { type: 'doc', content } + { type: 'doc', content: enrichedContent } ); if (!success) { From cbd2c709477fa13501e4c48041513ff92615cc0a Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Wed, 26 Nov 2025 18:49:10 -0300 Subject: [PATCH 4/4] chore: new tools --- .../__tests__/helpers/enrichContent.test.ts | 32 +-- packages/ai/src/ai-builder/content-schema.ts | 69 +++-- packages/ai/src/ai-builder/executor.ts | 95 ++++--- .../src/ai-builder/helpers/enrichContent.ts | 2 +- .../ai-builder/helpers/getDocumentContext.ts | 75 +++++ packages/ai/src/ai-builder/index.ts | 20 +- .../ai/src/ai-builder/providers/anthropic.ts | 266 ++++++++++++------ packages/ai/src/ai-builder/providers/index.ts | 2 +- .../src/ai-builder/tools/getContentSchema.ts | 36 +++ .../ai-builder/tools/getDocumentOutline.ts | 91 ++++++ packages/ai/src/ai-builder/tools/index.ts | 34 ++- .../ai/src/ai-builder/tools/insertContent.ts | 153 +++++----- .../ai/src/ai-builder/tools/readContent.ts | 87 ++++++ .../ai/src/ai-builder/tools/readSection.ts | 146 ++++++++++ .../ai/src/ai-builder/tools/readSelection.ts | 125 +++++--- .../ai/src/ai-builder/tools/replaceContent.ts | 244 ++++++++++------ .../{searchDocument.ts => searchContent.ts} | 24 +- packages/ai/src/ai-builder/types.ts | 100 +++---- packages/ai/tsup.config.ts | 2 +- 19 files changed, 1130 insertions(+), 473 deletions(-) create mode 100644 packages/ai/src/ai-builder/helpers/getDocumentContext.ts create mode 100644 packages/ai/src/ai-builder/tools/getContentSchema.ts create mode 100644 packages/ai/src/ai-builder/tools/getDocumentOutline.ts create mode 100644 packages/ai/src/ai-builder/tools/readContent.ts create mode 100644 packages/ai/src/ai-builder/tools/readSection.ts rename packages/ai/src/ai-builder/tools/{searchDocument.ts => searchContent.ts} (84%) diff --git a/packages/ai/src/ai-builder/__tests__/helpers/enrichContent.test.ts b/packages/ai/src/ai-builder/__tests__/helpers/enrichContent.test.ts index 7dadab568..8552b286b 100644 --- a/packages/ai/src/ai-builder/__tests__/helpers/enrichContent.test.ts +++ b/packages/ai/src/ai-builder/__tests__/helpers/enrichContent.test.ts @@ -3,9 +3,7 @@ import { enrichParagraphNodes } from '../../helpers/enrichContent'; describe('enrichParagraphNodes', () => { it('should add default spacing to paragraph nodes without spacing', () => { - const input = [ - { type: 'paragraph', content: [{ type: 'text', text: 'Hello' }] } - ]; + const input = [{ type: 'paragraph', content: [{ type: 'text', text: 'Hello' }] }]; const result = enrichParagraphNodes(input); @@ -14,7 +12,7 @@ describe('enrichParagraphNodes', () => { after: null, before: null, line: null, - lineRule: 'auto' + lineRule: 'auto', }); }); @@ -24,9 +22,9 @@ describe('enrichParagraphNodes', () => { type: 'paragraph', content: [{ type: 'text', text: 'Hello' }], attrs: { - spacing: { after: 100, before: 50, line: 120, lineRule: 'exact' } - } - } + spacing: { after: 100, before: 50, line: 120, lineRule: 'exact' }, + }, + }, ]; const result = enrichParagraphNodes(input); @@ -35,7 +33,7 @@ describe('enrichParagraphNodes', () => { after: 100, before: 50, line: 120, - lineRule: 'exact' + lineRule: 'exact', }); }); @@ -44,8 +42,8 @@ describe('enrichParagraphNodes', () => { { type: 'paragraph', content: [{ type: 'text', text: 'Hello' }], - attrs: { styleId: 'Heading1' } - } + attrs: { styleId: 'Heading1' }, + }, ]; const result = enrichParagraphNodes(input); @@ -55,14 +53,14 @@ describe('enrichParagraphNodes', () => { after: null, before: null, line: null, - lineRule: 'auto' + lineRule: 'auto', }); }); it('should not affect non-paragraph nodes', () => { const input = [ { type: 'heading', level: 1, content: [{ type: 'text', text: 'Title' }] }, - { type: 'paragraph', content: [{ type: 'text', text: 'Content' }] } + { type: 'paragraph', content: [{ type: 'text', text: 'Content' }] }, ]; const result = enrichParagraphNodes(input); @@ -87,9 +85,7 @@ describe('enrichParagraphNodes', () => { }); it('should not mutate original nodes', () => { - const input = [ - { type: 'paragraph', content: [{ type: 'text', text: 'Hello' }] } - ]; + const input = [{ type: 'paragraph', content: [{ type: 'text', text: 'Hello' }] }]; const original = JSON.parse(JSON.stringify(input)); const result = enrichParagraphNodes(input); @@ -105,17 +101,17 @@ describe('enrichParagraphNodes', () => { const input = [ { type: 'paragraph', content: [{ type: 'text', text: 'First' }] }, { type: 'paragraph', content: [{ type: 'text', text: 'Second' }] }, - { type: 'paragraph', content: [{ type: 'text', text: 'Third' }] } + { type: 'paragraph', content: [{ type: 'text', text: 'Third' }] }, ]; const result = enrichParagraphNodes(input); - result.forEach(node => { + result.forEach((node) => { expect(node.attrs.spacing).toEqual({ after: null, before: null, line: null, - lineRule: 'auto' + lineRule: 'auto', }); }); }); diff --git a/packages/ai/src/ai-builder/content-schema.ts b/packages/ai/src/ai-builder/content-schema.ts index 140eba207..dfa338ea4 100644 --- a/packages/ai/src/ai-builder/content-schema.ts +++ b/packages/ai/src/ai-builder/content-schema.ts @@ -26,7 +26,8 @@ export const CONTENT_SCHEMA = { type: { type: 'string', const: 'paragraph', - description: 'Paragraph node. For headings, use styleId attribute (e.g., "Heading1"). For lists, use numberingProperties.' + description: + 'Paragraph node. For headings, use styleId attribute (e.g., "Heading1"). For lists, use numberingProperties.', }, content: { type: 'array', @@ -40,11 +41,11 @@ export const CONTENT_SCHEMA = { type: { type: 'string', const: 'text', - description: 'Text content node' + description: 'Text content node', }, text: { type: 'string', - description: 'The actual text content' + description: 'The actual text content', }, marks: { type: 'array', @@ -56,16 +57,16 @@ export const CONTENT_SCHEMA = { type: { type: 'string', enum: ['bold', 'italic', 'underline', 'strike', 'link', 'highlight', 'textStyle'], - description: 'Type of formatting mark' + description: 'Type of formatting mark', }, attrs: { type: 'object', - description: 'Mark attributes (e.g., href for links, color for highlights)' - } - } - } - } - } + description: 'Mark attributes (e.g., href for links, color for highlights)', + }, + }, + }, + }, + }, }, { type: 'object', @@ -74,12 +75,12 @@ export const CONTENT_SCHEMA = { type: { type: 'string', const: 'hardBreak', - description: 'Line break (Shift+Enter)' - } - } - } - ] - } + description: 'Line break (Shift+Enter)', + }, + }, + }, + ], + }, }, attrs: { type: 'object', @@ -87,26 +88,20 @@ export const CONTENT_SCHEMA = { properties: { styleId: { type: 'string', - description: 'Word style ID for headings (e.g., "Heading1", "Heading2", etc.) or other styles' + description: 'Word style ID for headings (e.g., "Heading1", "Heading2", etc.) or other styles', }, textAlign: { type: 'string', enum: ['left', 'center', 'right', 'justify'], - description: 'Text alignment' + description: 'Text alignment', }, lineHeight: { - oneOf: [ - { type: 'string' }, - { type: 'number' } - ], - description: 'Line height (e.g., "1.5" or 1.5)' + oneOf: [{ type: 'string' }, { type: 'number' }], + description: 'Line height (e.g., "1.5" or 1.5)', }, textIndent: { - oneOf: [ - { type: 'string' }, - { type: 'number' } - ], - description: 'First-line indentation' + oneOf: [{ type: 'string' }, { type: 'number' }], + description: 'First-line indentation', }, numberingProperties: { type: 'object', @@ -115,16 +110,16 @@ export const CONTENT_SCHEMA = { properties: { numId: { type: 'number', - description: 'Numbering definition ID: 1 for bullets, 2 for numbered lists' + description: 'Numbering definition ID: 1 for bullets, 2 for numbered lists', }, ilvl: { type: 'number', - description: 'Indentation level (0-8, where 0 is top level)' - } - } - } - } - } - } - } + description: 'Indentation level (0-8, where 0 is top level)', + }, + }, + }, + }, + }, + }, + }, } as const; diff --git a/packages/ai/src/ai-builder/executor.ts b/packages/ai/src/ai-builder/executor.ts index b4f18c6e1..edfbfb407 100644 --- a/packages/ai/src/ai-builder/executor.ts +++ b/packages/ai/src/ai-builder/executor.ts @@ -25,58 +25,57 @@ import { getTool } from './tools'; * ``` */ export async function executeTool( - toolName: string, - params: any, - editor: Editor, - options?: ExecuteToolOptions + toolName: string, + params: any, + editor: Editor, + options?: ExecuteToolOptions, ): Promise { - try { - // Check for cancellation - if (options?.signal?.aborted) { - return { - success: false, - error: 'Tool execution was cancelled', - docChanged: false - }; - } - - // Get the tool - const tool = getTool(toolName); - if (!tool) { - return { - success: false, - error: `Unknown tool: ${toolName}`, - docChanged: false - }; - } - - // Validate params if requested - if (options?.validate) { - // Basic validation - could be enhanced with JSON Schema validation - if (params === undefined || params === null) { - return { - success: false, - error: 'Tool parameters are required', - docChanged: false - }; - } - } - - // Execute the tool - const result = await tool.execute(editor, params); + try { + // Check for cancellation + if (options?.signal?.aborted) { + return { + success: false, + error: 'Tool execution was cancelled', + docChanged: false, + }; + } - // Report progress if callback provided - if (options?.onProgress) { - options.onProgress(100); - } + // Get the tool + const tool = getTool(toolName); + if (!tool) { + return { + success: false, + error: `Unknown tool: ${toolName}`, + docChanged: false, + }; + } - return result; - } catch (error) { + // Validate params if requested + if (options?.validate) { + // Basic validation - could be enhanced with JSON Schema validation + if (params === undefined || params === null) { return { - success: false, - error: error instanceof Error ? error.message : 'Unknown error during tool execution', - docChanged: false + success: false, + error: 'Tool parameters are required', + docChanged: false, }; + } } -} + // Execute the tool + const result = await tool.execute(editor, params); + + // Report progress if callback provided + if (options?.onProgress) { + options.onProgress(100); + } + + return result; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error during tool execution', + docChanged: false, + }; + } +} diff --git a/packages/ai/src/ai-builder/helpers/enrichContent.ts b/packages/ai/src/ai-builder/helpers/enrichContent.ts index 4b9217270..2a8664315 100644 --- a/packages/ai/src/ai-builder/helpers/enrichContent.ts +++ b/packages/ai/src/ai-builder/helpers/enrichContent.ts @@ -34,7 +34,7 @@ export function enrichParagraphNodes(nodes: any[]): any[] { return nodes; } - return nodes.map(node => { + return nodes.map((node) => { // Only process paragraph nodes if (node?.type !== 'paragraph') { return node; diff --git a/packages/ai/src/ai-builder/helpers/getDocumentContext.ts b/packages/ai/src/ai-builder/helpers/getDocumentContext.ts new file mode 100644 index 000000000..fa6ee2059 --- /dev/null +++ b/packages/ai/src/ai-builder/helpers/getDocumentContext.ts @@ -0,0 +1,75 @@ +import type { Editor } from '../../types'; + +/** + * Result from getDocumentContext + */ +export interface DocumentContextResult { + /** Strategy used: 'full' for small docs, 'selection' for large docs */ + strategy: 'full' | 'selection'; + /** The document content (full or selection only) */ + content: unknown; + /** Guidance message for large documents */ + message?: string; +} + +/** + * Options for getDocumentContext + */ +export interface DocumentContextOptions { + /** Maximum tokens before switching to selection-only mode (default: 5000) */ + maxTokens?: number; +} + +/** + * Get document context optimized for token efficiency. + * + * - Small documents: returns full document content + * - Large documents: returns only selection, with guidance to use tools + * + * @example + * ```typescript + * const context = getDocumentContext(editor, { maxTokens: 5000 }); + * + * if (context.strategy === 'full') { + * // Send full document to LLM + * systemPrompt += `\n\nDocument:\n${JSON.stringify(context.content)}`; + * } else { + * // Send selection + guidance + * systemPrompt += `\n\nSelected content:\n${JSON.stringify(context.content)}`; + * systemPrompt += `\n\n${context.message}`; + * } + * ``` + */ +export function getDocumentContext(editor: Editor, options?: DocumentContextOptions): DocumentContextResult { + const maxTokens = options?.maxTokens ?? 5000; + const charsPerToken = 4; // rough estimate + + const { state } = editor; + if (!state) { + return { + strategy: 'full', + content: null, + }; + } + + const docSize = state.doc.content.size; + const estimatedTokens = Math.ceil(docSize / charsPerToken); + + // Small document: return full content + if (estimatedTokens <= maxTokens) { + return { + strategy: 'full', + content: state.doc.toJSON(), + }; + } + + // Large document: return selection only + const { from, to } = state.selection; + const selectedContent = state.doc.cut(from, to); + + return { + strategy: 'selection', + content: selectedContent.toJSON(), + message: 'Document is large. Use searchContent to find text positions and readContent to read specific sections.', + }; +} diff --git a/packages/ai/src/ai-builder/index.ts b/packages/ai/src/ai-builder/index.ts index 5eba25e41..252e3c6d3 100644 --- a/packages/ai/src/ai-builder/index.ts +++ b/packages/ai/src/ai-builder/index.ts @@ -6,25 +6,29 @@ * AI Builder provides the foundational components for creating AI-powered * document editing experiences. It offers: * - * - **Tools**: Core document operations (insert, replace) + * - **Tools**: Core document operations (read, search, insert, replace) * - **Executor**: Primitive for running tool calls (executeTool) * - **Provider**: Anthropic tool schemas - * - **Schema Generator**: Generate schemas from SuperDoc extensions + * - **Helper**: Token-efficient document context (getDocumentContext) * * @example * ```typescript - * import { executeTool, anthropicTools } from '@superdoc-dev/ai/ai-builder'; + * import { executeTool, anthropicTools, getDocumentContext } from '@superdoc-dev/ai/ai-builder'; * import Anthropic from '@anthropic-ai/sdk'; * * // Get tool definitions - * const tools = anthropicTools(editor.extensionManager.extensions); + * const tools = anthropicTools(); + * + * // Get document context (full doc for small, selection for large) + * const context = getDocumentContext(editor, { maxTokens: 5000 }); * * // Use with Anthropic SDK * const anthropic = new Anthropic({ apiKey: '...' }); - * const response = await anthropic.beta.messages.create({ + * const response = await anthropic.messages.create({ * model: 'claude-sonnet-4-5', + * system: `You are a document editor.\n\nDocument:\n${JSON.stringify(context.content)}`, * tools, - * messages: [...] + * messages: [{ role: 'user', content: userMessage }] * }); * * // Execute tool calls @@ -49,3 +53,7 @@ export * from './providers/index'; // Content schema export { CONTENT_SCHEMA } from './content-schema'; + +// Helpers +export { getDocumentContext } from './helpers/getDocumentContext'; +export type { DocumentContextResult, DocumentContextOptions } from './helpers/getDocumentContext'; diff --git a/packages/ai/src/ai-builder/providers/anthropic.ts b/packages/ai/src/ai-builder/providers/anthropic.ts index 9dd051162..463c7ccb0 100644 --- a/packages/ai/src/ai-builder/providers/anthropic.ts +++ b/packages/ai/src/ai-builder/providers/anthropic.ts @@ -1,13 +1,11 @@ import type { AnthropicTool, ToolDefinitionsOptions } from '../types'; -import { CONTENT_SCHEMA } from '../content-schema'; /** - * Generate Anthropic-compatible tool definitions from SuperDoc extensions. + * Generate Anthropic-compatible tool definitions for SuperDoc AI. * - * Returns an array of tool objects compatible with Anthropic's Messages API - * and structured outputs (strict mode). + * Returns an array of tool objects compatible with Anthropic's Messages API. * - * @param extensions - Array of SuperDoc extensions + * @param extensions - Array of SuperDoc extensions (unused for now, reserved for future) * @param options - Tool definition options * @returns Array of Anthropic tool definitions * @@ -16,103 +14,193 @@ import { CONTENT_SCHEMA } from '../content-schema'; * import { anthropicTools } from '@superdoc-dev/ai/ai-builder/providers'; * import Anthropic from '@anthropic-ai/sdk'; * - * const tools = anthropicTools(editor.extensionManager.extensions, { - * excludedNodes: ['table'], - * excludedMarks: ['strike', 'underline'], - * strict: true - * }); + * const tools = anthropicTools(); * * const anthropic = new Anthropic({ apiKey: '...' }); - * const response = await anthropic.beta.messages.create({ + * const response = await anthropic.messages.create({ * model: 'claude-sonnet-4-5', - * betas: ['structured-outputs-2025-11-13'], * tools, * messages: [...] * }); * ``` */ -export function anthropicTools( - extensions: any[] = [], - options?: ToolDefinitionsOptions -): AnthropicTool[] { - const { - enabledTools - } = options || {}; +export function anthropicTools(extensions: unknown[] = [], options?: ToolDefinitionsOptions): AnthropicTool[] { + const { enabledTools } = options || {}; - // Define all available tools - const allTools: AnthropicTool[] = [ - { - name: 'searchDocument', - description: 'Search for text or patterns in the document. Returns matches with their positions (from/to character offsets). Use this before replaceContent to find exact positions.', - input_schema: { - type: 'object', - properties: { - query: { - type: 'string', - description: 'The text or pattern to search for' - }, - caseSensitive: { - type: 'boolean', - description: 'Whether the search should be case-sensitive (default: false)' - }, - regex: { - type: 'boolean', - description: 'Whether to treat query as a regular expression (default: false)' - }, - findAll: { - type: 'boolean', - description: 'Whether to return all matches or just the first one (default: true)' - } - }, - required: ['query'], - additionalProperties: false - } + // Define all available tools + const allTools: AnthropicTool[] = [ + { + name: 'readSelection', + description: + 'Read the currently selected content in the document. Returns the selection range (from/to positions) and the JSON representation. Use withContext to include surrounding paragraphs.', + input_schema: { + type: 'object', + properties: { + withContext: { + type: 'integer', + description: 'Number of paragraphs to include before and after the selection for context (optional)', + }, + }, + required: [], + additionalProperties: false, + }, + }, + { + name: 'readContent', + description: + 'Read document content at a specific position range (from/to character offsets). Use after searchContent to read actual content at found positions.', + input_schema: { + type: 'object', + properties: { + from: { + type: 'integer', + description: 'Start position (character offset)', + }, + to: { + type: 'integer', + description: 'End position (character offset)', + }, + }, + required: ['from', 'to'], + additionalProperties: false, + }, + }, + { + name: 'searchContent', + description: + 'Search for text or patterns in the document. Returns matches with their positions (from/to character offsets). Use with readContent to see context or replaceContent to modify.', + input_schema: { + type: 'object', + properties: { + query: { + type: 'string', + description: 'The text or pattern to search for', + }, + caseSensitive: { + type: 'boolean', + description: 'Whether the search should be case-sensitive (default: false)', + }, + regex: { + type: 'boolean', + description: 'Whether to treat query as a regular expression (default: false)', + }, + findAll: { + type: 'boolean', + description: 'Whether to return all matches or just the first one (default: true)', + }, + }, + required: ['query'], + additionalProperties: false, + }, + }, + { + name: 'getContentSchema', + description: + 'Get the JSON schema for document content format. Call this before insertContent or replaceContent to understand the expected structure for the content array.', + input_schema: { + type: 'object', + properties: {}, + required: [], + additionalProperties: false, + }, + }, + { + name: 'insertContent', + description: + 'Insert new content into the document. Call getContentSchema first to understand the content format. Position can be "selection" (at cursor), "documentStart", or "documentEnd".', + input_schema: { + type: 'object', + properties: { + position: { + type: 'string', + enum: ['selection', 'documentStart', 'documentEnd'], + description: 'Where to insert the content', + }, + content: { + type: 'array', + description: 'Array of paragraph nodes. Call getContentSchema for the full format specification.', + }, + }, + required: ['position', 'content'], + additionalProperties: false, + }, + }, + { + name: 'replaceContent', + description: + 'Replace content in the document. Use query to search and replace text by name, or use from/to for exact positions.', + input_schema: { + type: 'object', + properties: { + query: { + type: 'string', + description: 'Text to search for and replace. Use this instead of from/to positions.', + }, + from: { + type: 'integer', + description: 'Start position (character offset). Only needed if query is not provided.', + }, + to: { + type: 'integer', + description: 'End position (character offset). Only needed if query is not provided.', + }, + content: { + type: 'array', + description: 'Array of paragraph nodes to replace with.', + }, + replaceAll: { + type: 'boolean', + description: 'Whether to replace all occurrences when using query (default: false)', + }, }, - { - name: 'insertContent', - description: 'Insert new content into the document. Position can be "selection" (at cursor), "documentStart", or "documentEnd". Content should be an array of paragraph blocks.', - input_schema: { - type: 'object', - properties: { - position: { - type: 'string', - enum: ['selection', 'documentStart', 'documentEnd'], - description: 'Where to insert the content' - }, - content: CONTENT_SCHEMA - }, - required: ['position', 'content'], - additionalProperties: false - } + required: ['content'], + additionalProperties: false, + }, + }, + { + name: 'getDocumentOutline', + description: + 'Get the document outline (headings and their positions). Use this to understand document structure before reading or editing specific sections.', + input_schema: { + type: 'object', + properties: {}, + required: [], + additionalProperties: false, + }, + }, + { + name: 'readSection', + description: + 'Read a specific section of the document by heading name or position range. Use heading parameter to find by name, or from/to for exact positions.', + input_schema: { + type: 'object', + properties: { + heading: { + type: 'string', + description: + 'Heading text to find and read (case-insensitive partial match). The section includes content until the next heading of same or higher level.', + }, + from: { + type: 'integer', + description: 'Start position (character offset). Alternative to heading parameter.', + }, + to: { + type: 'integer', + description: 'End position (character offset). Alternative to heading parameter.', + }, }, - { - name: 'replaceContent', - description: 'Replace content in a specific range of the document. Specify from and to positions (character offsets) and provide an array of paragraph blocks to replace with.', - input_schema: { - type: 'object', - properties: { - from: { - type: 'integer', - description: 'Start position (character offset)' - }, - to: { - type: 'integer', - description: 'End position (character offset)' - }, - content: CONTENT_SCHEMA - }, - required: ['from', 'to', 'content'], - additionalProperties: false - } - } - ]; + required: [], + additionalProperties: false, + }, + }, + ]; - // Filter tools if enabledTools is specified - if (enabledTools && enabledTools.length > 0) { - return allTools.filter(tool => enabledTools.includes(tool.name)); - } + // Filter tools if enabledTools is specified + if (enabledTools && enabledTools.length > 0) { + return allTools.filter((tool) => enabledTools.includes(tool.name)); + } - return allTools; + return allTools; } /** diff --git a/packages/ai/src/ai-builder/providers/index.ts b/packages/ai/src/ai-builder/providers/index.ts index 9dec5d384..b4fe22352 100644 --- a/packages/ai/src/ai-builder/providers/index.ts +++ b/packages/ai/src/ai-builder/providers/index.ts @@ -1 +1 @@ -export { anthropicTools, toolDefinitions as anthropicToolDefinitions } from './anthropic'; \ No newline at end of file +export { anthropicTools, toolDefinitions as anthropicToolDefinitions } from './anthropic'; diff --git a/packages/ai/src/ai-builder/tools/getContentSchema.ts b/packages/ai/src/ai-builder/tools/getContentSchema.ts new file mode 100644 index 000000000..546a18430 --- /dev/null +++ b/packages/ai/src/ai-builder/tools/getContentSchema.ts @@ -0,0 +1,36 @@ +import type { Editor } from '../../types'; +import type { SuperDocTool, ToolResult } from '../types'; +import { CONTENT_SCHEMA } from '../content-schema'; + +/** + * Tool for getting the content schema. + * Call this before insertContent or replaceContent to understand the expected format. + * + * @example + * // First get the schema + * const schema = await executeTool('getContentSchema', {}, editor); + * // Then use the format to create content + * await executeTool('insertContent', { + * position: 'selection', + * content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Hello' }] }] + * }, editor); + */ +export const getContentSchema: SuperDocTool = { + name: 'getContentSchema', + description: + 'Get the JSON schema that describes the expected format for document content. Call this before using insertContent or replaceContent to understand how to structure the content array.', + category: 'read', + + async execute(_editor: Editor): Promise { + return { + success: true, + data: { + schema: CONTENT_SCHEMA, + summary: + 'Content is an array of paragraph objects. Each paragraph has type="paragraph", optional attrs (styleId for headings, numberingProperties for lists), and content array of text nodes with optional marks (bold, italic, etc.).', + }, + docChanged: false, + message: 'Content schema returned. Use this format for insertContent and replaceContent.', + }; + }, +}; diff --git a/packages/ai/src/ai-builder/tools/getDocumentOutline.ts b/packages/ai/src/ai-builder/tools/getDocumentOutline.ts new file mode 100644 index 000000000..02e0a6944 --- /dev/null +++ b/packages/ai/src/ai-builder/tools/getDocumentOutline.ts @@ -0,0 +1,91 @@ +import type { Node } from 'prosemirror-model'; +import type { Editor } from '../../types'; +import type { SuperDocTool, ToolResult } from '../types'; + +/** + * Heading info returned in document outline + */ +export interface HeadingInfo { + /** The heading text */ + text: string; + /** Heading level (1-6) */ + level: number; + /** Start position in document */ + position: number; +} + +/** + * Tool for getting document structure/outline. + * Returns headings with their positions so LLM can navigate large documents. + * + * @example + * const outline = await executeTool('getDocumentOutline', {}, editor); + * // Returns: { headings: [{ text: "Introduction", level: 1, position: 0 }, ...], totalLength: 5000 } + */ +export const getDocumentOutline: SuperDocTool = { + name: 'getDocumentOutline', + description: + 'Get the document outline (headings and their positions). Use this to understand document structure before reading or editing specific sections.', + category: 'read', + + async execute(editor: Editor): Promise { + try { + const { state } = editor; + if (!state) { + return { + success: false, + error: 'Editor state not available', + docChanged: false, + }; + } + + const headings: HeadingInfo[] = []; + const doc = state.doc; + + // Walk through document to find headings + doc.descendants((node: Node, pos: number) => { + if (node.type.name === 'paragraph') { + const styleId = node.attrs?.styleId; + if (styleId && typeof styleId === 'string') { + // Check for Heading1, Heading2, etc. + const match = styleId.match(/^Heading(\d)$/i); + if (match) { + const level = parseInt(match[1], 10); + // Extract text content from the paragraph + let text = ''; + node.content.forEach((child: Node) => { + if (child.isText) { + text += child.text; + } + }); + + headings.push({ + text: text.trim() || '(untitled)', + level, + position: pos, + }); + } + } + } + return true; // continue traversal + }); + + return { + success: true, + data: { + headings, + totalLength: doc.content.size, + headingCount: headings.length, + }, + docChanged: false, + message: `Found ${headings.length} headings in document`, + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to get document outline', + docChanged: false, + }; + } + }, +}; diff --git a/packages/ai/src/ai-builder/tools/index.ts b/packages/ai/src/ai-builder/tools/index.ts index 9e52d9986..673cd8b76 100644 --- a/packages/ai/src/ai-builder/tools/index.ts +++ b/packages/ai/src/ai-builder/tools/index.ts @@ -1,38 +1,54 @@ export { readSelection } from './readSelection'; +export { readContent } from './readContent'; +export { searchContent } from './searchContent'; +export { getContentSchema } from './getContentSchema'; export { insertContent } from './insertContent'; export { replaceContent } from './replaceContent'; -export { searchDocument } from './searchDocument'; +export { getDocumentOutline } from './getDocumentOutline'; +export { readSection } from './readSection'; +export type { ReadSelectionParams } from './readSelection'; +export type { ReadContentParams } from './readContent'; +export type { SearchContentParams, SearchMatch } from './searchContent'; export type { InsertContentParams } from './insertContent'; export type { ReplaceContentParams } from './replaceContent'; -export type { SearchDocumentParams, SearchMatch } from './searchDocument'; +export type { HeadingInfo } from './getDocumentOutline'; +export type { ReadSectionParams } from './readSection'; import { readSelection } from './readSelection'; +import { readContent } from './readContent'; +import { searchContent } from './searchContent'; +import { getContentSchema } from './getContentSchema'; import { insertContent } from './insertContent'; import { replaceContent } from './replaceContent'; -import { searchDocument } from './searchDocument'; +import { getDocumentOutline } from './getDocumentOutline'; +import { readSection } from './readSection'; import type { SuperDocTool } from '../types'; /** * All available SuperDoc AI tools */ export const ALL_TOOLS: Record = { - readSelection, - insertContent, - replaceContent, - searchDocument + readSelection, + readContent, + searchContent, + getContentSchema, + insertContent, + replaceContent, + getDocumentOutline, + readSection, }; /** * Get a tool by name */ export function getTool(name: string): SuperDocTool | undefined { - return ALL_TOOLS[name]; + return ALL_TOOLS[name]; } /** * Get all tool names */ export function getToolNames(): string[] { - return Object.keys(ALL_TOOLS); + return Object.keys(ALL_TOOLS); } diff --git a/packages/ai/src/ai-builder/tools/insertContent.ts b/packages/ai/src/ai-builder/tools/insertContent.ts index 852ee73b8..38c34ba45 100644 --- a/packages/ai/src/ai-builder/tools/insertContent.ts +++ b/packages/ai/src/ai-builder/tools/insertContent.ts @@ -6,10 +6,10 @@ import { enrichParagraphNodes } from '../helpers/enrichContent'; * Params for insertContent tool */ export interface InsertContentParams { - /** Where to insert: 'selection' (at cursor), 'documentStart', or 'documentEnd' */ - position: 'selection' | 'documentStart' | 'documentEnd'; - /** Array of content nodes to insert (ProseMirror JSON format) */ - content: any[]; + /** Where to insert: 'selection' (at cursor), 'documentStart', or 'documentEnd' */ + position: 'selection' | 'documentStart' | 'documentEnd'; + /** Array of content nodes to insert (ProseMirror JSON format) */ + content: any[]; } /** @@ -17,87 +17,88 @@ export interface InsertContentParams { * Supports inserting at cursor position, document start, or document end. */ export const insertContent: SuperDocTool = { - name: 'insertContent', - description: 'Insert new content into the document. Position can be "selection" (at cursor), "documentStart", or "documentEnd". Content should be an array of paragraph blocks in ProseMirror JSON format.', - category: 'write', + name: 'insertContent', + description: + 'Insert new content into the document. Position can be "selection" (at cursor), "documentStart", or "documentEnd". Content should be an array of paragraph blocks in ProseMirror JSON format.', + category: 'write', - async execute(editor: Editor, params: InsertContentParams): Promise { - try { - const { position, content } = params; + async execute(editor: Editor, params: InsertContentParams): Promise { + try { + const { position, content } = params; - if (!content || !Array.isArray(content)) { - return { - success: false, - error: 'Content must be an array of nodes', - docChanged: false - }; - } + if (!content || !Array.isArray(content)) { + return { + success: false, + error: 'Content must be an array of nodes', + docChanged: false, + }; + } - // Automatically add default spacing attributes to paragraph nodes - const enrichedContent = enrichParagraphNodes(content); + // Automatically add default spacing attributes to paragraph nodes + const enrichedContent = enrichParagraphNodes(content); - const { state } = editor; - if (!state) { - return { - success: false, - error: 'Editor state not available', - docChanged: false - }; - } + const { state } = editor; + if (!state) { + return { + success: false, + error: 'Editor state not available', + docChanged: false, + }; + } - let insertPos: number; - switch (position) { - case 'selection': - insertPos = state.selection.from; - break; - case 'documentStart': - insertPos = 0; - break; - case 'documentEnd': - insertPos = state.doc.content.size; - break; - default: - return { - success: false, - error: `Invalid position: ${position}`, - docChanged: false - }; - } + let insertPos: number; + switch (position) { + case 'selection': + insertPos = state.selection.from; + break; + case 'documentStart': + insertPos = 0; + break; + case 'documentEnd': + insertPos = state.doc.content.size; + break; + default: + return { + success: false, + error: `Invalid position: ${position}`, + docChanged: false, + }; + } - // Use editor's insertContentAt command - // For single nodes, pass directly; for multiple, wrap in an array - let insertSuccess: boolean; + // Use editor's insertContentAt command + // For single nodes, pass directly; for multiple, wrap in an array + let insertSuccess: boolean; - if (Array.isArray(enrichedContent) && enrichedContent.length === 1) { - // Single node: pass directly - insertSuccess = editor.commands.insertContentAt(insertPos, enrichedContent[0]); - } else { - // Multiple nodes or array: pass as-is - insertSuccess = editor.commands.insertContentAt(insertPos, enrichedContent); - } + if (Array.isArray(enrichedContent) && enrichedContent.length === 1) { + // Single node: pass directly + insertSuccess = editor.commands.insertContentAt(insertPos, enrichedContent[0]); + } else { + // Multiple nodes or array: pass as-is + insertSuccess = editor.commands.insertContentAt(insertPos, enrichedContent); + } - const success = insertSuccess; + const success = insertSuccess; - if (!success) { - return { - success: false, - error: 'Failed to insert content', - docChanged: false - }; - } + if (!success) { + return { + success: false, + error: 'Failed to insert content', + docChanged: false, + }; + } - return { - success: true, - data: { insertedAt: insertPos }, - docChanged: true, - message: `Inserted ${enrichedContent.length} node(s) at ${position}` - }; - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : 'Unknown error', - docChanged: false - }; - } + return { + success: true, + data: { insertedAt: insertPos }, + docChanged: true, + message: `Inserted ${enrichedContent.length} node(s) at ${position}`, + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + docChanged: false, + }; } + }, }; diff --git a/packages/ai/src/ai-builder/tools/readContent.ts b/packages/ai/src/ai-builder/tools/readContent.ts new file mode 100644 index 000000000..b5b515012 --- /dev/null +++ b/packages/ai/src/ai-builder/tools/readContent.ts @@ -0,0 +1,87 @@ +import type { Editor } from '../../types'; +import type { SuperDocTool, ToolResult } from '../types'; + +/** + * Params for readContent tool + */ +export interface ReadContentParams { + /** Start position (character offset) */ + from: number; + /** End position (character offset) */ + to: number; +} + +/** + * Tool for reading content at a specific position range. + * Use this after searchContent to read the actual content around a found position. + * + * @example + * // First find the position + * const searchResult = await executeTool('searchContent', { query: 'Introduction' }, editor); + * // Then read the content around it + * const content = await executeTool('readContent', { + * from: searchResult.data.matches[0].from, + * to: searchResult.data.matches[0].to + 500 // read 500 chars after + * }, editor); + */ +export const readContent: SuperDocTool = { + name: 'readContent', + description: + 'Read document content at a specific position range (from/to character offsets). Use after searchContent to read actual content at found positions.', + category: 'read', + + async execute(editor: Editor, params: ReadContentParams): Promise { + try { + const { from, to } = params; + + if (typeof from !== 'number' || typeof to !== 'number') { + return { + success: false, + error: 'Both "from" and "to" parameters must be numbers', + docChanged: false, + }; + } + + if (from < 0 || to < from) { + return { + success: false, + error: 'Invalid range: "from" must be >= 0 and "to" must be >= "from"', + docChanged: false, + }; + } + + const { state } = editor; + if (!state) { + return { + success: false, + error: 'Editor state not available', + docChanged: false, + }; + } + + // Clamp positions to document bounds + const docSize = state.doc.content.size; + const clampedFrom = Math.min(from, docSize); + const clampedTo = Math.min(to, docSize); + + const content = state.doc.cut(clampedFrom, clampedTo); + + return { + success: true, + data: { + from: clampedFrom, + to: clampedTo, + content: content.toJSON(), + }, + docChanged: false, + message: `Read content from position ${clampedFrom} to ${clampedTo}`, + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to read content', + docChanged: false, + }; + } + }, +}; diff --git a/packages/ai/src/ai-builder/tools/readSection.ts b/packages/ai/src/ai-builder/tools/readSection.ts new file mode 100644 index 000000000..1653defb4 --- /dev/null +++ b/packages/ai/src/ai-builder/tools/readSection.ts @@ -0,0 +1,146 @@ +import type { Node } from 'prosemirror-model'; +import type { Editor } from '../../types'; +import type { SuperDocTool, ToolResult } from '../types'; + +/** + * Params for readSection tool + */ +export interface ReadSectionParams { + /** Heading text to find and read (case-insensitive partial match) */ + heading?: string; + /** Start position (alternative to heading) */ + from?: number; + /** End position (alternative to heading) */ + to?: number; +} + +/** + * Tool for reading a specific section of the document by heading name. + * Use after getDocumentOutline to read content of a specific section. + * + * @example + * // Read by heading name + * const section = await executeTool('readSection', { heading: 'Introduction' }, editor); + * + * // Read by position (from outline) + * const section = await executeTool('readSection', { from: 150, to: 450 }, editor); + */ +export const readSection: SuperDocTool = { + name: 'readSection', + description: + 'Read a specific section of the document by heading name or position range. Use heading parameter to find by name, or from/to for exact positions.', + category: 'read', + + async execute(editor: Editor, params: ReadSectionParams): Promise { + try { + const { heading, from, to } = params; + const { state } = editor; + + if (!state) { + return { + success: false, + error: 'Editor state not available', + docChanged: false, + }; + } + + const doc = state.doc; + + // If from/to provided, read that range directly + if (typeof from === 'number' && typeof to === 'number') { + const clampedFrom = Math.max(0, Math.min(from, doc.content.size)); + const clampedTo = Math.max(clampedFrom, Math.min(to, doc.content.size)); + const content = doc.cut(clampedFrom, clampedTo); + + return { + success: true, + data: { + from: clampedFrom, + to: clampedTo, + content: content.toJSON(), + }, + docChanged: false, + message: `Read section from position ${clampedFrom} to ${clampedTo}`, + }; + } + + // Find section by heading name + if (!heading) { + return { + success: false, + error: 'Either "heading" or "from"/"to" parameters are required', + docChanged: false, + }; + } + + const searchTerm = heading.toLowerCase(); + let sectionStart: number | null = null; + let sectionEnd: number | null = null; + let foundHeadingLevel: number | null = null; + let foundHeadingText: string | null = null; + + // Find the heading and the next heading at same or higher level + doc.descendants((node: Node, pos: number) => { + if (node.type.name === 'paragraph') { + const styleId = node.attrs?.styleId; + if (styleId && typeof styleId === 'string') { + const match = styleId.match(/^Heading(\d)$/i); + if (match) { + const level = parseInt(match[1], 10); + let text = ''; + node.content.forEach((child: Node) => { + if (child.isText) text += child.text; + }); + + // If we haven't found our section yet, look for matching heading + if (sectionStart === null) { + if (text.toLowerCase().includes(searchTerm)) { + sectionStart = pos; + foundHeadingLevel = level; + foundHeadingText = text.trim(); + } + } else { + // We found our section, now look for end (same or higher level heading) + if (level <= foundHeadingLevel!) { + sectionEnd = pos; + return false; // stop traversal + } + } + } + } + } + return true; + }); + + if (sectionStart === null) { + return { + success: false, + error: `No heading found matching "${heading}"`, + docChanged: false, + }; + } + + // If no end found, section goes to end of document + const finalEnd = sectionEnd ?? doc.content.size; + const content = doc.cut(sectionStart, finalEnd); + + return { + success: true, + data: { + heading: foundHeadingText, + from: sectionStart, + to: finalEnd, + content: content.toJSON(), + }, + docChanged: false, + message: `Read section "${foundHeadingText}" (positions ${sectionStart}-${finalEnd})`, + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to read section', + docChanged: false, + }; + } + }, +}; diff --git a/packages/ai/src/ai-builder/tools/readSelection.ts b/packages/ai/src/ai-builder/tools/readSelection.ts index 149fd5d37..058f434a2 100644 --- a/packages/ai/src/ai-builder/tools/readSelection.ts +++ b/packages/ai/src/ai-builder/tools/readSelection.ts @@ -1,45 +1,100 @@ +import type { Node } from 'prosemirror-model'; import type { Editor } from '../../types'; import type { SuperDocTool, ToolResult } from '../types'; +/** + * Params for readSelection tool + */ +export interface ReadSelectionParams { + /** Number of paragraphs to include before and after the selection for context */ + withContext?: number; +} + /** * Tool for reading the currently selected content in the document. * Returns the selection range and the JSON representation of the selected content. + * Optionally includes surrounding paragraphs for context. + * + * @example + * // Read just the selection + * const selection = await executeTool('readSelection', {}, editor); + * + * // Read selection with 2 paragraphs before/after for context + * const selection = await executeTool('readSelection', { withContext: 2 }, editor); */ export const readSelection: SuperDocTool = { - name: 'readSelection', - description: 'Read the currently selected content in the document. Returns the selection range (from/to positions) and the JSON representation of the selected content.', - category: 'read', - - async execute(editor: Editor): Promise { - try { - const { state } = editor; - if (!state) { - return { - success: false, - error: 'Editor state not available', - docChanged: false - }; - } - - const { from, to } = state.selection; - const selectedContent = state.doc.cut(from, to); - - return { - success: true, - data: { - from, - to, - content: selectedContent.toJSON() - }, - docChanged: false, - message: `Selection from position ${from} to ${to}` - }; - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : 'Unknown error', - docChanged: false - }; - } + name: 'readSelection', + description: + 'Read the currently selected content in the document. Returns the selection range (from/to positions) and the JSON representation. Use withContext to include surrounding paragraphs.', + category: 'read', + + async execute(editor: Editor, params?: ReadSelectionParams): Promise { + try { + const { state } = editor; + if (!state) { + return { + success: false, + error: 'Editor state not available', + docChanged: false, + }; + } + + const { from, to } = state.selection; + const selectedContent = state.doc.cut(from, to); + const doc = state.doc; + + const result: { + from: number; + to: number; + content: any; + before?: any; + after?: any; + } = { + from, + to, + content: selectedContent.toJSON(), + }; + + // If withContext is specified, get surrounding paragraphs + const contextCount = params?.withContext; + if (contextCount && contextCount > 0) { + // Get paragraphs before selection + const beforeParagraphs: { position: number; content: ReturnType }[] = []; + doc.nodesBetween(0, from, (node: Node, pos: number) => { + if (node.type.name === 'paragraph') { + beforeParagraphs.push({ + position: pos, + content: node.toJSON(), + }); + } + return true; + }); + // Take the last N paragraphs before selection + result.before = beforeParagraphs.slice(-contextCount).map((p) => p.content); + + // Get paragraphs after selection + const afterParagraphs: ReturnType[] = []; + doc.nodesBetween(to, doc.content.size, (node: Node, _pos: number) => { + if (node.type.name === 'paragraph' && afterParagraphs.length < contextCount) { + afterParagraphs.push(node.toJSON()); + } + return afterParagraphs.length < contextCount; + }); + result.after = afterParagraphs; + } + + return { + success: true, + data: result, + docChanged: false, + message: `Selection from position ${from} to ${to}${contextCount ? ` with ${contextCount} paragraphs context` : ''}`, + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + docChanged: false, + }; } + }, }; diff --git a/packages/ai/src/ai-builder/tools/replaceContent.ts b/packages/ai/src/ai-builder/tools/replaceContent.ts index 53d93dc3f..2fe3dec71 100644 --- a/packages/ai/src/ai-builder/tools/replaceContent.ts +++ b/packages/ai/src/ai-builder/tools/replaceContent.ts @@ -6,12 +6,16 @@ import { enrichParagraphNodes } from '../helpers/enrichContent'; * Params for replaceContent tool */ export interface ReplaceContentParams { - /** Start position (character offset) */ - from: number; - /** End position (character offset) */ - to: number; - /** Array of content nodes to replace with (ProseMirror JSON format) */ - content: any[]; + /** Text to search for and replace (alternative to from/to positions) */ + query?: string; + /** Start position (character offset) - required if query not provided */ + from?: number; + /** End position (character offset) - required if query not provided */ + to?: number; + /** Array of content nodes to replace with (ProseMirror JSON format) */ + content: any[]; + /** Whether to replace all occurrences when using query (default: false) */ + replaceAll?: boolean; } /** @@ -19,93 +23,147 @@ export interface ReplaceContentParams { * Removes content from 'from' to 'to' positions and inserts new content. */ export const replaceContent: SuperDocTool = { - name: 'replaceContent', - description: 'Replace content in a specific range of the document. Specify from and to positions (character offsets) and provide an array of paragraph blocks to replace with.', - category: 'write', - - async execute(editor: Editor, params: ReplaceContentParams): Promise { - try { - const { from, to, content } = params; - - if (typeof from !== 'number' || typeof to !== 'number') { - return { - success: false, - error: 'From and to must be numbers', - docChanged: false - }; - } - - if (from < 0 || to < from) { - return { - success: false, - error: 'Invalid range: from must be >= 0 and to must be >= from', - docChanged: false - }; - } - - if (!content || !Array.isArray(content)) { - return { - success: false, - error: 'Content must be an array of nodes', - docChanged: false - }; - } - - // Automatically add default spacing attributes to paragraph nodes - const enrichedContent = enrichParagraphNodes(content); - - const { state } = editor; - if (!state) { - return { - success: false, - error: 'Editor state not available', - docChanged: false - }; - } - - // Clamp positions to valid document range - const docSize = state.doc.content.size; - const validFrom = Math.max(0, Math.min(from, docSize)); - const validTo = Math.max(0, Math.min(to, docSize)); - - // For full document replacement, use setContent - if (validFrom === 0 && validTo === docSize) { - const success = editor.commands.setContent({ type: 'doc', content: enrichedContent }); - - return { - success, - data: { replacedRange: { from: validFrom, to: validTo } }, - docChanged: success, - message: success ? 'Replaced entire document' : 'Failed to replace document' - }; - } - - // For partial replacement, use insertContentAt - const success = editor.commands.insertContentAt( - { from: validFrom, to: validTo }, - { type: 'doc', content: enrichedContent } - ); - - if (!success) { - return { - success: false, - error: 'Failed to replace content', - docChanged: false - }; - } - - return { - success: true, - data: { replacedRange: { from: validFrom, to: validTo } }, - docChanged: true, - message: `Replaced content from position ${validFrom} to ${validTo}` - }; - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : 'Unknown error', - docChanged: false - }; + name: 'replaceContent', + description: + 'Replace content in the document. Either provide a query to search and replace text, or specify exact from/to positions.', + category: 'write', + + async execute(editor: Editor, params: ReplaceContentParams): Promise { + try { + const { query, from, to, content, replaceAll = false } = params; + + if (!content || !Array.isArray(content)) { + return { + success: false, + error: 'Content must be an array of nodes', + docChanged: false, + }; + } + + const { state } = editor; + if (!state) { + return { + success: false, + error: 'Editor state not available', + docChanged: false, + }; + } + + // Automatically add default spacing attributes to paragraph nodes + const enrichedContent = enrichParagraphNodes(content); + + // If query is provided, search for it first + if (query) { + if (!editor.commands?.search) { + return { + success: false, + error: 'Search command not available in editor', + docChanged: false, + }; } + + const matches = editor.commands.search(query); + if (!matches || !Array.isArray(matches) || matches.length === 0) { + return { + success: false, + error: `No matches found for "${query}"`, + docChanged: false, + }; + } + + // For inline text replacement, extract text content from paragraphs + // This prevents splitting paragraphs when replacing text within them + let inlineContent = enrichedContent; + if ( + enrichedContent.length === 1 && + enrichedContent[0].type === 'paragraph' && + Array.isArray(enrichedContent[0].content) + ) { + // Extract just the inline content (text nodes) from the paragraph + inlineContent = enrichedContent[0].content; + } + + // Replace matches (in reverse order to maintain positions) + const matchesToReplace = replaceAll ? [...matches].reverse() : [matches[0]]; + let replacedCount = 0; + + for (const match of matchesToReplace) { + const success = editor.commands.insertContentAt({ from: match.from, to: match.to }, inlineContent); + if (success) replacedCount++; + } + + return { + success: replacedCount > 0, + data: { + replacedCount, + totalMatches: matches.length, + query, + }, + docChanged: replacedCount > 0, + message: `Replaced ${replacedCount} of ${matches.length} occurrence(s) of "${query}"`, + }; + } + + // Otherwise, use explicit from/to positions + if (typeof from !== 'number' || typeof to !== 'number') { + return { + success: false, + error: 'Either query or from/to positions must be provided', + docChanged: false, + }; + } + + if (from < 0 || to < from) { + return { + success: false, + error: 'Invalid range: from must be >= 0 and to must be >= from', + docChanged: false, + }; + } + + // Clamp positions to valid document range + const docSize = state.doc.content.size; + const validFrom = Math.max(0, Math.min(from, docSize)); + const validTo = Math.max(0, Math.min(to, docSize)); + + // For full document replacement, use setContent + if (validFrom === 0 && validTo === docSize) { + const success = editor.commands.setContent({ type: 'doc', content: enrichedContent }); + + return { + success, + data: { replacedRange: { from: validFrom, to: validTo } }, + docChanged: success, + message: success ? 'Replaced entire document' : 'Failed to replace document', + }; + } + + // For partial replacement, use insertContentAt + const success = editor.commands.insertContentAt( + { from: validFrom, to: validTo }, + { type: 'doc', content: enrichedContent }, + ); + + if (!success) { + return { + success: false, + error: 'Failed to replace content', + docChanged: false, + }; + } + + return { + success: true, + data: { replacedRange: { from: validFrom, to: validTo } }, + docChanged: true, + message: `Replaced content from position ${validFrom} to ${validTo}`, + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + docChanged: false, + }; } + }, }; diff --git a/packages/ai/src/ai-builder/tools/searchDocument.ts b/packages/ai/src/ai-builder/tools/searchContent.ts similarity index 84% rename from packages/ai/src/ai-builder/tools/searchDocument.ts rename to packages/ai/src/ai-builder/tools/searchContent.ts index de5ac6726..a71acbedb 100644 --- a/packages/ai/src/ai-builder/tools/searchDocument.ts +++ b/packages/ai/src/ai-builder/tools/searchContent.ts @@ -2,9 +2,9 @@ import type { Editor } from '../../types'; import type { SuperDocTool, ToolResult } from '../types'; /** - * Params for searchDocument tool + * Params for searchContent tool */ -export interface SearchDocumentParams { +export interface SearchContentParams { /** The text or pattern to search for */ query: string; /** Whether the search should be case-sensitive (default: false) */ @@ -29,31 +29,37 @@ export interface SearchMatch { /** * Tool for searching text in the document. - * Returns positions of matches that can be used with other tools like replaceContent. + * Returns positions of matches that can be used with readContent or replaceContent. * * @example * // Search for all occurrences of "privacy" - * const result = await executeTool('searchDocument', { + * const result = await executeTool('searchContent', { * query: 'privacy', * caseSensitive: false, * findAll: true * }, editor); * // Returns: { matches: [{ text: 'privacy', from: 100, to: 107 }, ...] } * - * // Then use with replaceContent: + * // Then read content around the match: + * await executeTool('readContent', { + * from: result.data.matches[0].from - 50, + * to: result.data.matches[0].to + 50 + * }, editor); + * + * // Or replace it: * await executeTool('replaceContent', { * from: result.data.matches[0].from, * to: result.data.matches[0].to, * content: [{ type: 'paragraph', content: [{ type: 'text', text: 'confidentiality' }] }] * }, editor); */ -export const searchDocument: SuperDocTool = { - name: 'searchDocument', +export const searchContent: SuperDocTool = { + name: 'searchContent', description: - 'Search for text or patterns in the document. Returns an array of matches with their positions (from/to character offsets). Use this before replaceContent to find exact positions to replace.', + 'Search for text or patterns in the document. Returns an array of matches with their positions (from/to character offsets). Use with readContent to see context or replaceContent to modify.', category: 'read', - async execute(editor: Editor, params: SearchDocumentParams): Promise { + async execute(editor: Editor, params: SearchContentParams): Promise { try { const { query, caseSensitive = false, regex = false, findAll = true } = params; diff --git a/packages/ai/src/ai-builder/types.ts b/packages/ai/src/ai-builder/types.ts index 96e62895d..2f1204bca 100644 --- a/packages/ai/src/ai-builder/types.ts +++ b/packages/ai/src/ai-builder/types.ts @@ -4,16 +4,16 @@ import type { Editor } from '../types'; * Result of executing a tool */ export interface ToolResult { - /** Whether the tool executed successfully */ - success: boolean; - /** Data returned by the tool */ - data?: any; - /** Error message if execution failed */ - error?: string; - /** Whether the document was modified */ - docChanged: boolean; - /** Optional message to send back to the AI */ - message?: string; + /** Whether the tool executed successfully */ + success: boolean; + /** Data returned by the tool */ + data?: any; + /** Error message if execution failed */ + error?: string; + /** Whether the document was modified */ + docChanged: boolean; + /** Optional message to send back to the AI */ + message?: string; } /** @@ -25,70 +25,70 @@ export type ToolCategory = 'read' | 'write' | 'navigate' | 'analyze'; * Core tool interface that all SuperDoc AI tools must implement */ export interface SuperDocTool { - /** Unique identifier for the tool */ - name: string; - /** Human-readable description of what the tool does */ - description: string; - /** Category of operation */ - category: ToolCategory; - /** Execute the tool with given parameters */ - execute: (editor: Editor, params: any) => Promise; + /** Unique identifier for the tool */ + name: string; + /** Human-readable description of what the tool does */ + description: string; + /** Category of operation */ + category: ToolCategory; + /** Execute the tool with given parameters */ + execute: (editor: Editor, params: any) => Promise; } /** * Options for filtering which tools and features to include */ export interface ToolDefinitionsOptions { - /** List of tool names to enable (if undefined, all are enabled) */ - enabledTools?: string[]; - /** Node types to exclude (all others from extensions are included) */ - excludedNodes?: string[]; - /** Mark types to exclude (all others from extensions are included) */ - excludedMarks?: string[]; - /** Attribute names to exclude */ - excludedAttrs?: string[]; - /** Whether to use strict mode (for providers that support it) */ - strict?: boolean; + /** List of tool names to enable (if undefined, all are enabled) */ + enabledTools?: string[]; + /** Node types to exclude (all others from extensions are included) */ + excludedNodes?: string[]; + /** Mark types to exclude (all others from extensions are included) */ + excludedMarks?: string[]; + /** Attribute names to exclude */ + excludedAttrs?: string[]; + /** Whether to use strict mode (for providers that support it) */ + strict?: boolean; } /** * Options for tool execution */ export interface ExecuteToolOptions { - /** Whether to validate params before execution */ - validate?: boolean; - /** Callback for progress updates during execution */ - onProgress?: (progress: number) => void; - /** Abort signal for cancellation */ - signal?: AbortSignal; + /** Whether to validate params before execution */ + validate?: boolean; + /** Callback for progress updates during execution */ + onProgress?: (progress: number) => void; + /** Abort signal for cancellation */ + signal?: AbortSignal; } /** * Generic tool schema format (provider-agnostic) */ export interface GenericToolSchema { - name: string; - description: string; - parameters: { - type: 'object'; - properties: Record; - required?: string[]; - additionalProperties?: boolean; - }; + name: string; + description: string; + parameters: { + type: 'object'; + properties: Record; + required?: string[]; + additionalProperties?: boolean; + }; } /** * Anthropic-specific tool format */ export interface AnthropicTool { - name: string; - description: string; - input_schema: { - type: 'object'; - properties: Record; - required?: string[]; - additionalProperties?: boolean; - }; + name: string; + description: string; + input_schema: { + type: 'object'; + properties: Record; + required?: string[]; + additionalProperties?: boolean; + }; } /** diff --git a/packages/ai/tsup.config.ts b/packages/ai/tsup.config.ts index cbe2261a1..0c056cc80 100644 --- a/packages/ai/tsup.config.ts +++ b/packages/ai/tsup.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from 'tsup'; export default defineConfig({ entry: ['src/index.ts'], - format: ['cjs'], + format: ['esm'], dts: true, clean: true, // Always clean dist folder before build minify: true, // Minify the output