diff --git a/package-lock.json b/package-lock.json index 6fde221..792ffbd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,11 @@ "@modelcontextprotocol/sdk": "^1.29.0", "@radix-ui/react-use-controllable-state": "^1.2.2", "@tabler/icons-react": "^3.41.1", + "@tiptap/extension-link": "^2.27.2", + "@tiptap/extension-underline": "^2.27.2", + "@tiptap/pm": "^2.27.2", + "@tiptap/react": "^2.27.2", + "@tiptap/starter-kit": "^2.27.2", "@xyflow/react": "^12.10.2", "ai": "^6.0.180", "bcryptjs": "^3.0.3", @@ -779,7 +784,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1315,7 +1319,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -1381,7 +1384,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" @@ -1393,7 +1395,6 @@ "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -3651,7 +3652,6 @@ "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", "license": "MIT", - "peer": true, "engines": { "node": "^14.21.3 || >=16" }, @@ -3799,7 +3799,6 @@ "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "playwright": "1.60.0" }, @@ -3810,6 +3809,16 @@ "node": ">=18" } }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, "node_modules/@radix-ui/number": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", @@ -6223,6 +6232,12 @@ "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", "license": "MIT" }, + "node_modules/@remirror/core-constants": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz", + "integrity": "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==", + "license": "MIT" + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz", @@ -6896,6 +6911,72 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.10.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.10.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz", @@ -6957,6 +7038,421 @@ "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" } }, + "node_modules/@tiptap/core": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.2.tgz", + "integrity": "sha512-ABL1N6eoxzDzC1bYvkMbvyexHacszsKdVPYqhl5GwHLOvpZcv9VE9QaKwDILTyz5voCA0lGcAAXZp+qnXOk5lQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-blockquote": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-2.27.2.tgz", + "integrity": "sha512-oIGZgiAeA4tG3YxbTDfrmENL4/CIwGuP3THtHsNhwRqwsl9SfMk58Ucopi2GXTQSdYXpRJ0ahE6nPqB5D6j/Zw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-bold": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-2.27.2.tgz", + "integrity": "sha512-bR7J5IwjCGQ0s3CIxyMvOCnMFMzIvsc5OVZKscTN5UkXzFsaY6muUAIqtKxayBUucjtUskm5qZowJITCeCb1/A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-bubble-menu": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-2.27.2.tgz", + "integrity": "sha512-VkwlCOcr0abTBGzjPXklJ92FCowG7InU8+Od9FyApdLNmn0utRYGRhw0Zno6VgE9EYr1JY4BRnuSa5f9wlR72w==", + "license": "MIT", + "dependencies": { + "tippy.js": "^6.3.7" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-bullet-list": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-2.27.2.tgz", + "integrity": "sha512-gmFuKi97u5f8uFc/GQs+zmezjiulZmFiDYTh3trVoLRoc2SAHOjGEB7qxdx7dsqmMN7gwiAWAEVurLKIi1lnnw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-code": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-2.27.2.tgz", + "integrity": "sha512-7X9AgwqiIGXoZX7uvdHQsGsjILnN/JaEVtqfXZnPECzKGaWHeK/Ao4sYvIIIffsyZJA8k5DC7ny2/0sAgr2TuA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-code-block": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-2.27.2.tgz", + "integrity": "sha512-KgvdQHS4jXr79aU3wZOGBIZYYl9vCB7uDEuRFV4so2rYrfmiYMw3T8bTnlNEEGe4RUeAms1i4fdwwvQp9nR1Dw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-document": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-2.27.2.tgz", + "integrity": "sha512-CFhAYsPnyYnosDC4639sCJnBUnYH4Cat9qH5NZWHVvdgtDwu8GZgZn2eSzaKSYXWH1vJ9DSlCK+7UyC3SNXIBA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-dropcursor": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-2.27.2.tgz", + "integrity": "sha512-oEu/OrktNoQXq1x29NnH/GOIzQZm8ieTQl3FK27nxfBPA89cNoH4mFEUmBL5/OFIENIjiYG3qWpg6voIqzswNw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-floating-menu": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-2.27.2.tgz", + "integrity": "sha512-GUN6gPIGXS7ngRJOwdSmtBRBDt9Kt9CM/9pSwKebhLJ+honFoNA+Y6IpVyDvvDMdVNgBchiJLs6qA5H97gAePQ==", + "license": "MIT", + "dependencies": { + "tippy.js": "^6.3.7" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-gapcursor": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-2.27.2.tgz", + "integrity": "sha512-/c9VF1HBxj+AP54XGVgCmD9bEGYc5w5OofYCFQgM7l7PB1J00A4vOke0oPkHJnqnOOyPlFaxO/7N6l3XwFcnKA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-hard-break": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-2.27.2.tgz", + "integrity": "sha512-kSRVGKlCYK6AGR0h8xRkk0WOFGXHIIndod3GKgWU49APuIGDiXd8sziXsSlniUsWmqgDmDXcNnSzPcV7AQ8YNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-heading": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-2.27.2.tgz", + "integrity": "sha512-iM3yeRWuuQR/IRQ1djwNooJGfn9Jts9zF43qZIUf+U2NY8IlvdNsk2wTOdBgh6E0CamrStPxYGuln3ZS4fuglw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-history": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-history/-/extension-history-2.27.2.tgz", + "integrity": "sha512-+hSyqERoFNTWPiZx4/FCyZ/0eFqB9fuMdTB4AC/q9iwu3RNWAQtlsJg5230bf/qmyO6bZxRUc0k8p4hrV6ybAw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-horizontal-rule": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-2.27.2.tgz", + "integrity": "sha512-WGWUSgX+jCsbtf9Y9OCUUgRZYuwjVoieW5n6mAUohJ9/6gc6sGIOrUpBShf+HHo6WD+gtQjRd+PssmX3NPWMpg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-italic": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-2.27.2.tgz", + "integrity": "sha512-1OFsw2SZqfaqx5Fa5v90iNlPRcqyt+lVSjBwTDzuPxTPFY4Q0mL89mKgkq2gVHYNCiaRkXvFLDxaSvBWbmthgg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-link": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-2.27.2.tgz", + "integrity": "sha512-bnP61qkr0Kj9Cgnop1hxn2zbOCBzNtmawxr92bVTOE31fJv6FhtCnQiD6tuPQVGMYhcmAj7eihtvuEMFfqEPcQ==", + "license": "MIT", + "dependencies": { + "linkifyjs": "^4.3.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-list-item": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-2.27.2.tgz", + "integrity": "sha512-eJNee7IEGXMnmygM5SdMGDC8m/lMWmwNGf9fPCK6xk0NxuQRgmZHL6uApKcdH6gyNcRPHCqvTTkhEP7pbny/fg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-ordered-list": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-2.27.2.tgz", + "integrity": "sha512-M7A4tLGJcLPYdLC4CI2Gwl8LOrENQW59u3cMVa+KkwG1hzSJyPsbDpa1DI6oXPC2WtYiTf22zrbq3gVvH+KA2w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-paragraph": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-2.27.2.tgz", + "integrity": "sha512-elYVn2wHJJ+zB9LESENWOAfI4TNT0jqEN34sMA/hCtA4im1ZG2DdLHwkHIshj/c4H0dzQhmsS/YmNC5Vbqab/A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-strike": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-2.27.2.tgz", + "integrity": "sha512-HHIjhafLhS2lHgfAsCwC1okqMsQzR4/mkGDm4M583Yftyjri1TNA7lzhzXWRFWiiMfJxKtdjHjUAQaHuteRTZw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-text": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-2.27.2.tgz", + "integrity": "sha512-Xk7nYcigljAY0GO9hAQpZ65ZCxqOqaAlTPDFcKerXmlkQZP/8ndx95OgUb1Xf63kmPOh3xypurGS2is3v0MXSA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-text-style": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-2.27.2.tgz", + "integrity": "sha512-Omk+uxjJLyEY69KStpCw5fA9asvV+MGcAX2HOxyISDFoLaL49TMrNjhGAuz09P1L1b0KGXo4ml7Q3v/Lfy4WPA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-underline": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-2.27.2.tgz", + "integrity": "sha512-gPOsbAcw1S07ezpAISwoO8f0RxpjcSH7VsHEFDVuXm4ODE32nhvSinvHQjv2icRLOXev+bnA7oIBu7Oy859gWQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/pm": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.27.2.tgz", + "integrity": "sha512-kaEg7BfiJPDQMKbjVIzEPO3wlcA+pZb2tlcK9gPrdDnEFaec2QTF1sXz2ak2IIb2curvnIrQ4yrfHgLlVA72wA==", + "license": "MIT", + "dependencies": { + "prosemirror-changeset": "^2.3.0", + "prosemirror-collab": "^1.3.1", + "prosemirror-commands": "^1.6.2", + "prosemirror-dropcursor": "^1.8.1", + "prosemirror-gapcursor": "^1.3.2", + "prosemirror-history": "^1.4.1", + "prosemirror-inputrules": "^1.4.0", + "prosemirror-keymap": "^1.2.2", + "prosemirror-markdown": "^1.13.1", + "prosemirror-menu": "^1.2.4", + "prosemirror-model": "^1.23.0", + "prosemirror-schema-basic": "^1.2.3", + "prosemirror-schema-list": "^1.4.1", + "prosemirror-state": "^1.4.3", + "prosemirror-tables": "^1.6.4", + "prosemirror-trailing-node": "^3.0.0", + "prosemirror-transform": "^1.10.2", + "prosemirror-view": "^1.37.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, + "node_modules/@tiptap/react": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/react/-/react-2.27.2.tgz", + "integrity": "sha512-0EAs8Cpkfbvben1PZ34JN2Nd79Dhioynm2jML27DBbf1VWPk+FFWFGTMLUT0bu+Np5iVxio8fqV9t0mc4D6thA==", + "license": "MIT", + "dependencies": { + "@tiptap/extension-bubble-menu": "^2.27.2", + "@tiptap/extension-floating-menu": "^2.27.2", + "@types/use-sync-external-store": "^0.0.6", + "fast-deep-equal": "^3", + "use-sync-external-store": "^1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tiptap/starter-kit": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-2.27.2.tgz", + "integrity": "sha512-bb0gJvPoDuyRUQ/iuN52j1//EtWWttw+RXAv1uJxfR0uKf8X7uAqzaOOgwjknoCIDC97+1YHwpGdnRjpDkOBxw==", + "license": "MIT", + "dependencies": { + "@tiptap/core": "^2.27.2", + "@tiptap/extension-blockquote": "^2.27.2", + "@tiptap/extension-bold": "^2.27.2", + "@tiptap/extension-bullet-list": "^2.27.2", + "@tiptap/extension-code": "^2.27.2", + "@tiptap/extension-code-block": "^2.27.2", + "@tiptap/extension-document": "^2.27.2", + "@tiptap/extension-dropcursor": "^2.27.2", + "@tiptap/extension-gapcursor": "^2.27.2", + "@tiptap/extension-hard-break": "^2.27.2", + "@tiptap/extension-heading": "^2.27.2", + "@tiptap/extension-history": "^2.27.2", + "@tiptap/extension-horizontal-rule": "^2.27.2", + "@tiptap/extension-italic": "^2.27.2", + "@tiptap/extension-list-item": "^2.27.2", + "@tiptap/extension-ordered-list": "^2.27.2", + "@tiptap/extension-paragraph": "^2.27.2", + "@tiptap/extension-strike": "^2.27.2", + "@tiptap/extension-text": "^2.27.2", + "@tiptap/extension-text-style": "^2.27.2", + "@tiptap/pm": "^2.27.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, "node_modules/@ts-morph/common": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.27.0.tgz", @@ -7157,6 +7653,22 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", + "license": "MIT" + }, + "node_modules/@types/markdown-it": { + "version": "14.1.2", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", + "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", + "license": "MIT", + "dependencies": { + "@types/linkify-it": "^5", + "@types/mdurl": "^2" + } + }, "node_modules/@types/mdast": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", @@ -7166,7 +7678,13 @@ "@types/unist": "*" } }, - "node_modules/@types/ms": { + "node_modules/@types/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", + "license": "MIT" + }, + "node_modules/@types/ms": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", @@ -7206,7 +7724,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz", "integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -7217,7 +7734,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -7243,6 +7759,12 @@ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "license": "MIT" }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@types/validate-npm-package-name": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz", @@ -7294,7 +7816,6 @@ "integrity": "sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.59.4", "@typescript-eslint/types": "8.59.4", @@ -7879,7 +8400,6 @@ "integrity": "sha512-qsYPeXc5Q9dFLd1i8Ap+Bx8sQgcp+rFVQo4R0dDsWNBzl26ldVF1qOO+RL24K7FDrR6pA+50XedRLSoSG24bVQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.7", @@ -8051,7 +8571,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -8449,7 +8968,6 @@ "integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/types": "^7.26.0" } @@ -8608,7 +9126,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -9090,6 +9607,12 @@ } } }, + "node_modules/crelt": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -9179,7 +9702,6 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -9813,6 +10335,18 @@ "node": ">=8.6" } }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/env-paths": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", @@ -10073,7 +10607,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -10088,7 +10621,6 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -10274,7 +10806,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -10618,7 +11149,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -11048,7 +11578,6 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -11494,7 +12023,6 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.21.tgz", "integrity": "sha512-uV63apnb0kyPtAUwoWgaGh9HyIFcv8lgmzPZSiTBQAFOFGIzka5EZ1dZocmGnn0XdX0+XTqJ6Tqv7selMuGLRQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=16.9.0" } @@ -12875,6 +13403,31 @@ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "license": "MIT" }, + "node_modules/linkify-it": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.1.tgz", + "integrity": "sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, + "node_modules/linkifyjs": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz", + "integrity": "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==", + "license": "MIT" + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -13100,6 +13653,33 @@ "sprintf-js": "~1.0.2" } }, + "node_modules/markdown-it": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.2.0.tgz", + "integrity": "sha512-1TGiQiJVRQ3NPmZH6sx5Cfnmg6GQm9jvC1ch4TK511NjSJvjzKLzn5pPfZRNZkRPZP0HqCioSndqH8v2nRaWVQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.4.0", + "linkify-it": "^5.0.1", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, "node_modules/markdown-table": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", @@ -13401,6 +13981,12 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", + "license": "MIT" + }, "node_modules/media-typer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", @@ -14278,7 +14864,6 @@ "resolved": "https://registry.npmjs.org/next/-/next-16.2.6.tgz", "integrity": "sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw==", "license": "MIT", - "peer": true, "dependencies": { "@next/env": "16.2.6", "@swc/helpers": "0.5.15", @@ -14756,6 +15341,12 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/orderedmap": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz", + "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==", + "license": "MIT" + }, "node_modules/outvariant": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", @@ -15151,7 +15742,6 @@ "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.9.tgz", "integrity": "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==", "license": "Unlicense", - "peer": true, "engines": { "node": ">=12" }, @@ -15177,7 +15767,6 @@ "resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz", "integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==", "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" @@ -15267,6 +15856,201 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/prosemirror-changeset": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.1.tgz", + "integrity": "sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw==", + "license": "MIT", + "dependencies": { + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-collab": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/prosemirror-collab/-/prosemirror-collab-1.3.1.tgz", + "integrity": "sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0" + } + }, + "node_modules/prosemirror-commands": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz", + "integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.10.2" + } + }, + "node_modules/prosemirror-dropcursor": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz", + "integrity": "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0", + "prosemirror-view": "^1.1.0" + } + }, + "node_modules/prosemirror-gapcursor": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz", + "integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.0.0", + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-view": "^1.0.0" + } + }, + "node_modules/prosemirror-history": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz", + "integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.2.2", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.31.0", + "rope-sequence": "^1.3.0" + } + }, + "node_modules/prosemirror-inputrules": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz", + "integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-keymap": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz", + "integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "w3c-keyname": "^2.2.0" + } + }, + "node_modules/prosemirror-markdown": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/prosemirror-markdown/-/prosemirror-markdown-1.13.4.tgz", + "integrity": "sha512-D98dm4cQ3Hs6EmjK500TdAOew4Z03EV71ajEFiWra3Upr7diytJsjF4mPV2dW+eK5uNectiRj0xFxYI9NLXDbw==", + "license": "MIT", + "dependencies": { + "@types/markdown-it": "^14.0.0", + "markdown-it": "^14.0.0", + "prosemirror-model": "^1.25.0" + } + }, + "node_modules/prosemirror-menu": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/prosemirror-menu/-/prosemirror-menu-1.3.2.tgz", + "integrity": "sha512-6VgUJTYod0nMBlCaYJGhXGLu7Gt4AvcwcOq0YfJCY/6Uh+3S7UsWhpy6rJFCBFOmonq1hD8KyWOtZhkppd4YPg==", + "license": "MIT", + "dependencies": { + "crelt": "^1.0.0", + "prosemirror-commands": "^1.0.0", + "prosemirror-history": "^1.0.0", + "prosemirror-state": "^1.0.0" + } + }, + "node_modules/prosemirror-model": { + "version": "1.25.7", + "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.7.tgz", + "integrity": "sha512-A79aN8QEFUwI6cax8Yq4Rpcx1TJZ3Kagn+ii7qLo4/V8H3mMiHrhFyhTyHHvpSnOgMPpWiDGSwM3etwrxE50ug==", + "license": "MIT", + "dependencies": { + "orderedmap": "^2.0.0" + } + }, + "node_modules/prosemirror-schema-basic": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/prosemirror-schema-basic/-/prosemirror-schema-basic-1.2.4.tgz", + "integrity": "sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.25.0" + } + }, + "node_modules/prosemirror-schema-list": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz", + "integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.7.3" + } + }, + "node_modules/prosemirror-state": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", + "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.27.0" + } + }, + "node_modules/prosemirror-tables": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz", + "integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.4", + "prosemirror-state": "^1.4.4", + "prosemirror-transform": "^1.10.5", + "prosemirror-view": "^1.41.4" + } + }, + "node_modules/prosemirror-trailing-node": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/prosemirror-trailing-node/-/prosemirror-trailing-node-3.0.0.tgz", + "integrity": "sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==", + "license": "MIT", + "dependencies": { + "@remirror/core-constants": "3.0.0", + "escape-string-regexp": "^4.0.0" + }, + "peerDependencies": { + "prosemirror-model": "^1.22.1", + "prosemirror-state": "^1.4.2", + "prosemirror-view": "^1.33.8" + } + }, + "node_modules/prosemirror-transform": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.0.tgz", + "integrity": "sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.21.0" + } + }, + "node_modules/prosemirror-view": { + "version": "1.41.8", + "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.8.tgz", + "integrity": "sha512-TnKDdohEatgyZNGCDWIdccOHXhYloJwbwU+phw/a23KBvJIR9lWQWW7WHHK3vBdOLDNuF7TaX98GObUZOWkOnA==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.20.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -15290,6 +16074,15 @@ "node": ">=6" } }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/qrcode.react": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", @@ -15463,7 +16256,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -15473,7 +16265,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -15908,6 +16699,12 @@ "@rolldown/binding-win32-x64-msvc": "1.0.2" } }, + "node_modules/rope-sequence": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz", + "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==", + "license": "MIT" + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -16584,6 +17381,15 @@ "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", "license": "MIT" }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/string-width": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", @@ -16747,15 +17553,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.1.0" - } - }, "node_modules/stringify-entities": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", @@ -16952,8 +17749,7 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz", "integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/tapable": { "version": "2.3.3", @@ -17051,7 +17847,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -17069,6 +17864,15 @@ "node": ">=14.0.0" } }, + "node_modules/tippy.js": { + "version": "6.3.7", + "resolved": "https://registry.npmjs.org/tippy.js/-/tippy.js-6.3.7.tgz", + "integrity": "sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==", + "license": "MIT", + "dependencies": { + "@popperjs/core": "^2.9.0" + } + }, "node_modules/tldts": { "version": "7.0.30", "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.30.tgz", @@ -17874,7 +18678,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -17907,6 +18710,12 @@ "typescript": ">=4.8.4 <6.1.0" } }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "license": "MIT" + }, "node_modules/unbox-primitive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", @@ -18278,7 +19087,6 @@ "integrity": "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.1.7", "@vitest/mocker": "4.1.7", @@ -18363,510 +19171,67 @@ } } }, - "node_modules/vitest/node_modules/@esbuild/aix-ppc64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", - "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", - "cpu": [ - "ppc64" - ], + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.7.tgz", + "integrity": "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" + "dependencies": { + "@vitest/spy": "4.1.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } } }, - "node_modules/vitest/node_modules/@esbuild/android-arm": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", - "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", - "cpu": [ - "arm" - ], + "node_modules/vitest/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "dev": true, + "hasInstallScript": true, "license": "MIT", "optional": true, "os": [ - "android" + "darwin" ], "engines": { - "node": ">=18" + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/vitest/node_modules/@esbuild/android-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", - "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", - "cpu": [ - "arm64" - ], + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "android" - ], "engines": { - "node": ">=18" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/vitest/node_modules/@esbuild/android-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", - "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", - "cpu": [ - "x64" - ], + "node_modules/vitest/node_modules/vite": { + "version": "8.0.14", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz", + "integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/darwin-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", - "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/darwin-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", - "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", - "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/freebsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", - "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-arm": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", - "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", - "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-ia32": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", - "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-loong64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", - "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-mips64el": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", - "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-ppc64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", - "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-riscv64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", - "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-s390x": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", - "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", - "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", - "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/netbsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", - "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", - "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/openbsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", - "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", - "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/sunos-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", - "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/win32-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", - "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/win32-ia32": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", - "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/win32-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", - "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@vitest/mocker": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.7.tgz", - "integrity": "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "4.1.7", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.21" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, - "node_modules/vitest/node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/vitest/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/vitest/node_modules/vite": { - "version": "8.0.14", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz", - "integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==", - "dev": true, - "license": "MIT", - "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", @@ -18939,6 +19304,12 @@ } } }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, "node_modules/warning": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", @@ -19303,7 +19674,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 6ace0f2..f760a50 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,11 @@ "@modelcontextprotocol/sdk": "^1.29.0", "@radix-ui/react-use-controllable-state": "^1.2.2", "@tabler/icons-react": "^3.41.1", + "@tiptap/extension-link": "^2.27.2", + "@tiptap/extension-underline": "^2.27.2", + "@tiptap/pm": "^2.27.2", + "@tiptap/react": "^2.27.2", + "@tiptap/starter-kit": "^2.27.2", "@xyflow/react": "^12.10.2", "ai": "^6.0.180", "bcryptjs": "^3.0.3", diff --git a/src/app/(app)/admin/cabinet/actions.ts b/src/app/(app)/admin/cabinet/actions.ts index 9e98ece..2cd38ca 100644 --- a/src/app/(app)/admin/cabinet/actions.ts +++ b/src/app/(app)/admin/cabinet/actions.ts @@ -1,5 +1,7 @@ "use server"; +import type { ActionResult as BaseActionResult } from "@/lib/actions/result"; + import { revalidatePath } from "next/cache"; import { eq } from "drizzle-orm"; import { z } from "zod"; @@ -14,9 +16,7 @@ const schema = z.object({ legalDisclaimer: z.string().trim().max(1000), }); -export type ActionResult = - | { ok: true } - | { ok: false; error: string }; +export type ActionResult = BaseActionResult; export async function updateCabinetSettings( _prev: ActionResult | null, diff --git a/src/app/(app)/admin/users/actions.ts b/src/app/(app)/admin/users/actions.ts index 8837d47..35a40a3 100644 --- a/src/app/(app)/admin/users/actions.ts +++ b/src/app/(app)/admin/users/actions.ts @@ -1,5 +1,7 @@ "use server"; +import type { ActionResult as BaseActionResult } from "@/lib/actions/result"; + import { revalidatePath } from "next/cache"; import { and, eq, ne, sql } from "drizzle-orm"; import { z } from "zod"; @@ -16,7 +18,7 @@ const createSchema = z.object({ role: z.enum(["admin", "member"]), }); -export type ActionResult = { ok: true } | { ok: false; error: string }; +export type ActionResult = BaseActionResult; export async function createUser( _prev: ActionResult | null, diff --git a/src/app/(app)/board/actions.ts b/src/app/(app)/board/actions.ts index 23248df..f20b3a2 100644 --- a/src/app/(app)/board/actions.ts +++ b/src/app/(app)/board/actions.ts @@ -1,9 +1,11 @@ "use server"; +import type { ActionResult as BaseActionResult } from "@/lib/actions/result"; + import { revalidatePath } from "next/cache"; import { and, asc, eq, sql } from "drizzle-orm"; import { z } from "zod"; -import { auth } from "@/auth"; +import { requireUserId } from "@/lib/auth/permissions"; import { db } from "@/db"; import { pipelineAgents, @@ -13,16 +15,8 @@ import { } from "@/db/schema"; import { findPreset, seedPresetsForUser } from "@/lib/orchestrator"; -export type ActionResult = { ok: true } | { ok: false; error: string }; -export type ActionResultWith = - | ({ ok: true } & T) - | { ok: false; error: string }; - -async function requireUserId(): Promise { - const session = await auth(); - if (!session?.user?.id) throw new Error("Unauthorized"); - return session.user.id; -} +export type ActionResult = BaseActionResult; +export type ActionResultWith = BaseActionResult; const pipelineMetaSchema = z.object({ name: z.string().trim().min(1).max(120), @@ -136,34 +130,39 @@ export async function clonePipeline( const baseName = newName ?? `${source.pipeline.name} (copie)`; const slug = await uniqueSlug(userId, source.pipeline.slug); - const [created] = await db - .insert(pipelines) - .values({ - userId, - slug, - name: baseName, - description: source.pipeline.description, - isPreset: false, - }) - .returning({ id: pipelines.id }); - - if (source.agents.length > 0) { - await db.insert(pipelineAgents).values( - source.agents.map((a, i) => ({ - pipelineId: created.id, - role: a.role, - label: a.label, - providerKeyId: a.providerKeyId, - modelOverride: a.modelOverride, - systemPrompt: a.systemPrompt, - toolAllowlist: a.toolAllowlist, - position: i, - })) - ); - } + // Transaction : pipeline + ses agents insérés atomiquement, sinon un échec + // après l'insert du pipeline laisse un pipeline sans aucun agent. + const newId = await db.transaction(async (tx) => { + const [created] = await tx + .insert(pipelines) + .values({ + userId, + slug, + name: baseName, + description: source.pipeline.description, + isPreset: false, + }) + .returning({ id: pipelines.id }); + + if (source.agents.length > 0) { + await tx.insert(pipelineAgents).values( + source.agents.map((a, i) => ({ + pipelineId: created.id, + role: a.role, + label: a.label, + providerKeyId: a.providerKeyId, + modelOverride: a.modelOverride, + systemPrompt: a.systemPrompt, + toolAllowlist: a.toolAllowlist, + position: i, + })) + ); + } + return created.id; + }); revalidatePath("/board"); - return { ok: true, id: created.id }; + return { ok: true, id: newId }; } export async function clonePresetBySlug( @@ -175,37 +174,41 @@ export async function clonePresetBySlug( const newSlug = await uniqueSlug(userId, slug); - const [created] = await db - .insert(pipelines) - .values({ - userId, - slug: newSlug, - name: `${preset.name} (copie)`, - description: preset.description, - isPreset: false, - mode: preset.mode ?? "sequential", - rounds: preset.rounds ?? 1, - }) - .returning({ id: pipelines.id }); - - if (preset.agents.length > 0) { - await db.insert(pipelineAgents).values( - preset.agents.map((a, i) => ({ - pipelineId: created.id, - role: a.role, - label: a.label, - providerKeyId: null, - modelOverride: null, - systemPrompt: a.systemPrompt ?? null, - toolAllowlist: - a.toolAllowlist === undefined ? null : a.toolAllowlist, - position: i, - })) - ); - } + // Transaction : pipeline + agents atomiques (cf. clonePipeline). + const newId = await db.transaction(async (tx) => { + const [created] = await tx + .insert(pipelines) + .values({ + userId, + slug: newSlug, + name: `${preset.name} (copie)`, + description: preset.description, + isPreset: false, + mode: preset.mode ?? "sequential", + rounds: preset.rounds ?? 1, + }) + .returning({ id: pipelines.id }); + + if (preset.agents.length > 0) { + await tx.insert(pipelineAgents).values( + preset.agents.map((a, i) => ({ + pipelineId: created.id, + role: a.role, + label: a.label, + providerKeyId: null, + modelOverride: null, + systemPrompt: a.systemPrompt ?? null, + toolAllowlist: + a.toolAllowlist === undefined ? null : a.toolAllowlist, + position: i, + })) + ); + } + return created.id; + }); revalidatePath("/board"); - return { ok: true, id: created.id }; + return { ok: true, id: newId }; } export async function updatePipelineMeta( diff --git a/src/app/(app)/chat/actions.ts b/src/app/(app)/chat/actions.ts index 7d9c34d..af5a83b 100644 --- a/src/app/(app)/chat/actions.ts +++ b/src/app/(app)/chat/actions.ts @@ -2,20 +2,14 @@ import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; -import { and, asc, eq, gt } from "drizzle-orm"; +import { and, asc, eq, gt, inArray } from "drizzle-orm"; import { z } from "zod"; -import { auth } from "@/auth"; +import { requireUserId } from "@/lib/auth/permissions"; import { db } from "@/db"; import { agentRuns, conversations, messages } from "@/db/schema"; const titleSchema = z.string().trim().min(1).max(120); -async function requireUserId(): Promise { - const session = await auth(); - if (!session?.user?.id) throw new Error("Unauthorized"); - return session.user.id; -} - export async function renameConversation( id: string, title: string @@ -113,27 +107,40 @@ export async function editUserMessageAndTrim( return { ok: false, error: "Seuls les messages utilisateur sont éditables." }; } - // Drop tout ce qui a été écrit après le message édité (réponses - // assistant, tool calls, agent events…). Comparaison stricte sur - // createdAt pour conserver le message lui-même. - await db - .delete(messages) - .where( - and( - eq(messages.conversationId, conversationId), - gt(messages.createdAt, target.createdAt) + // Transaction : élagage + édition doivent être atomiques. Sans ça, un crash + // entre le delete et l'update tronquerait l'historique tout en perdant + // l'édition. On supprime aussi le trail d'audit des messages élagués + // (agent_runs.messageId est ON DELETE SET NULL → suppression explicite). + await db.transaction(async (tx) => { + // Drop tout ce qui a été écrit après le message édité (réponses + // assistant, tool calls, agent events…). Comparaison stricte sur + // createdAt pour conserver le message lui-même. + const removed = await tx + .delete(messages) + .where( + and( + eq(messages.conversationId, conversationId), + gt(messages.createdAt, target.createdAt) + ) ) - ); - - await db - .update(messages) - .set({ content: parsed.data }) - .where(eq(messages.id, messageId)); - - await db - .update(conversations) - .set({ updatedAt: new Date() }) - .where(eq(conversations.id, conversationId)); + .returning({ id: messages.id }); + if (removed.length > 0) { + await tx.delete(agentRuns).where( + inArray( + agentRuns.messageId, + removed.map((r) => r.id) + ) + ); + } + await tx + .update(messages) + .set({ content: parsed.data }) + .where(eq(messages.id, messageId)); + await tx + .update(conversations) + .set({ updatedAt: new Date() }) + .where(eq(conversations.id, conversationId)); + }); revalidatePath("/chat"); return { ok: true }; diff --git a/src/app/(app)/chat/chat-shell.tsx b/src/app/(app)/chat/chat-shell.tsx index 25d072e..5a456ce 100644 --- a/src/app/(app)/chat/chat-shell.tsx +++ b/src/app/(app)/chat/chat-shell.tsx @@ -46,7 +46,11 @@ import { import { ModelPicker } from "./model-picker"; import { editUserMessageAndTrim } from "./actions"; import { uiPartsFromSaved } from "@/lib/ai/saved-parts"; -import type { SavedPart } from "@/db/schema/messages"; +import { + unwrapToolResult, + type DocumentArtifactMeta, +} from "@/lib/ai/tool-result"; +import type { SavedPart, MessageMetadata } from "@/db/schema/messages"; import { DocPanel } from "./doc-panel"; import { EditCard } from "./edit-card"; import { @@ -67,6 +71,7 @@ import { IconFileTypeDocx, IconAlertTriangle, IconPencil, + IconEye, } from "@tabler/icons-react"; import { Popover, @@ -228,8 +233,22 @@ function linkifyDocMentions( let out = raw; for (const d of sorted) { if (!d.filename) continue; - const pattern = new RegExp(`(?; }; +type DocCard = + | { variant: "edited"; documentId: string; doc: EditedDocument } + | { + variant: "download"; + documentId: string; + filename: string; + format: "docx" | "pdf"; + title: string; + }; + /** - * L'AI SDK v6 emballe la sortie d'un tool dans `{type: "json", value: {...}}` - * (ou `{type: "text", text: "..."}` pour les retours scalaires). Notre tool - * renvoie ensuite une envelope ToolResult `{ok: true, data: {...}}`. On - * unwrap successivement ces couches pour récupérer la `data` métier. - * - * Cas gérés : - * - { type: "json", value: { ok: true, data: T } } ← AI SDK + ToolResult - * - { type: "text", text: "" } ← AI SDK provider-executed - * - { ok: true, data: T } ← envelope brute - * - T ← objet déjà dépouillé - * - "" ← string serialisée + * Construit les cartes d'artefact document d'un message assistant (façon + * Claude/Sana), dédupliquées par documentId. Trois sources, par priorité : + * 1. Tool parts LIVE (pendant le stream) → carte la plus riche (édition avec + * ses détails de changes). + * 2. metadata PERSISTÉE (messages.metadata.documents) → survit au remount de + * ChatShell (key=?id au 1er message) ET au reload dur, indépendamment de + * ce que le modèle écrit. C'est la source de vérité quand les tool parts + * ne se reconstruisent pas de façon fiable depuis la DB. + * 3. Filet ultime : un document CONNU cité par son nom de fichier dans le + * texte (legacy / messages sans metadata). */ -function unwrapToolResult(o: unknown): T | null { - if (!o) return null; - let candidate: unknown = o; - - // Couche 1 : si string, parse en JSON - if (typeof candidate === "string") { - try { - candidate = JSON.parse(candidate); - } catch { - return null; +function buildDocCards( + parts: { type: string; output?: unknown }[], + persisted: DocumentArtifactMeta[], + text: string, + knownDocs: { id: string; filename: string }[] +): DocCard[] { + const byId = new Map(); + + // 1. Tool parts live (les plus riches). + for (const p of parts) { + if (p.type === "tool-generate_document") { + const d = unwrapToolResult(p.output); + if (d?.document_id && !byId.has(d.document_id)) { + byId.set(d.document_id, { + variant: "download", + documentId: d.document_id, + filename: d.filename, + format: d.format, + title: "Document généré", + }); + } + } else if (p.type === "tool-edit_document") { + const d = unwrapToolResult(p.output); + if (d?.document_id && !byId.has(d.document_id)) { + byId.set(d.document_id, { + variant: "edited", + documentId: d.document_id, + doc: d, + }); + } } } - if (typeof candidate !== "object" || candidate === null) return null; - // Couche 2 : enveloppe AI SDK {type, value/text} - const aiObj = candidate as Record; - if ("type" in aiObj && "value" in aiObj && aiObj.type === "json") { - candidate = aiObj.value; - } else if ("type" in aiObj && "text" in aiObj && aiObj.type === "text") { - try { - candidate = JSON.parse(String(aiObj.text)); - } catch { - return null; - } + // 2. metadata persistée (survit remount/reload, source de vérité). + for (const a of persisted) { + if (byId.has(a.documentId)) continue; + byId.set(a.documentId, { + variant: "download", + documentId: a.documentId, + filename: a.filename, + format: a.format, + title: a.kind === "edited" ? "Document modifié" : "Document généré", + }); } - if (typeof candidate !== "object" || candidate === null) return null; - // Couche 3 : envelope ToolResult {ok, data} - const env = candidate as Record; - if ("ok" in env && "data" in env) { - if (env.ok === false) return null; - return env.data as T; + // 3. Filet ultime : mention du filename d'un doc connu dans le texte. Ne + // s'active QUE si aucun artefact n'a été capté par les sources fiables (#1 + // tool parts, #2 metadata) — sinon un simple rappel d'un fichier existant + // (« comme dans contrat.docx ») créait une fausse carte de téléchargement. + // Les mentions légitimes restent cliquables via linkifyDocMentions (inline). + if (text && byId.size === 0) { + for (const d of knownDocs) { + if (!d.filename || byId.has(d.id)) continue; + if (text.includes(d.filename)) { + byId.set(d.id, { + variant: "download", + documentId: d.id, + filename: d.filename, + format: /\.pdf$/i.test(d.filename) ? "pdf" : "docx", + title: "Document", + }); + } + } } - return env as T; + return [...byId.values()]; } function DocumentDownloadCard({ @@ -373,7 +432,7 @@ function DocumentDownloadCard({ }) { const Icon = format === "pdf" ? IconFileTypePdf : IconFileTypeDocx; return ( -
+
- - - Télécharger - +
+ + + + Télécharger + +
); } @@ -426,7 +495,7 @@ function EditedDocumentCard({ onPreview: () => void; }) { return ( -
+
+ ); +} + +function Divider() { + return ; +} diff --git a/src/app/(app)/chat/doc-panel.tsx b/src/app/(app)/chat/doc-panel.tsx index a7e11f7..0e145a0 100644 --- a/src/app/(app)/chat/doc-panel.tsx +++ b/src/app/(app)/chat/doc-panel.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import dynamic from "next/dynamic"; import { IconX, @@ -8,30 +8,35 @@ import { IconFile, IconAlertTriangle, IconDownload, + IconPencil, + IconEye, + IconDeviceFloppy, } from "@tabler/icons-react"; import { Spinner } from "@/components/ui/spinner"; import { DocxView } from "./docx-view"; +import { DocEditor, type DocEditorHandle } from "./doc-editor"; // PdfView importe pdfjs qui touche DOMMatrix / window au module-eval — // donc browser-only. Dynamic import ssr:false évite « DOMMatrix is not // defined » au rendu serveur de la route /chat. -const PdfView = dynamic( - () => import("./pdf-view").then((m) => m.PdfView), - { - ssr: false, - loading: () => ( -
- -
- ), - } -); +const PdfView = dynamic(() => import("./pdf-view").then((m) => m.PdfView), { + ssr: false, + loading: () => ( +
+ +
+ ), +}); + +const DOCX_MEDIA_TYPE = + "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; type DocPreview = { id: string; filename: string; contentType: string; sizeBytes: number; + version: number; extractedText: string | null; extractionStatus: string; hasPdfPreview: boolean; @@ -39,16 +44,36 @@ type DocPreview = { type Props = { documentId: string; - /** Citation cliquée — passé à DocxView pour scroll/highlight. */ + /** Citation cliquée — passé aux vues d'aperçu pour scroll/highlight. */ targetText?: string; onClose: () => void; + /** Bascule le panneau sur un autre document (ex : nouvelle version au save). */ + onReplace?: (documentId: string) => void; + /** Pilote l'animation de sortie : le parent démonte après l'anim. */ + closing?: boolean; }; -export function DocPanel({ documentId, targetText, onClose }: Props) { +export function DocPanel({ + documentId, + targetText, + onClose, + onReplace, + closing, +}: Props) { const [doc, setDoc] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + // Une citation cliquée ouvre en aperçu (highlight/scroll y fonctionnent) ; + // sinon on ouvre directement en édition. + const [mode, setMode] = useState<"edit" | "preview">( + targetText ? "preview" : "edit" + ); + const [dirty, setDirty] = useState(false); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(null); + const editorRef = useRef(null); + // Le parent passe key={documentId} → ce composant remount sur changement // de document, useState repart frais. Pas besoin de reset manuel ici. useEffect(() => { @@ -62,6 +87,8 @@ export function DocPanel({ documentId, targetText, onClose }: Props) { if (cancelled) return; setDoc(d); setLoading(false); + // Un PDF n'est pas éditable → on force l'aperçu. + if (d.contentType !== DOCX_MEDIA_TYPE) setMode("preview"); }) .catch((err) => { if (cancelled) return; @@ -74,18 +101,97 @@ export function DocPanel({ documentId, targetText, onClose }: Props) { }, [documentId]); const isPdf = doc?.contentType === "application/pdf"; + const isDocx = doc?.contentType === DOCX_MEDIA_TYPE; + const editing = isDocx && mode === "edit"; const Icon = isPdf ? IconFileText : IconFile; - const body = ( - <> -
+ async function handleSave() { + const json = editorRef.current?.getJSON(); + if (!json) return; + setSaving(true); + setSaveError(null); + try { + const r = await fetch(`/api/documents/${documentId}/save`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ doc: json }), + }); + if (!r.ok) throw new Error(await r.text()); + const { documentId: newId } = (await r.json()) as { documentId: string }; + setDirty(false); + // Bascule sur la nouvelle version (remount du panneau). + if (onReplace) onReplace(newId); + } catch (e) { + setSaveError( + e instanceof Error ? e.message : "Échec de l'enregistrement" + ); + } finally { + setSaving(false); + } + } + + return ( + ); } diff --git a/src/app/(app)/documents/actions.ts b/src/app/(app)/documents/actions.ts index 7f9c8e6..d7d3a49 100644 --- a/src/app/(app)/documents/actions.ts +++ b/src/app/(app)/documents/actions.ts @@ -3,7 +3,7 @@ import { revalidatePath } from "next/cache"; import { and, eq, inArray, isNotNull } from "drizzle-orm"; import { z } from "zod"; -import { auth } from "@/auth"; +import { requireUserId } from "@/lib/auth/permissions"; import { db } from "@/db"; import { documents, documentFolders } from "@/db/schema"; import { deleteObject } from "@/lib/storage"; @@ -11,12 +11,6 @@ import { recordAudit } from "@/lib/audit"; import { reindexDocument, type ReindexResult } from "@/lib/rag/index-document"; import { diffLines, collapseDiff, type DisplayOp } from "@/lib/diff/line-diff"; -async function requireUserId(): Promise { - const session = await auth(); - if (!session?.user?.id) throw new Error("Unauthorized"); - return session.user.id; -} - export async function deleteDocument(id: string): Promise { const userId = await requireUserId(); diff --git a/src/app/(app)/projects/actions.ts b/src/app/(app)/projects/actions.ts index 89a4811..085bf75 100644 --- a/src/app/(app)/projects/actions.ts +++ b/src/app/(app)/projects/actions.ts @@ -1,10 +1,12 @@ "use server"; +import type { ActionResult as BaseActionResult } from "@/lib/actions/result"; + import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; import { and, eq } from "drizzle-orm"; import { z } from "zod"; -import { auth } from "@/auth"; +import { requireUserId } from "@/lib/auth/permissions"; import { db } from "@/db"; import { projects, @@ -18,13 +20,7 @@ const createSchema = z.object({ description: z.string().trim().max(500).optional(), }); -export type ActionResult = { ok: true; id?: string } | { ok: false; error: string }; - -async function requireUserId(): Promise { - const session = await auth(); - if (!session?.user?.id) throw new Error("Unauthorized"); - return session.user.id; -} +export type ActionResult = BaseActionResult<{ id?: string }>; export async function createProject( _prev: ActionResult | null, @@ -74,31 +70,37 @@ export async function createProject( } } - if (!folderId) { - const nameRaw = formData.get("folderName"); - const folderName = - (typeof nameRaw === "string" && nameRaw.trim()) || parsed.data.name; - const [folder] = await db - .insert(documentFolders) - .values({ userId, name: folderName.slice(0, 80), parentFolderId: null }) - .returning({ id: documentFolders.id }); - folderId = folder.id; - } - - const [row] = await db - .insert(projects) - .values({ - userId, - name: parsed.data.name, - description: parsed.data.description || null, - folderId, - }) - .returning({ id: projects.id }); + // Transaction : création du dossier-racine (si besoin) + du projet de manière + // atomique — sinon un échec après l'insert du dossier laisse un dossier + // orphelin sans projet. + const newId = await db.transaction(async (tx) => { + let resolvedFolderId = folderId; + if (!resolvedFolderId) { + const nameRaw = formData.get("folderName"); + const folderName = + (typeof nameRaw === "string" && nameRaw.trim()) || parsed.data.name; + const [folder] = await tx + .insert(documentFolders) + .values({ userId, name: folderName.slice(0, 80), parentFolderId: null }) + .returning({ id: documentFolders.id }); + resolvedFolderId = folder.id; + } + const [row] = await tx + .insert(projects) + .values({ + userId, + name: parsed.data.name, + description: parsed.data.description || null, + folderId: resolvedFolderId, + }) + .returning({ id: projects.id }); + return row.id; + }); revalidatePath("/projects"); revalidatePath("/documents"); revalidatePath("/chat"); - return { ok: true, id: row.id }; + return { ok: true, id: newId }; } export async function updateProject( diff --git a/src/app/(app)/settings/connectors/actions.ts b/src/app/(app)/settings/connectors/actions.ts index f33ffd2..731e01c 100644 --- a/src/app/(app)/settings/connectors/actions.ts +++ b/src/app/(app)/settings/connectors/actions.ts @@ -1,9 +1,11 @@ "use server"; +import type { ActionResult as BaseActionResult } from "@/lib/actions/result"; + import { revalidatePath } from "next/cache"; import { and, eq } from "drizzle-orm"; import { z } from "zod"; -import { auth } from "@/auth"; +import { requireUserId } from "@/lib/auth/permissions"; import { db } from "@/db"; import { connectorKeys } from "@/db/schema"; import { encrypt } from "@/lib/crypto"; @@ -17,13 +19,7 @@ const baseSchema = z.object({ label: z.string().trim().min(1).max(80), }); -export type ActionResult = { ok: true } | { ok: false; error: string }; - -async function requireUserId(): Promise { - const session = await auth(); - if (!session?.user?.id) throw new Error("Unauthorized"); - return session.user.id; -} +export type ActionResult = BaseActionResult; export async function createConnectorKey( _prev: ActionResult | null, diff --git a/src/app/(app)/settings/mcp/actions.ts b/src/app/(app)/settings/mcp/actions.ts index 39f418e..7bf5e53 100644 --- a/src/app/(app)/settings/mcp/actions.ts +++ b/src/app/(app)/settings/mcp/actions.ts @@ -1,12 +1,15 @@ "use server"; +import type { ActionResult as BaseActionResult } from "@/lib/actions/result"; + import { revalidatePath } from "next/cache"; import { and, eq } from "drizzle-orm"; import { z } from "zod"; -import { auth } from "@/auth"; +import { requireUserId } from "@/lib/auth/permissions"; import { db } from "@/db"; import { mcpServers, type CachedMcpTool } from "@/db/schema"; import { encrypt } from "@/lib/crypto"; +import { recordAudit } from "@/lib/audit"; import { mcpListTools } from "@/lib/mcp/client"; const TRANSPORTS = ["sse", "http"] as const; @@ -18,13 +21,7 @@ const createSchema = z.object({ headers: z.string().optional().or(z.literal("")), }); -export type ActionResult = { ok: true } | { ok: false; error: string }; - -async function requireUserId(): Promise { - const session = await auth(); - if (!session?.user?.id) throw new Error("Unauthorized"); - return session.user.id; -} +export type ActionResult = BaseActionResult; function parseHeaders(raw: string | undefined | null): Record | null { if (!raw || !raw.trim()) return null; @@ -117,6 +114,14 @@ export async function createMcpServer( .where(eq(mcpServers.id, inserted.id)); } + // Audit : un serveur MCP est un sink de credentials sortant — tracé comme + // les providers/connecteurs. + await recordAudit({ + userId, + action: "mcp.add", + target: parsed.data.label, + }); + revalidatePath("/settings/mcp"); revalidatePath("/chat"); return { ok: true }; @@ -124,9 +129,17 @@ export async function createMcpServer( export async function deleteMcpServer(id: string): Promise { const userId = await requireUserId(); + const [server] = await db + .select({ label: mcpServers.label }) + .from(mcpServers) + .where(and(eq(mcpServers.id, id), eq(mcpServers.userId, userId))) + .limit(1); await db .delete(mcpServers) .where(and(eq(mcpServers.id, id), eq(mcpServers.userId, userId))); + if (server) { + await recordAudit({ userId, action: "mcp.delete", target: server.label }); + } revalidatePath("/settings/mcp"); } @@ -135,7 +148,7 @@ export async function toggleMcpServerActive( ): Promise { const userId = await requireUserId(); const [current] = await db - .select({ isActive: mcpServers.isActive }) + .select({ isActive: mcpServers.isActive, label: mcpServers.label }) .from(mcpServers) .where(and(eq(mcpServers.id, id), eq(mcpServers.userId, userId))) .limit(1); @@ -148,6 +161,12 @@ export async function toggleMcpServerActive( } catch { return { ok: false, error: "Impossible de modifier l'état du serveur." }; } + await recordAudit({ + userId, + action: "mcp.toggle", + target: current.label, + meta: { active: !current.isActive }, + }); revalidatePath("/settings/mcp"); return { ok: true }; } diff --git a/src/app/(app)/settings/memory/actions.ts b/src/app/(app)/settings/memory/actions.ts index f1055ed..1e1ca48 100644 --- a/src/app/(app)/settings/memory/actions.ts +++ b/src/app/(app)/settings/memory/actions.ts @@ -2,16 +2,10 @@ import { revalidatePath } from "next/cache"; import { and, eq } from "drizzle-orm"; -import { auth } from "@/auth"; +import { requireUserId } from "@/lib/auth/permissions"; import { db } from "@/db"; import { projectMemories } from "@/db/schema"; -async function requireUserId(): Promise { - const session = await auth(); - if (!session?.user?.id) throw new Error("Unauthorized"); - return session.user.id; -} - /** Valide un fait : il pourra désormais influencer les réponses du dossier. */ export async function approveMemory(id: string): Promise { const userId = await requireUserId(); diff --git a/src/app/(app)/settings/models/actions.ts b/src/app/(app)/settings/models/actions.ts index ba4fb8d..ae0cc61 100644 --- a/src/app/(app)/settings/models/actions.ts +++ b/src/app/(app)/settings/models/actions.ts @@ -1,9 +1,11 @@ "use server"; +import type { ActionResult as BaseActionResult } from "@/lib/actions/result"; + import { revalidatePath } from "next/cache"; import { and, eq } from "drizzle-orm"; import { z } from "zod"; -import { auth } from "@/auth"; +import { requireUserId } from "@/lib/auth/permissions"; import { db } from "@/db"; import { modelSettings, @@ -13,15 +15,7 @@ import { import { MODEL_CATALOG } from "@/lib/providers/models"; import type { ProviderType } from "@/lib/providers/catalog"; -export type ActionResult = - | { ok: true } - | { ok: false; error: string }; - -async function requireUserId(): Promise { - const session = await auth(); - if (!session?.user?.id) throw new Error("Unauthorized"); - return session.user.id; -} +export type ActionResult = BaseActionResult; const addModelSchema = z.object({ providerType: z.string().min(1).max(50), diff --git a/src/app/(app)/settings/profile/actions.ts b/src/app/(app)/settings/profile/actions.ts index efd85f2..4fd7a1d 100644 --- a/src/app/(app)/settings/profile/actions.ts +++ b/src/app/(app)/settings/profile/actions.ts @@ -1,20 +1,17 @@ "use server"; +import type { ActionResult as BaseActionResult } from "@/lib/actions/result"; + import { revalidatePath } from "next/cache"; -import { eq } from "drizzle-orm"; +import { eq, sql } from "drizzle-orm"; import { z } from "zod"; import bcrypt from "bcryptjs"; -import { auth } from "@/auth"; +import { requireUserId } from "@/lib/auth/permissions"; import { db } from "@/db"; import { users } from "@/db/schema"; +import { recordAudit } from "@/lib/audit"; -export type ActionResult = { ok: true } | { ok: false; error: string }; - -async function requireUserId(): Promise { - const session = await auth(); - if (!session?.user?.id) throw new Error("Unauthorized"); - return session.user.id; -} +export type ActionResult = BaseActionResult; const nameSchema = z.object({ name: z.string().trim().min(1, "Nom requis").max(80), @@ -62,7 +59,7 @@ export async function updatePassword( } const [user] = await db - .select({ passwordHash: users.passwordHash }) + .select({ passwordHash: users.passwordHash, email: users.email }) .from(users) .where(eq(users.id, userId)) .limit(1); @@ -73,7 +70,19 @@ export async function updatePassword( if (!ok) return { ok: false, error: "Mot de passe actuel incorrect." }; const newHash = await bcrypt.hash(parsed.data.newPassword, 12); - await db.update(users).set({ passwordHash: newHash }).where(eq(users.id, userId)); + // Bump tokenVersion → invalide les sessions JWT existantes (cf. callback jwt). + await db + .update(users) + .set({ passwordHash: newHash, tokenVersion: sql`${users.tokenVersion} + 1` }) + .where(eq(users.id, userId)); + + // Audit : changement de mot de passe self-service (l'équivalent admin + // user.password.reset était déjà tracé — ici c'était un angle mort). + await recordAudit({ + userId, + action: "auth.password.change", + target: user.email, + }); return { ok: true }; } diff --git a/src/app/(app)/settings/providers/actions.ts b/src/app/(app)/settings/providers/actions.ts index 7058938..eb16314 100644 --- a/src/app/(app)/settings/providers/actions.ts +++ b/src/app/(app)/settings/providers/actions.ts @@ -1,9 +1,11 @@ "use server"; +import type { ActionResult as BaseActionResult } from "@/lib/actions/result"; + import { revalidatePath } from "next/cache"; import { and, eq } from "drizzle-orm"; import { z } from "zod"; -import { auth } from "@/auth"; +import { requireUserId } from "@/lib/auth/permissions"; import { db } from "@/db"; import { providerKeys } from "@/db/schema"; import { encrypt, decrypt } from "@/lib/crypto"; @@ -19,13 +21,7 @@ const createSchema = z.object({ baseUrl: z.url().optional().or(z.literal("")), }); -async function requireUserId(): Promise { - const session = await auth(); - if (!session?.user?.id) throw new Error("Unauthorized"); - return session.user.id; -} - -export type ActionResult = { ok: true } | { ok: false; error: string }; +export type ActionResult = BaseActionResult; export async function createProviderKey( _prev: ActionResult | null, @@ -145,16 +141,21 @@ export async function setProviderKeyDefault(id: string): Promise { .where(and(eq(providerKeys.id, id), eq(providerKeys.userId, userId))) .limit(1); if (!target) return; - await db - .update(providerKeys) - .set({ isDefault: false }) - .where( - and(eq(providerKeys.userId, userId), eq(providerKeys.type, target.type)) - ); - await db - .update(providerKeys) - .set({ isDefault: true }) - .where(and(eq(providerKeys.id, id), eq(providerKeys.userId, userId))); + // Transaction : démarquer l'ancien défaut puis marquer le nouveau doivent + // être atomiques, sinon un échec entre les deux laisse l'utilisateur sans + // aucune clé par défaut pour ce type de provider. + await db.transaction(async (tx) => { + await tx + .update(providerKeys) + .set({ isDefault: false }) + .where( + and(eq(providerKeys.userId, userId), eq(providerKeys.type, target.type)) + ); + await tx + .update(providerKeys) + .set({ isDefault: true }) + .where(and(eq(providerKeys.id, id), eq(providerKeys.userId, userId))); + }); revalidatePath("/settings/providers"); } diff --git a/src/app/(app)/settings/security/actions.ts b/src/app/(app)/settings/security/actions.ts index 05aadbf..be2100a 100644 --- a/src/app/(app)/settings/security/actions.ts +++ b/src/app/(app)/settings/security/actions.ts @@ -1,12 +1,13 @@ "use server"; import { revalidatePath } from "next/cache"; -import { eq } from "drizzle-orm"; +import { eq, sql } from "drizzle-orm"; import bcrypt from "bcryptjs"; import { auth } from "@/auth"; import { db } from "@/db"; import { users } from "@/db/schema"; import { recordAudit } from "@/lib/audit"; +import { rateLimit } from "@/lib/rate-limit"; import { generateTotpSecret, otpauthUri, @@ -46,6 +47,13 @@ export async function confirmTotpEnrollment( code: string ): Promise { const user = await requireUser(); + const rl = await rateLimit("totp", user.id); + if (!rl.allowed) { + return { + ok: false, + error: "Trop de tentatives. Réessayez dans quelques minutes.", + }; + } const [row] = await db .select({ pending: users.totpSecretPending }) .from(users) @@ -80,8 +88,42 @@ export async function confirmTotpEnrollment( return { ok: true, backupCodes }; } -export async function disableTotp(): Promise { +export type DisableResult = { ok: true } | { ok: false; error: string }; + +/** + * Désactive la 2FA — exige un code TOTP courant valide (step-up). Sans cette + * ré-authentification, une session volée suffirait à retirer le second facteur + * du compte. Throttlé par compte pour éviter le brute-force du code. + */ +export async function disableTotp(code: string): Promise { const user = await requireUser(); + const rl = await rateLimit("totp", user.id); + if (!rl.allowed) { + return { + ok: false, + error: "Trop de tentatives. Réessayez dans quelques minutes.", + }; + } + const [row] = await db + .select({ secret: users.totpSecret }) + .from(users) + .where(eq(users.id, user.id)) + .limit(1); + if (!row?.secret) { + return { ok: false, error: "La 2FA n'est pas active." }; + } + if (!verifyTotp(row.secret, code)) { + await recordAudit({ + userId: user.id, + action: "auth.totp.failed", + target: user.email, + meta: { context: "disable" }, + }); + return { + ok: false, + error: "Code invalide. Saisissez un code de votre application.", + }; + } await db .update(users) .set({ @@ -89,6 +131,8 @@ export async function disableTotp(): Promise { totpSecret: null, totpSecretPending: null, backupCodes: null, + // Désactiver la 2FA invalide aussi les sessions existantes (cf. jwt). + tokenVersion: sql`${users.tokenVersion} + 1`, }) .where(eq(users.id, user.id)); await recordAudit({ @@ -97,4 +141,5 @@ export async function disableTotp(): Promise { target: user.email, }); revalidatePath("/settings/security"); + return { ok: true }; } diff --git a/src/app/(app)/settings/security/two-factor-setup.tsx b/src/app/(app)/settings/security/two-factor-setup.tsx index 229beed..3b2e975 100644 --- a/src/app/(app)/settings/security/two-factor-setup.tsx +++ b/src/app/(app)/settings/security/two-factor-setup.tsx @@ -50,9 +50,14 @@ export function TwoFactorSetup({ enabled }: { enabled: boolean }) { function turnOff() { start(async () => { try { - await disableTotp(); - setStage({ step: "idle" }); - toast.success("2FA désactivée."); + const res = await disableTotp(code); + if (res.ok) { + setStage({ step: "idle" }); + setCode(""); + toast.success("2FA désactivée."); + } else { + toast.error(res.error); + } } catch { toast.error("Impossible de désactiver la 2FA."); } @@ -69,7 +74,24 @@ export function TwoFactorSetup({ enabled }: { enabled: boolean }) {

Un code à 6 chiffres vous sera demandé à chaque connexion.

-
diff --git a/src/app/(app)/settings/skills/actions.ts b/src/app/(app)/settings/skills/actions.ts index 5824183..9c0698e 100644 --- a/src/app/(app)/settings/skills/actions.ts +++ b/src/app/(app)/settings/skills/actions.ts @@ -1,22 +1,16 @@ "use server"; +import type { ActionResult as BaseActionResult } from "@/lib/actions/result"; + import { revalidatePath } from "next/cache"; import { and, asc, eq } from "drizzle-orm"; import { z } from "zod"; -import { auth } from "@/auth"; +import { requireUserId } from "@/lib/auth/permissions"; import { db } from "@/db"; import { skills, type Skill } from "@/db/schema"; import { SKILL_PRESETS, findSkillPreset } from "@/lib/skills/presets"; -export type ActionResult = - | { ok: true } - | { ok: false; error: string }; - -async function requireUserId(): Promise { - const session = await auth(); - if (!session?.user?.id) throw new Error("Unauthorized"); - return session.user.id; -} +export type ActionResult = BaseActionResult; const upsertSchema = z.object({ name: z.string().trim().min(1).max(120), diff --git a/src/app/(app)/tabular-reviews/actions.ts b/src/app/(app)/tabular-reviews/actions.ts index f0639dc..ab8f4c1 100644 --- a/src/app/(app)/tabular-reviews/actions.ts +++ b/src/app/(app)/tabular-reviews/actions.ts @@ -1,12 +1,14 @@ "use server"; +import type { ActionResult as BaseActionResult } from "@/lib/actions/result"; + import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; import { after } from "next/server"; import { and, eq, inArray, isNotNull, lt } from "drizzle-orm"; import { z } from "zod"; import { generateText, Output, type LanguageModel } from "ai"; -import { auth } from "@/auth"; +import { requireUserId } from "@/lib/auth/permissions"; import { db } from "@/db"; import { documents, @@ -52,15 +54,7 @@ function buildValuesSchema(columns: ReviewColumn[]) { ); } -async function requireUserId(): Promise { - const session = await auth(); - if (!session?.user?.id) throw new Error("Unauthorized"); - return session.user.id; -} - -export type ActionResult = - | { ok: true; id?: string } - | { ok: false; error: string }; +export type ActionResult = BaseActionResult<{ id?: string }>; const columnSchema = z.object({ label: z.string().trim().min(1).max(80), diff --git a/src/app/(app)/workflows/actions.ts b/src/app/(app)/workflows/actions.ts index 857b3bd..c841175 100644 --- a/src/app/(app)/workflows/actions.ts +++ b/src/app/(app)/workflows/actions.ts @@ -1,21 +1,15 @@ "use server"; +import type { ActionResult as BaseActionResult } from "@/lib/actions/result"; + import { revalidatePath } from "next/cache"; import { and, eq } from "drizzle-orm"; import { z } from "zod"; -import { auth } from "@/auth"; +import { requireUserId } from "@/lib/auth/permissions"; import { db } from "@/db"; import { workflows } from "@/db/schema"; -export type ActionResult = - | { ok: true; id?: string } - | { ok: false; error: string }; - -async function requireUserId(): Promise { - const session = await auth(); - if (!session?.user?.id) throw new Error("Unauthorized"); - return session.user.id; -} +export type ActionResult = BaseActionResult<{ id?: string }>; const upsertSchema = z.object({ name: z.string().trim().min(1).max(120), diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index 6b9c853..6f894aa 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -3,9 +3,10 @@ import { createUIMessageStreamResponse, type UIMessage, } from "ai"; -import { and, eq, inArray } from "drizzle-orm"; +import { and, desc, eq, gt, inArray } from "drizzle-orm"; import { auth } from "@/auth"; import { db } from "@/db"; +import { log } from "@/lib/log"; import { agentRuns, conversations, @@ -19,6 +20,10 @@ import { memoryExtractionEnabled, } from "@/lib/memory-extract"; import { assessDeliverable } from "@/lib/orchestrator/verify"; +import { + documentArtifactFromToolResult, + type DocumentArtifactMeta, +} from "@/lib/ai/tool-result"; import { recordAudit } from "@/lib/audit"; import { loadProviderKey, modelFromKey } from "@/lib/providers/factory"; import { getProjectScope } from "@/lib/projects/scope"; @@ -50,6 +55,14 @@ type Body = { projectId?: string | null; /** Pipeline orchestrateur à utiliser. null/undefined → chat-simple. */ pipelineId?: string | null; + /** + * Émis par DefaultChatTransport : `submit-message` pour un nouvel envoi, + * `regenerate-message` pour une régénération. En régénération le message + * user existe déjà — on ne le ré-insère pas et on remplace l'ancienne + * réponse au lieu de l'empiler. + */ + trigger?: "submit-message" | "regenerate-message"; + messageId?: string; }; export async function POST(req: Request) { @@ -95,6 +108,7 @@ export async function POST(req: Request) { projectId: projectIdFromBody, pipelineId, } = body; + const isRegenerate = body.trigger === "regenerate-message"; let conversationId = body.conversationId ?? null; if (!providerKeyId) { @@ -171,10 +185,53 @@ export async function POST(req: Request) { ? await getProjectScope(userId, effectiveProjectId) : null; + // Régénération : le message user existe déjà (tour initial). On purge + // l'ancienne réponse assistant + son trail d'audit pour que la nouvelle la + // REMPLACE au lieu de s'empiler (sinon doublons + coût compté deux fois). + // Transaction : la suppression du message et de ses agent_runs est atomique. + if (isRegenerate) { + const [lastUserRow] = await db + .select({ createdAt: messages.createdAt }) + .from(messages) + .where( + and( + eq(messages.conversationId, finalConversationId), + eq(messages.role, "user") + ) + ) + .orderBy(desc(messages.createdAt)) + .limit(1); + if (lastUserRow) { + await db.transaction(async (tx) => { + const removed = await tx + .delete(messages) + .where( + and( + eq(messages.conversationId, finalConversationId), + gt(messages.createdAt, lastUserRow.createdAt) + ) + ) + .returning({ id: messages.id }); + if (removed.length > 0) { + // agent_runs.messageId est en ON DELETE SET NULL → on les supprime + // explicitement pour ne pas laisser de runs orphelins dans l'audit. + await tx.delete(agentRuns).where( + inArray( + agentRuns.messageId, + removed.map((r) => r.id) + ) + ); + } + }); + } + } + let userMessageId: string | null = null; let userMessageText = ""; const lastUser = uiMessages.at(-1); - if (lastUser?.role === "user") { + // En régénération, NE PAS ré-insérer le message user (déjà en base) — sinon + // il apparaît en double au reload. + if (!isRegenerate && lastUser?.role === "user") { const text = extractTextPreview(lastUser); if (text) { userMessageText = text; @@ -310,6 +367,11 @@ export async function POST(req: Request) { // message final pour ré-hydrater les tool calls au reload) et par // onEvent (audit trail multi-agent dans agent_runs). const savedParts: SavedPart[] = []; + // Artefacts documents (generate/edit_document) produits ce tour — persistés + // dans messages.metadata, source de vérité pour la carte d'artefact côté + // client. Indépendant de la reconstruction des tool parts (fragile au reload) + // et de la prose du modèle. + const docArtifacts: DocumentArtifactMeta[] = []; let finalText = ""; const finalUsage: { inputTokens?: number; outputTokens?: number } = {}; const agentStarts = new Map(); @@ -430,11 +492,29 @@ export async function POST(req: Request) { }); } }, + onError: (error) => { + // Sans onError, l'AI SDK renvoie un générique "An error occurred." au + // client ET n'écrit rien côté serveur : les erreurs riches de + // l'orchestrateur (échec provider, retries épuisés, débatteurs en + // échec…) étaient masquées et non diagnostiquables. On logue le détail + // et on renvoie un message exploitable. + log.error("chat-stream", "Erreur pendant la génération", { + conversationId: finalConversationId, + error: error instanceof Error ? error.message : String(error), + }); + return error instanceof Error && error.message + ? error.message + : "Une erreur est survenue pendant la génération."; + }, onFinish: async ({ messages: streamMessages }) => { // Décision : un « Stop » n'enregistre PAS de réponse partielle. Le tour // est annulé proprement (pas de message tronqué, pas d'agent_runs // orphelins). L'utilisateur relance s'il le souhaite. if (req.signal.aborted) return; + // H3 : toute la persistance est gardée. Un échec DB (insert message, + // agent_runs…) est loggé au lieu d'être silencieusement avalé et ne fait + // plus planter la finalisation du stream sans trace. + try { // Reconstitue les parts brutes du dernier message assistant (le // texte final + les tool calls/results) pour les re-render au load. for (const m of streamMessages) { @@ -475,6 +555,21 @@ export async function POST(req: Request) { toolName, output: toolPart.output, }); + // Capture l'artefact document pour le persister en metadata + // (état terminal output-available garanti, contrairement au + // tool-call/input-available qui peut manquer après agrégation). + const artifact = documentArtifactFromToolResult( + toolName, + toolPart.output + ); + if ( + artifact && + !docArtifacts.some( + (a) => a.documentId === artifact.documentId + ) + ) { + docArtifacts.push(artifact); + } } } else if (PERSISTED_DATA_PARTS.has(part.type)) { // H3a : persiste le trail multi-agents (events/outputs/retries) + @@ -499,6 +594,8 @@ export async function POST(req: Request) { role: "assistant", content: finalText, parts: savedParts.length > 0 ? savedParts : null, + metadata: + docArtifacts.length > 0 ? { documents: docArtifacts } : null, inputTokens: finalUsage.inputTokens ?? null, outputTokens: finalUsage.outputTokens ?? null, modelId: modelOverride ?? null, @@ -577,6 +674,12 @@ export async function POST(req: Request) { // best-effort } } + } catch (err) { + log.error("chat-persist", "Échec de la persistance de la réponse", { + conversationId: finalConversationId, + error: err instanceof Error ? err.message : String(err), + }); + } }, }); diff --git a/src/app/api/documents/[id]/html/route.ts b/src/app/api/documents/[id]/html/route.ts new file mode 100644 index 0000000..a936525 --- /dev/null +++ b/src/app/api/documents/[id]/html/route.ts @@ -0,0 +1,63 @@ +import mammoth from "mammoth"; +import { and, eq } from "drizzle-orm"; +import { auth } from "@/auth"; +import { db } from "@/db"; +import { documents } from "@/db/schema"; +import { getObjectBytes } from "@/lib/storage"; + +type Params = { id: string }; + +const DOCX_MEDIA_TYPE = + "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; + +/** + * Représentation HTML éditable d'un document, pour alimenter l'éditeur + * Tiptap du DocPanel. + * + * Contrairement à /preview (qui ne renvoie le HTML mammoth qu'en l'absence + * de PDF LibreOffice), cette route convertit TOUJOURS le .docx en HTML — + * c'est la source d'édition. Tiptap re-parse ce HTML via son schéma + * (titres, gras/italique/souligné, listes, citations), ce qui agit aussi + * comme filtre : les balises/inline styles non supportés sont ignorés. + * + * Réservé aux .docx : un PDF n'a pas de texte structuré ré-éditable → 415. + */ +export async function GET( + _req: Request, + { params }: { params: Promise } +) { + const session = await auth(); + if (!session?.user?.id) { + return new Response("Unauthorized", { status: 401 }); + } + const userId = session.user.id; + const { id } = await params; + + const [doc] = await db + .select({ + filename: documents.filename, + contentType: documents.contentType, + storageKey: documents.storageKey, + }) + .from(documents) + .where(and(eq(documents.id, id), eq(documents.userId, userId))) + .limit(1); + + if (!doc) { + return new Response("Not found", { status: 404 }); + } + if (doc.contentType !== DOCX_MEDIA_TYPE) { + return new Response("Document non éditable (PDF)", { status: 415 }); + } + + let html: string; + try { + const bytes = Buffer.from(await getObjectBytes(doc.storageKey)); + const result = await mammoth.convertToHtml({ buffer: bytes }); + html = result.value; + } catch { + return new Response("Conversion error", { status: 500 }); + } + + return Response.json({ filename: doc.filename, html }); +} diff --git a/src/app/api/documents/[id]/preview/route.ts b/src/app/api/documents/[id]/preview/route.ts index f1e84f7..ab2b36f 100644 --- a/src/app/api/documents/[id]/preview/route.ts +++ b/src/app/api/documents/[id]/preview/route.ts @@ -33,6 +33,7 @@ export async function GET( filename: documents.filename, contentType: documents.contentType, sizeBytes: documents.sizeBytes, + version: documents.version, storageKey: documents.storageKey, previewStorageKey: documents.previewStorageKey, extractedText: documents.extractedText, @@ -64,6 +65,7 @@ export async function GET( filename: doc.filename, contentType: doc.contentType, sizeBytes: doc.sizeBytes, + version: doc.version, extractedText: doc.extractedText, extractionStatus: doc.extractionStatus, hasPdfPreview: Boolean(doc.previewStorageKey), diff --git a/src/app/api/documents/[id]/save/route.ts b/src/app/api/documents/[id]/save/route.ts new file mode 100644 index 0000000..57bbea1 --- /dev/null +++ b/src/app/api/documents/[id]/save/route.ts @@ -0,0 +1,117 @@ +import { and, eq, or } from "drizzle-orm"; +import { auth } from "@/auth"; +import { db } from "@/db"; +import { documents } from "@/db/schema"; +import { generateDocx } from "@/lib/docgen/docx"; +import { storeBuffer } from "@/lib/docgen"; +import { editorJsonToSpec } from "@/lib/docgen/from-prosemirror"; +import { recordAudit } from "@/lib/audit"; + +type Params = { id: string }; + +const DOCX_MEDIA_TYPE = + "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; + +/** + * Sauvegarde une édition WYSIWYG (JSON Tiptap) comme NOUVELLE VERSION du + * document : on ré-exporte en .docx via le pipeline maison (`generateDocx`), + * rattaché à la famille du document d'origine (`parentDocumentId` = racine, + * `version` = max + 1). On ne mute jamais le .docx existant — chaque save + * crée une révision, cohérent avec le versioning légal de Louis. + */ +export async function POST( + req: Request, + { params }: { params: Promise } +) { + const session = await auth(); + if (!session?.user?.id) { + return new Response("Unauthorized", { status: 401 }); + } + const userId = session.user.id; + const { id } = await params; + + let body: { doc?: unknown }; + try { + body = (await req.json()) as { doc?: unknown }; + } catch { + return new Response("Invalid JSON", { status: 400 }); + } + if (!body.doc || typeof body.doc !== "object") { + return new Response("Champ `doc` (JSON éditeur) requis.", { status: 400 }); + } + + const [src] = await db + .select({ + id: documents.id, + filename: documents.filename, + contentType: documents.contentType, + projectId: documents.projectId, + folderId: documents.folderId, + parentDocumentId: documents.parentDocumentId, + }) + .from(documents) + .where(and(eq(documents.id, id), eq(documents.userId, userId))) + .limit(1); + + if (!src) { + return new Response("Not found", { status: 404 }); + } + if (src.contentType !== DOCX_MEDIA_TYPE) { + return new Response("Seuls les .docx sont éditables.", { status: 415 }); + } + + // Racine de la famille de versions : le parent direct, ou soi-même (v1). + const rootId = src.parentDocumentId ?? src.id; + + // Prochain numéro de version = max de la famille + 1. + const family = await db + .select({ version: documents.version }) + .from(documents) + .where( + and( + eq(documents.userId, userId), + or(eq(documents.id, rootId), eq(documents.parentDocumentId, rootId)) + ) + ); + const nextVersion = + family.reduce((max, r) => Math.max(max, r.version), 0) + 1; + + const stem = src.filename.replace(/\.[^.]+$/, "") || "document"; + const spec = editorJsonToSpec( + body.doc as Parameters[0], + stem + ); + + let buffer: Buffer; + try { + buffer = await generateDocx(spec); + } catch { + return new Response("Génération du document échouée", { status: 500 }); + } + + const result = await storeBuffer({ + buffer, + contentType: DOCX_MEDIA_TYPE, + filename: src.filename, + userId, + projectId: src.projectId, + folderId: src.folderId, + parentDocumentId: rootId, + version: nextVersion, + }); + + // Audit : une nouvelle version d'un document est un livrable modifié — + // tracé pour la défendabilité (doc.delete l'était déjà, pas doc.save). + await recordAudit({ + userId, + action: "doc.save", + target: src.filename, + meta: { version: nextVersion }, + }); + + return Response.json({ + documentId: result.documentId, + filename: result.filename, + version: nextVersion, + }); +} diff --git a/src/app/login/actions.ts b/src/app/login/actions.ts index 3689883..906de98 100644 --- a/src/app/login/actions.ts +++ b/src/app/login/actions.ts @@ -2,10 +2,14 @@ import { AuthError } from "next-auth"; import { headers } from "next/headers"; -import { signIn } from "@/auth"; +import { signIn, verifyPasswordStep } from "@/auth"; import { rateLimit } from "@/lib/rate-limit"; +export type LoginStep = "credentials" | "totp"; + export type LoginState = { + /** Étape que l'UI doit afficher après cette action. */ + step: LoginStep; error?: string; }; @@ -25,16 +29,29 @@ async function getClientIp(): Promise { return "unknown"; } +/** + * Login en deux temps, piloté par le champ caché `step` : + * + * 1. `credentials` — on valide email + mot de passe (sans session). Si le + * compte a la 2FA, on renvoie `{ step: "totp" }` pour révéler l'écran de + * code. Sinon on finalise directement (signIn → redirect). + * 2. `totp` — email + mot de passe sont rejoués avec le code ; `signIn` + * revalide tout (mot de passe + second facteur) et établit la session. + * + * Pour un compte sans 2FA, l'utilisateur ne voit qu'une seule étape : le + * premier submit le connecte. Le double bcrypt (vérif d'étape 1 puis re-vérif + * dans `authorize`) est négligeable sur ce chemin froid. + */ export async function loginAction( _prev: LoginState, formData: FormData ): Promise { + const step = formData.get("step") === "totp" ? "totp" : "credentials"; const email = formData.get("email"); const password = formData.get("password"); - const totp = formData.get("totp"); if (typeof email !== "string" || typeof password !== "string") { - return { error: "Champs requis manquants." }; + return { step: "credentials", error: "Champs requis manquants." }; } // Rate-limit anti brute-force par IP. La fenêtre est globale (toute @@ -46,10 +63,27 @@ export async function loginAction( if (!rl.allowed) { const retryS = Math.ceil((rl.resetAt - Date.now()) / 1000); return { + step, error: `Trop de tentatives. Réessayez dans ${retryS} secondes.`, }; } + // --- Étape 1 : vérifier les identifiants, déterminer si la 2FA est requise. + if (step === "credentials") { + const res = await verifyPasswordStep(email, password); + if (res.status === "invalid") { + return { step: "credentials", error: "Identifiants invalides." }; + } + if (res.needsTotp) { + // Mot de passe valide mais 2FA activée → on demande le code. Aucune + // session n'est établie tant que le second facteur n'est pas fourni. + return { step: "totp" }; + } + // Pas de 2FA : on enchaîne sur la finalisation ci-dessous. + } + + // --- Finalisation : étape 1 sans 2FA, OU étape 2 avec le code. + const totp = formData.get("totp"); try { await signIn("credentials", { email, @@ -57,10 +91,16 @@ export async function loginAction( totp: typeof totp === "string" ? totp : "", redirectTo: "/dashboard", }); - return {}; + // Inatteignable : signIn lève un redirect en cas de succès. + return { step }; } catch (err) { if (err instanceof AuthError) { - return { error: "Identifiants invalides." }; + // À l'étape 2, le mot de passe a déjà été validé : un échec ici = code + // 2FA invalide. On reste sur l'écran de code. + if (step === "totp") { + return { step: "totp", error: "Code de vérification invalide." }; + } + return { step: "credentials", error: "Identifiants invalides." }; } // Next.js redirect throws are expected — re-throw. throw err; diff --git a/src/app/login/login-aside.tsx b/src/app/login/login-aside.tsx new file mode 100644 index 0000000..aa37a2a --- /dev/null +++ b/src/app/login/login-aside.tsx @@ -0,0 +1,208 @@ +import { + IconPlus, + IconArrowUp, + IconFileText, + IconDownload, + IconX, + IconCircleCheck, +} from "@tabler/icons-react"; +import { LouisLogo } from "@/components/louis-logo"; + +/** + * Panneau de marque (colonne droite du split login). + * + * Toujours sombre, indépendamment du thème clair/sombre : c'est une surface + * de marque fixe, façon « sceau royal » — d'où les couleurs en dur (white/…, + * oklch fixes) plutôt que les tokens de thème, qui s'inverseraient en dark et + * casseraient le contraste. Tout reste sur le hue 265° (bleu de France). + * + * La pièce maîtresse est une *vitrine produit* : un mock statique de l'app + * (composer, message, étape agent, document généré en split-pane, citation + * surlignée) — pour montrer la valeur dès l'écran de connexion, à la manière + * d'un hero produit. Purement décoratif → masqué aux lecteurs d'écran + * (aria-hidden) et caché sous `lg` pour laisser toute la place au formulaire. + */ +export function LoginAside() { + return ( + + ); +} + +/** Accent bleu de France fixe, lisible sur le fond sombre du panneau. */ +const ACCENT = "oklch(0.62 0.19 265)"; + +/** + * Maquette statique de l'app, façon fenêtre flottante. Tout est décoratif : + * barres de squelette plutôt que vrai texte, une seule ligne « citée » + * surlignée en ambre (rappel de la feature citations), un seul accent bleu + * (puce de génération + bouton d'envoi). + */ +function ProductMock() { + return ( +
+ {/* Carte fantôme en retrait — donne de la profondeur à la pile. */} +
+ + {/* Fenêtre applicative. */} +
+ {/* Barre de titre. */} +
+ + + + + + + Nouvelle assignation + + + + Mistral Large + +
+ + {/* Corps : conversation à gauche, document généré à droite. */} +
+ {/* Rail conversation. */} +
+ {/* Message utilisateur. */} +
+
+
+
+ + {/* Réponse assistant : étapes agent. */} +
+ +
+
+ + Sources vérifiées +
+
+
+ + + Rédaction du document… + +
+
+
+
+
+ + {/* Document généré (split-pane). */} +
+ {/* En-tête du document. */} +
+ + assignation.docx + + + + +
+ + {/* Page A4. */} +
+
+

+ TRIBUNAL JUDICIAIRE DE PARIS +

+
+
+
+
+ {/* Ligne citée — surlignage ambre (feature citations). */} +
+
+
+
+
+
+
+
+ + {/* Composer. */} +
+
+ + + Posez une question juridique… + + + + +
+
+
+
+ ); +} diff --git a/src/app/login/login-form.tsx b/src/app/login/login-form.tsx index fa986c0..98a99c1 100644 --- a/src/app/login/login-form.tsx +++ b/src/app/login/login-form.tsx @@ -1,46 +1,84 @@ "use client"; -import { useActionState } from "react"; -import Link from "next/link"; +import { useActionState, useEffect, useRef, useState } from "react"; +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; +import { IconArrowLeft, IconLock } from "@tabler/icons-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Alert, AlertDescription } from "@/components/ui/alert"; -import { LouisLogo } from "@/components/louis-logo"; import { loginAction, type LoginState } from "./actions"; -const initialState: LoginState = {}; +const initialState: LoginState = { step: "credentials" }; export function LoginForm() { const [state, formAction, pending] = useActionState(loginAction, initialState); + // `view` = l'étape réellement affichée. On la dérive du retour serveur en + // ajustant l'état pendant le render (pattern React recommandé plutôt qu'un + // effet) : à chaque nouveau `state` renvoyé par l'action, on s'aligne sur + // `state.step`. Comparer l'identité de `state` (et non `state.step`) permet + // de réavancer même quand le serveur renvoie deux fois "totp". Un retour + // manuel ("Changer") n'altère pas `state` → il n'est donc pas écrasé. + const [view, setView] = useState("credentials"); + const [seenState, setSeenState] = useState(state); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const totpRef = useRef(null); + const reduce = useReducedMotion(); + + if (state !== seenState) { + setSeenState(state); + setView(state.step); + } + + // Focus le champ code dès qu'on arrive sur l'étape 2. + useEffect(() => { + if (view === "totp") totpRef.current?.focus(); + }, [view]); + + const slide = reduce + ? {} + : { + initial: { opacity: 0, x: 16 }, + animate: { opacity: 1, x: 0 }, + exit: { opacity: 0, x: -16 }, + transition: { duration: 0.22, ease: [0.4, 0, 0.2, 1] as const }, + }; + return ( -
-
- - - Louis - - - - - Connexion - - Accédez à votre instance Louis. - - - -
+
+
+

+ {view === "credentials" ? "Bon retour" : "Vérification"} +

+

+ {view === "credentials" + ? "Connectez-vous pour accéder à vos dossiers." + : "Saisissez le code de votre application d'authentification."} +

+
+ + + {/* Pilote l'action serveur ; reflète l'étape affichée. */} + + + + {view === "credentials" ? ( +
- + setEmail(e.target.value)} aria-invalid={!!state.error} aria-describedby={state.error ? "login-error" : undefined} /> @@ -53,53 +91,91 @@ export function LoginForm() { type="password" autoComplete="current-password" required + className="h-11" + value={password} + onChange={(e) => setPassword(e.target.value)} aria-invalid={!!state.error} aria-describedby={state.error ? "login-error" : undefined} />
+
+ ) : ( + + {/* Identifiants validés à l'étape 1, rejoués avec le code. */} + + + +
+ + {email} + +
+
- + +

+ Code à 6 chiffres, ou un code de secours si besoin. +

+
+ )} +
- {state.error && ( - - {state.error} - - )} - - - -

- Mot de passe oublié ? Contactez l'administrateur de votre - cabinet. -

- - - -

- Pas encore d'instance ?{" "} - - Découvrir Louis - -

-
-
+ {state.error && ( + + {state.error} + + )} + + + + {view === "totp" && ( + + )} + + +

+ Mot de passe oublié ? Votre administrateur de cabinet peut le + réinitialiser. +

+
); } - diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 7669cac..f2ed666 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -1,9 +1,13 @@ import { redirect } from "next/navigation"; +import Link from "next/link"; import { auth } from "@/auth"; +import { LouisLogo } from "@/components/louis-logo"; import { LoginForm } from "./login-form"; +import { LoginAside } from "./login-aside"; /** - * Page de connexion (Server Component). + * Page de connexion (Server Component) — split screen : formulaire à gauche, + * panneau de marque à droite (masqué sous `lg`). * * Si l'utilisateur a une session valide, on le renvoie directement vers * /chat (évite un crochet "connexion → reconnexion" inutile). @@ -17,5 +21,30 @@ import { LoginForm } from "./login-form"; export default async function LoginPage() { const session = await auth(); if (session?.user) redirect("/chat"); - return ; + + return ( +
+
+
+ + + Louis + +
+ +
+ +
+ +
+ Plateforme privée — accès réservé aux membres du cabinet. +
+
+ + +
+ ); } diff --git a/src/auth/index.ts b/src/auth/index.ts index 8f492ba..5b33d07 100644 --- a/src/auth/index.ts +++ b/src/auth/index.ts @@ -7,12 +7,78 @@ import { db } from "@/db"; import { users } from "@/db/schema"; import { recordAudit } from "@/lib/audit"; import { verifyTotp } from "@/lib/totp"; +import { rateLimit } from "@/lib/rate-limit"; const loginSchema = z.object({ email: z.email(), password: z.string().min(1), }); +/** + * Hash bcrypt (coût 12, comme les vrais) d'une valeur factice. On le compare + * quand l'utilisateur est inconnu/inactif pour que le temps de réponse soit + * identique à celui d'un compte existant — sinon le retour immédiat (sans + * bcrypt) permet d'énumérer les comptes valides par timing. + */ +const DUMMY_PASSWORD_HASH = + "$2b$12$/UhpgtrIsu3oraRqMFfceusRyGfNtEboKmT1qI7fkb2rxNEuITKNK"; + +/** + * Première étape du login en deux temps : vérifie l'email + le mot de passe + * SANS créer de session, et indique si un second facteur (TOTP) est requis. + * + * Sert au formulaire multi-étapes (src/app/login) : on ne révèle l'écran + * « code 2FA » qu'après un mot de passe valide — sinon on divulguerait à un + * attaquant quels comptes ont la 2FA activée (énumération + ciblage). + * + * Le log d'audit d'échec est calqué sur `authorize` ci-dessous : comme un + * échec à cette étape n'atteint jamais `signIn`/`authorize`, c'est ici qu'il + * faut tracer la tentative ratée. Le succès, lui, n'est PAS loggé ici (aucune + * session établie) — il le sera par `authorize` lors du `signIn` final. + */ +export async function verifyPasswordStep( + email: string, + password: string +): Promise<{ status: "invalid" } | { status: "ok"; needsTotp: boolean }> { + const parsed = loginSchema.safeParse({ email, password }); + if (!parsed.success) return { status: "invalid" }; + + const [user] = await db + .select() + .from(users) + .where(eq(users.email, parsed.data.email)) + .limit(1); + + if (!user || !user.isActive) { + // Compare contre un hash factice : même coût CPU qu'un compte réel, pour + // ne pas révéler par timing si l'email existe. + await bcrypt.compare(parsed.data.password, DUMMY_PASSWORD_HASH); + await recordAudit({ + userId: null, + action: "auth.login.failed", + target: parsed.data.email, + meta: { reason: user ? "inactive" : "unknown" }, + }); + return { status: "invalid" }; + } + + const passwordMatch = await bcrypt.compare( + parsed.data.password, + user.passwordHash + ); + if (!passwordMatch) { + await recordAudit({ + userId: user.id, + action: "auth.login.failed", + target: parsed.data.email, + meta: { reason: "bad_password" }, + }); + return { status: "invalid" }; + } + + return { status: "ok", needsTotp: user.totpEnabled }; +} + // En dev on tourne sur http://localhost — donc PAS de cookie Secure // (sinon le navigateur ne le renvoie pas et l'utilisateur est // déconnecté à chaque retour de tab). En prod, Auth.js bascule via la @@ -72,6 +138,8 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ .limit(1); if (!user || !user.isActive) { + // Timing parity (voir DUMMY_PASSWORD_HASH) — évite l'énumération. + await bcrypt.compare(password, DUMMY_PASSWORD_HASH); // Log failed attempt sans userId (utilisateur inconnu ou désactivé) await recordAudit({ userId: null, @@ -96,6 +164,18 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ // Second facteur (TOTP) si activé : un code à 6 chiffres OU un code de // secours à usage unique (haché). Sans second facteur valide, on rejette. if (user.totpEnabled) { + // Plafond par compte : empêche le brute-force du code à 6 chiffres + // (le plafond login par IP ne couvre pas un attaquant distribué). + const rl = await rateLimit("totp", user.id); + if (!rl.allowed) { + await recordAudit({ + userId: user.id, + action: "auth.totp.failed", + target: email, + meta: { reason: "rate_limited" }, + }); + return null; + } const rawCredentials = credentials as Record; const code = typeof rawCredentials.totp === "string" @@ -150,6 +230,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ name: user.name, image: user.avatarUrl, role: user.role, + tokenVersion: user.tokenVersion, }; }, }), @@ -159,6 +240,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ if (user) { token.id = user.id!; token.role = user.role; + token.tokenVersion = user.tokenVersion ?? 0; return token; } // Sessions existantes : on revalide le compte à CHAQUE accès. Sans ça, @@ -172,11 +254,20 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ if (token.id) { try { const [u] = await db - .select({ isActive: users.isActive, role: users.role }) + .select({ + isActive: users.isActive, + role: users.role, + tokenVersion: users.tokenVersion, + }) .from(users) .where(eq(users.id, token.id)) .limit(1); if (!u || !u.isActive) return null; // compte supprimé/désactivé → session détruite + // Invalidation des sessions : un changement de mot de passe ou une + // désactivation 2FA incrémente tokenVersion → les JWT antérieurs sont + // rejetés (les tokens émis avant l'ajout de la colonne ont une version + // absente → comparée à 0, donc préservés, pas de déconnexion massive). + if ((token.tokenVersion ?? 0) !== u.tokenVersion) return null; token.role = u.role; // propage un changement de rôle immédiatement } catch { // blip DB → on conserve la session existante diff --git a/src/auth/types.ts b/src/auth/types.ts index 7dd86c1..420c710 100644 --- a/src/auth/types.ts +++ b/src/auth/types.ts @@ -5,6 +5,7 @@ import type { UserRole } from "@/db/schema/users"; declare module "next-auth" { interface User { role: UserRole; + tokenVersion?: number; } interface Session { user: { @@ -21,5 +22,6 @@ declare module "next-auth/jwt" { interface JWT { id: string; role: UserRole; + tokenVersion?: number; } } diff --git a/src/db/schema/conversations.ts b/src/db/schema/conversations.ts index 92ed1a3..4bf0394 100644 --- a/src/db/schema/conversations.ts +++ b/src/db/schema/conversations.ts @@ -1,4 +1,4 @@ -import { pgTable, uuid, text, timestamp } from "drizzle-orm/pg-core"; +import { pgTable, uuid, text, timestamp, index } from "drizzle-orm/pg-core"; import { users } from "./users"; import { providerKeys } from "./provider-keys"; import { projects } from "./projects"; @@ -19,7 +19,10 @@ export const conversations = pgTable("conversations", { pinnedAt: timestamp("pinned_at"), createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(), -}); +}, (t) => [ + // Liste des conversations d'un utilisateur (sidebar) sur chaque page chat. + index("conversations_user_idx").on(t.userId), +]); export type Conversation = typeof conversations.$inferSelect; export type NewConversation = typeof conversations.$inferInsert; diff --git a/src/db/schema/document-folders.ts b/src/db/schema/document-folders.ts index b7e2249..6cffdb6 100644 --- a/src/db/schema/document-folders.ts +++ b/src/db/schema/document-folders.ts @@ -3,6 +3,7 @@ import { uuid, text, timestamp, + index, type AnyPgColumn, } from "drizzle-orm/pg-core"; import { users } from "./users"; @@ -18,7 +19,11 @@ export const documentFolders = pgTable("document_folders", { ), name: text("name").notNull(), createdAt: timestamp("created_at").defaultNow().notNull(), -}); +}, (t) => [ + // Arbre de dossiers : par propriétaire et par parent (traversée du sous-arbre). + index("document_folders_user_idx").on(t.userId), + index("document_folders_parent_idx").on(t.parentFolderId), +]); export type DocumentFolder = typeof documentFolders.$inferSelect; export type NewDocumentFolder = typeof documentFolders.$inferInsert; diff --git a/src/db/schema/documents.ts b/src/db/schema/documents.ts index b1a3223..e8360db 100644 --- a/src/db/schema/documents.ts +++ b/src/db/schema/documents.ts @@ -4,6 +4,7 @@ import { text, integer, timestamp, + index, type AnyPgColumn, } from "drizzle-orm/pg-core"; import { users } from "./users"; @@ -43,7 +44,13 @@ export const documents = pgTable("documents", { extractionStatus: text("extraction_status").notNull().default("pending"), extractionError: text("extraction_error"), createdAt: timestamp("created_at").defaultNow().notNull(), -}); +}, (t) => [ + // Arbre /documents + familles de versions : filtrés par propriétaire, + // dossier, et document racine (lookup version au /save). + index("documents_user_idx").on(t.userId), + index("documents_folder_idx").on(t.folderId), + index("documents_parent_idx").on(t.parentDocumentId), +]); export type Document = typeof documents.$inferSelect; export type NewDocument = typeof documents.$inferInsert; diff --git a/src/db/schema/messages.ts b/src/db/schema/messages.ts index 16d66ac..150bf32 100644 --- a/src/db/schema/messages.ts +++ b/src/db/schema/messages.ts @@ -6,9 +6,23 @@ import { pgEnum, jsonb, integer, + index, } from "drizzle-orm/pg-core"; +import type { DocumentArtifactMeta } from "@/lib/ai/tool-result"; import { conversations } from "./conversations"; +/** + * Forme (non contrainte par Postgres) de la colonne `metadata` jsonb : + * - message user : `documentIds` (pièces jointes). + * - message assistant : `documents` (artefacts générés/édités ce tour) — + * source de vérité pour rendre la carte d'artefact, indépendante des tool + * parts (qui ne se reconstruisent pas de façon fiable au reload). + */ +export type MessageMetadata = { + documentIds?: string[]; + documents?: DocumentArtifactMeta[]; +}; + export const messageRoleEnum = pgEnum("message_role", [ "user", "assistant", @@ -46,7 +60,7 @@ export const messages = pgTable("messages", { .references(() => conversations.id, { onDelete: "cascade" }), role: messageRoleEnum("role").notNull(), content: text("content").notNull(), - metadata: jsonb("metadata"), + metadata: jsonb("metadata").$type(), // Tool calls + textes au format minimal — null sur les anciens messages // (on retombe alors sur le texte seul). parts: jsonb("parts").$type(), @@ -54,7 +68,12 @@ export const messages = pgTable("messages", { outputTokens: integer("output_tokens"), modelId: text("model_id"), createdAt: timestamp("created_at").defaultNow().notNull(), -}); +}, (t) => [ + // Requête la plus chaude de l'app : tous les messages d'une conversation, + // ordonnés. Index composite (conversationId, createdAt) → plus de scan + // séquentiel qui se dégrade avec le volume. + index("messages_conversation_idx").on(t.conversationId, t.createdAt), +]); export type Message = typeof messages.$inferSelect; export type NewMessage = typeof messages.$inferInsert; diff --git a/src/db/schema/pipelines.ts b/src/db/schema/pipelines.ts index fac3a7c..6f03c2b 100644 --- a/src/db/schema/pipelines.ts +++ b/src/db/schema/pipelines.ts @@ -160,6 +160,8 @@ export const agentRuns = pgTable( (t) => [ index("agent_runs_conversation_idx").on(t.conversationId), index("agent_runs_pipeline_idx").on(t.pipelineId), + // Trail d'audit groupé par message assistant (export, fiche conversation). + index("agent_runs_message_idx").on(t.messageId), ] ); diff --git a/src/db/schema/projects.ts b/src/db/schema/projects.ts index 158c68d..b2b811e 100644 --- a/src/db/schema/projects.ts +++ b/src/db/schema/projects.ts @@ -3,6 +3,7 @@ import { uuid, text, timestamp, + index, type AnyPgColumn, } from "drizzle-orm/pg-core"; import { users } from "./users"; @@ -25,7 +26,10 @@ export const projects = pgTable("projects", { description: text("description"), createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(), -}); +}, (t) => [ + // Liste des projets d'un utilisateur (sidebar, page projets). + index("projects_user_idx").on(t.userId), +]); export type Project = typeof projects.$inferSelect; export type NewProject = typeof projects.$inferInsert; diff --git a/src/db/schema/users.ts b/src/db/schema/users.ts index f7fd80e..3846868 100644 --- a/src/db/schema/users.ts +++ b/src/db/schema/users.ts @@ -33,6 +33,12 @@ export const users = pgTable("users", { totpSecretPending: text("totp_secret_pending"), totpEnabled: boolean("totp_enabled").default(false).notNull(), backupCodes: jsonb("backup_codes").$type(), + // Version d'identifiants : stampée dans le JWT à la connexion, incrémentée au + // changement de mot de passe et à la désactivation 2FA. Le callback jwt + // rejette tout token dont la version a divergé → les sessions existantes sont + // invalidées quand l'utilisateur change son mot de passe (remédiation réelle + // après suspicion de compromission, malgré la stratégie JWT 30 jours). + tokenVersion: integer("token_version").notNull().default(0), createdAt: timestamp("created_at").defaultNow().notNull(), }); diff --git a/src/lib/actions/result.ts b/src/lib/actions/result.ts new file mode 100644 index 0000000..1a97833 --- /dev/null +++ b/src/lib/actions/result.ts @@ -0,0 +1,11 @@ +/** + * Résultat standard d'une server action. `T` ajoute des champs au cas succès : + * ActionResult → { ok: true } | { ok: false; error } + * ActionResult<{ id: string }> → { ok: true; id } | { ok: false; error } + * + * Centralisé pour ne plus redéfinir le même type dans chaque `actions.ts` + * (auparavant ~13 copies, avec de légères divergences). + */ +export type ActionResult = + | ({ ok: true } & T) + | { ok: false; error: string }; diff --git a/src/lib/ai/saved-parts.test.ts b/src/lib/ai/saved-parts.test.ts new file mode 100644 index 0000000..d0f36d7 --- /dev/null +++ b/src/lib/ai/saved-parts.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect } from "vitest"; +import type { SavedPart } from "@/db/schema/messages"; +import { uiPartsFromSaved } from "./saved-parts"; + +describe("uiPartsFromSaved", () => { + it("reconstruit un tool-result ORPHELIN (sans tool-call apparié)", () => { + // Cas réel : l'agrégation multi-agents AI SDK v6 ne conserve que l'état + // terminal → savedParts a un tool-result mais pas de tool-call. La part + // outil doit quand même être réémise (sinon carte document/citations + // disparaissent au reload). + const saved: SavedPart[] = [ + { type: "text", text: "Voici le document." }, + { + type: "tool-result", + toolCallId: "c1", + toolName: "generate_document", + output: { ok: true, data: { document_id: "d1", filename: "acte.docx" } }, + }, + ]; + const parts = uiPartsFromSaved(saved) as Array>; + const tool = parts.find((p) => p.type === "tool-generate_document"); + expect(tool).toBeDefined(); + expect(tool?.state).toBe("output-available"); + expect(tool?.output).toEqual({ + ok: true, + data: { document_id: "d1", filename: "acte.docx" }, + }); + }); + + it("n'émet PAS de doublon quand tool-call ET tool-result sont présents", () => { + const saved: SavedPart[] = [ + { + type: "tool-call", + toolCallId: "c1", + toolName: "generate_document", + input: { title: "x" }, + }, + { + type: "tool-result", + toolCallId: "c1", + toolName: "generate_document", + output: { ok: true, data: { document_id: "d1" } }, + }, + ]; + const parts = uiPartsFromSaved(saved) as Array>; + const tools = parts.filter((p) => p.type === "tool-generate_document"); + expect(tools).toHaveLength(1); + expect(tools[0].state).toBe("output-available"); + expect(tools[0].input).toEqual({ title: "x" }); + expect(tools[0].output).toEqual({ ok: true, data: { document_id: "d1" } }); + }); + + it("tool-call sans résultat → input-available (pending)", () => { + const saved: SavedPart[] = [ + { + type: "tool-call", + toolCallId: "c1", + toolName: "legifrance_search", + input: { query: "x" }, + }, + ]; + const parts = uiPartsFromSaved(saved) as Array>; + expect(parts[0].state).toBe("input-available"); + }); + + it("passe le texte et les data parts", () => { + const saved: SavedPart[] = [ + { type: "text", text: "hello" }, + { type: "data", dataType: "data-agent-event", data: { foo: 1 } }, + ]; + const parts = uiPartsFromSaved(saved) as Array>; + expect(parts).toEqual([ + { type: "text", text: "hello" }, + { type: "data-agent-event", data: { foo: 1 } }, + ]); + }); +}); diff --git a/src/lib/ai/saved-parts.ts b/src/lib/ai/saved-parts.ts index ac5718a..0db45d6 100644 --- a/src/lib/ai/saved-parts.ts +++ b/src/lib/ai/saved-parts.ts @@ -14,10 +14,10 @@ export function uiPartsFromSaved( string, Extract >(); + const callIds = new Set(); for (const p of saved) { - if (p.type === "tool-result") { - resultByCallId.set(p.toolCallId, p); - } + if (p.type === "tool-result") resultByCallId.set(p.toolCallId, p); + else if (p.type === "tool-call") callIds.add(p.toolCallId); } const out: UIMessage["parts"] = []; @@ -33,12 +33,25 @@ export function uiPartsFromSaved( input: p.input, output: result?.output, } as never); + } else if (p.type === "tool-result" && !callIds.has(p.toolCallId)) { + // tool-result ORPHELIN : pas de tool-call apparié. L'agrégation + // multi-agents d'AI SDK v6 ne conserve souvent que l'état terminal + // (output-available) ; sans ce cas, la part outil n'était jamais + // réémise au reload → le rendu riche (cartes document, citations + // Légifrance/Pappers) disparaissait. On la reconstruit avec son output. + out.push({ + type: `tool-${p.toolName}`, + toolCallId: p.toolCallId, + state: "output-available", + input: undefined, + output: p.output, + } as never); } else if (p.type === "data") { // Ré-émet le data part tel que les consommateurs (theatre, badges, // skills) l'attendent : { type: "data-agent-event", data }. out.push({ type: p.dataType, data: p.data } as never); } - // tool-result : déjà pairé avec son tool-call ci-dessus, on saute. + // tool-result apparié à un tool-call : déjà émis ci-dessus, on saute. } return out; } diff --git a/src/lib/ai/tool-result.test.ts b/src/lib/ai/tool-result.test.ts new file mode 100644 index 0000000..d433750 --- /dev/null +++ b/src/lib/ai/tool-result.test.ts @@ -0,0 +1,118 @@ +import { describe, it, expect } from "vitest"; +import { + unwrapToolResult, + toolResultOk, + documentArtifactFromToolResult, +} from "./tool-result"; + +const DOC = { document_id: "doc1", filename: "acte.docx", format: "docx" }; + +describe("unwrapToolResult", () => { + it("dépile l'enveloppe AI SDK json + ToolResult", () => { + expect( + unwrapToolResult({ type: "json", value: { ok: true, data: DOC } }) + ).toEqual(DOC); + }); + it("dépile une enveloppe ToolResult brute", () => { + expect(unwrapToolResult({ ok: true, data: DOC })).toEqual(DOC); + }); + it("parse une string JSON", () => { + expect(unwrapToolResult(JSON.stringify({ ok: true, data: DOC }))).toEqual( + DOC + ); + }); + it("retourne null sur ok:false", () => { + expect(unwrapToolResult({ ok: false, error: "x" })).toBeNull(); + }); + it("retourne l'objet déjà dépouillé (sans enveloppe ok/data)", () => { + expect(unwrapToolResult(DOC)).toEqual(DOC); + }); + it("retourne null sur null / primitive", () => { + expect(unwrapToolResult(null)).toBeNull(); + expect(unwrapToolResult(42)).toBeNull(); + }); +}); + +describe("toolResultOk (préserve ok:false)", () => { + it("lit ok sur enveloppe json", () => { + expect(toolResultOk({ type: "json", value: { ok: true, data: DOC } })).toEqual( + { ok: true, error: undefined } + ); + }); + it("lit l'échec + error", () => { + expect( + toolResultOk({ type: "json", value: { ok: false, error: "boom" } }) + ).toEqual({ ok: false, error: "boom" }); + }); + it("ok:false par défaut sur sortie malformée", () => { + expect(toolResultOk(null)).toEqual({ ok: false, error: undefined }); + expect(toolResultOk("pas du json")).toEqual({ ok: false, error: undefined }); + }); +}); + +describe("documentArtifactFromToolResult", () => { + it("extrait l'artefact d'un generate_document (enveloppe json)", () => { + expect( + documentArtifactFromToolResult("generate_document", { + type: "json", + value: { ok: true, data: DOC }, + }) + ).toEqual({ + documentId: "doc1", + filename: "acte.docx", + format: "docx", + kind: "generated", + }); + }); + it("extrait l'artefact d'un edit_document (enveloppe brute)", () => { + expect( + documentArtifactFromToolResult("edit_document", { + ok: true, + data: { document_id: "d2", filename: "v2.docx", format: "docx" }, + }) + ).toEqual({ + documentId: "d2", + filename: "v2.docx", + format: "docx", + kind: "edited", + }); + }); + it("normalise un format inconnu vers docx", () => { + expect( + documentArtifactFromToolResult("generate_document", { + ok: true, + data: { document_id: "d3", filename: "x", format: undefined }, + })?.format + ).toBe("docx"); + }); + it("retourne null sur ok:false", () => { + expect( + documentArtifactFromToolResult("generate_document", { + ok: false, + error: "gotenberg ko", + }) + ).toBeNull(); + }); + it("retourne null pour un outil non-effectif", () => { + expect( + documentArtifactFromToolResult("legifrance_search", { + ok: true, + data: DOC, + }) + ).toBeNull(); + }); + it("retourne null si document_id ou filename manquent", () => { + expect( + documentArtifactFromToolResult("generate_document", { + ok: true, + data: { filename: "x.docx" }, + }) + ).toBeNull(); + expect( + documentArtifactFromToolResult("generate_document", { + ok: true, + data: { document_id: "d" }, + }) + ).toBeNull(); + }); +}); diff --git a/src/lib/ai/tool-result.ts b/src/lib/ai/tool-result.ts new file mode 100644 index 0000000..6575d47 --- /dev/null +++ b/src/lib/ai/tool-result.ts @@ -0,0 +1,118 @@ +/** + * Décodage des résultats d'outils (ToolResult) — source UNIQUE partagée + * serveur + client. + * + * L'AI SDK v6 emballe la sortie d'un tool dans `{ type: "json", value: {...} }` + * (ou `{ type: "text", text: "" }` pour les retours scalaires). Nos tools + * renvoient ensuite une enveloppe métier `{ ok: true, data: {...} }` (toolOk) ou + * `{ ok: false, error }`. Centraliser le décodage évite que serveur (persistance, + * audit) et client (rendu) divergent sur la forme de l'enveloppe — divergence + * qui causait à la fois la perte de la carte d'artefact et de faux + * « deliverable.failed » dans l'audit. + */ + +/** + * Retire les couches d'enveloppe AI SDK (string JSON, `{type:"json",value}`, + * `{type:"text",text}`) pour exposer l'objet ToolResult brut `{ ok, data, error }`. + * Ne juge PAS le succès — préserve `ok:false` (à la différence de + * `unwrapToolResult`). Retourne null si la valeur n'est pas un objet exploitable. + */ +export function peelToolEnvelope(o: unknown): Record | null { + let candidate: unknown = o; + if (candidate == null) return null; + + // Couche 1 : string JSON. + if (typeof candidate === "string") { + try { + candidate = JSON.parse(candidate); + } catch { + return null; + } + } + if (typeof candidate !== "object" || candidate === null) return null; + + // Couche 2 : enveloppe AI SDK { type, value/text }. + const ai = candidate as Record; + if ("type" in ai && "value" in ai && ai.type === "json") { + candidate = ai.value; + } else if ("type" in ai && "text" in ai && ai.type === "text") { + try { + candidate = JSON.parse(String(ai.text)); + } catch { + return null; + } + } + if (typeof candidate !== "object" || candidate === null) return null; + + return candidate as Record; +} + +/** + * Récupère la `data` métier d'un ToolResult réussi, sinon null + * (`ok:false` ou non-résultat → null). Pour le rendu/extraction côté client. + */ +export function unwrapToolResult(o: unknown): T | null { + const env = peelToolEnvelope(o); + if (!env) return null; + // Échec explicite → pas de data exploitable (même sans champ `data`). + if ("ok" in env && env.ok === false) return null; + if ("ok" in env && "data" in env) return env.data as T; + return env as T; +} + +/** + * Lit le statut d'un ToolResult, enveloppe-agnostique, en PRÉSERVANT l'échec. + * Utilisé par l'audit de livrable (verify.ts) qui doit distinguer succès et + * échec silencieux d'un outil effectif. `ok` vaut false par défaut. + */ +export function toolResultOk(o: unknown): { ok: boolean; error?: string } { + const env = peelToolEnvelope(o); + const ok = !!(env && env.ok === true); + const error = + env && typeof env.error === "string" ? (env.error as string) : undefined; + return { ok, error }; +} + +/** Métadonnée d'artefact document persistée dans `messages.metadata.documents`. */ +export type DocumentArtifactMeta = { + documentId: string; + filename: string; + format: "docx" | "pdf"; + kind: "generated" | "edited"; +}; + +const DOC_TOOL_KINDS: Record = { + generate_document: "generated", + edit_document: "edited", +}; + +/** + * Si `output` est le résultat réussi d'un outil document (generate/edit), + * retourne son artefact canonique (id, nom, format, type) — sinon null. + * Indépendant de l'enveloppe et de la prose du modèle. + */ +export function documentArtifactFromToolResult( + toolName: string, + output: unknown +): DocumentArtifactMeta | null { + const kind = DOC_TOOL_KINDS[toolName]; + if (!kind) return null; + const data = unwrapToolResult<{ + document_id?: unknown; + filename?: unknown; + format?: unknown; + }>(output); + if ( + !data || + typeof data.document_id !== "string" || + typeof data.filename !== "string" + ) { + return null; + } + return { + documentId: data.document_id, + filename: data.filename, + format: data.format === "pdf" ? "pdf" : "docx", + kind, + }; +} diff --git a/src/lib/audit/labels.ts b/src/lib/audit/labels.ts index c273d13..009336e 100644 --- a/src/lib/audit/labels.ts +++ b/src/lib/audit/labels.ts @@ -18,10 +18,18 @@ export const ACTION_LABEL: Record = { "provider.toggle": "Clé provider activée/désactivée", "connector.add": "Connecteur ajouté", "connector.delete": "Connecteur supprimé", + "mcp.add": "Serveur MCP ajouté", + "mcp.delete": "Serveur MCP supprimé", + "mcp.toggle": "Serveur MCP activé/désactivé", "doc.delete": "Document supprimé", + "doc.save": "Document enregistré", "cabinet.update": "Configuration cabinet modifiée", "auth.login": "Connexion", "auth.login.failed": "Échec de connexion", + "auth.password.change": "Mot de passe modifié", + "auth.totp.enabled": "2FA activée", + "auth.totp.disabled": "2FA désactivée", + "auth.totp.failed": "Échec 2FA", }; export function labelForAction(action: string): string { diff --git a/src/lib/auth/permissions.ts b/src/lib/auth/permissions.ts index 403f3bf..7ef93b8 100644 --- a/src/lib/auth/permissions.ts +++ b/src/lib/auth/permissions.ts @@ -7,6 +7,17 @@ export class PermissionDeniedError extends Error { } } +/** + * Renvoie l'id de l'utilisateur connecté, ou lève si la session est absente. + * Helper partagé par toutes les server actions (auparavant redéfini à + * l'identique dans chaque fichier `actions.ts`). + */ +export async function requireUserId(): Promise { + const session = await auth(); + if (!session?.user?.id) throw new Error("Unauthorized"); + return session.user.id; +} + export async function requireAdmin(): Promise<{ userId: string }> { const session = await auth(); if (!session?.user?.id) { diff --git a/src/lib/connectors/pappers.ts b/src/lib/connectors/pappers.ts index 63a40c2..0053a79 100644 --- a/src/lib/connectors/pappers.ts +++ b/src/lib/connectors/pappers.ts @@ -126,9 +126,18 @@ export async function pappersGet( userId: string, siren: string ): Promise> { + // Valide AVANT l'appel facturé : un SIREN non conforme (9 chiffres) gaspille + // une requête API et renvoie de toute façon une erreur côté Pappers. + const cleaned = siren.replace(/\s/g, ""); + if (!/^\d{9}$/.test(cleaned)) { + return toolError( + "config", + "SIREN invalide : 9 chiffres attendus (ex. 552032534)." + ); + } return runTool(() => pappersFetch(userId, "/entreprise", { - siren: siren.replace(/\s/g, ""), + siren: cleaned, }) ); } diff --git a/src/lib/connectors/tools.ts b/src/lib/connectors/tools.ts index 813f872..07bd76f 100644 --- a/src/lib/connectors/tools.ts +++ b/src/lib/connectors/tools.ts @@ -305,9 +305,17 @@ export async function buildToolsForUser( }), execute: async ({ format, sections, ...rest }) => runTool(async () => { + // Le modèle produit des listes plates (items = string[]) ; on les + // convertit au modèle interne ListItem (niveau 0). La numérotation + // multi-niveaux est réservée au round-trip éditeur (from-prosemirror). + const normalizedSections = sections.map((s) => + s.kind === "list" + ? { ...s, items: s.items.map((text) => ({ text, level: 0 })) } + : s + ); const result = await generateAndStore({ format, - spec: { ...rest, sections }, + spec: { ...rest, sections: normalizedSections }, userId, folderId: generatedDocFolderId, }); diff --git a/src/lib/docgen/docx-tracked.test.ts b/src/lib/docgen/docx-tracked.test.ts new file mode 100644 index 0000000..750055c --- /dev/null +++ b/src/lib/docgen/docx-tracked.test.ts @@ -0,0 +1,27 @@ +import { describe, it, expect } from "vitest"; +import { generateDocx } from "./docx"; +import { applyTrackedEdits, extractDocxBodyText } from "./docx-tracked"; + +/** + * Régression #8 : après une modification suivie, extractDocxBodyText (outil + * read_document) doit refléter le texte COURANT — insertions incluses, + * suppressions exclues — sinon le modèle relit un document faux et la 2ᵉ passe + * d'édition échoue. + */ +describe("extractDocxBodyText sur un document à modifications suivies (#8)", () => { + it("inclut le texte inséré et exclut le texte supprimé", async () => { + const base = await generateDocx({ + title: "Bail", + sections: [{ kind: "paragraph", content: "Le loyer est de 1000 euros." }], + }); + + const res = await applyTrackedEdits(base, [{ find: "1000", replace: "1200" }]); + expect(res.applied.length).toBe(1); + expect(res.errors.length).toBe(0); + + const text = await extractDocxBodyText(res.buffer); + // Avant le fix : "Le loyer est de euros." (insertion perdue, double espace). + expect(text).toContain("Le loyer est de 1200 euros."); + expect(text).not.toContain("1000"); + }); +}); diff --git a/src/lib/docgen/docx-tracked.ts b/src/lib/docgen/docx-tracked.ts index 1c58465..902c05a 100644 --- a/src/lib/docgen/docx-tracked.ts +++ b/src/lib/docgen/docx-tracked.ts @@ -176,6 +176,44 @@ function flattenRuns(paraChildren: XNode[]): { return { text, runs }; } +/** + * Texte « courant » d'un paragraphe pour la LECTURE (read_document) : inclut le + * texte des insertions suivies (w:ins) et EXCLUT les suppressions suivies + * (w:del) — soit le document tel qu'il sera accepté. Distinct de flattenRuns, + * qui sert au chemin d'écriture (indices de runs top-level) et ne DOIT PAS + * descendre dans w:ins sous peine de fausser le splice. Sans ça, read_document + * renvoyait un texte amputé des insertions précédentes (le modèle relisait un + * document faux) avec une double-espace là où une suppression avait eu lieu. + */ +function paragraphCurrentText(paraChildren: XNode[]): string { + let text = ""; + const walk = (nodes: XNode[]) => { + for (const child of nodes) { + const name = elName(child); + if (!name) continue; + // Texte marqué supprimé → hors du texte courant. + if (name === "w:del") continue; + if (name === "w:t") { + for (const piece of elChildren(child)) { + if (elName(piece) === "#text") { + const v = (piece as Record)["#text"]; + if (typeof v === "string") text += v; + } + } + } else if ( + name === "w:r" || + name === "w:ins" || + name === "w:hyperlink" || + name === "w:smartTag" + ) { + walk(elChildren(child)); + } + } + }; + walk(paraChildren); + return text; +} + /** * Cherche `find` dans le texte normalisé du paragraphe avec un contexte * optionnel autour. Renvoie la position dans le texte ORIGINAL (non @@ -539,7 +577,7 @@ export async function extractDocxBodyText(docxBytes: Buffer): Promise { if (!body) return ""; const out: string[] = []; forEachParagraph(body, (children) => { - out.push(flattenRuns(children).text); + out.push(paragraphCurrentText(children)); }); return out.filter((s) => s.length > 0).join("\n"); } diff --git a/src/lib/docgen/docx.ts b/src/lib/docgen/docx.ts index b9a2ad5..94e84de 100644 --- a/src/lib/docgen/docx.ts +++ b/src/lib/docgen/docx.ts @@ -95,17 +95,17 @@ function sectionToDocxChildren( }), ]; case "list": - return section.items.map( - (item) => - new Paragraph({ - numbering: section.ordered - ? { reference: "ordered", level: 0 } - : undefined, - bullet: section.ordered ? undefined : { level: 0 }, - spacing: { after: 80 }, - children: makeRuns(item, font), - }) - ); + return section.items.map((item) => { + const level = Math.min(Math.max(item.level, 0), 4); + return new Paragraph({ + numbering: section.ordered + ? { reference: "ordered", level } + : undefined, + bullet: section.ordered ? undefined : { level }, + spacing: { after: 80 }, + children: makeRuns(item.text, font), + }); + }); case "blockquote": return [ new Paragraph({ @@ -319,13 +319,14 @@ export async function generateDocx(spec: DocumentSpec): Promise { config: [ { reference: "ordered", + // Niveaux d'imbrication 0–4 (1. → a. → i. → 1. → a.), pour rendre les + // clauses multi-niveaux d'un acte au lieu de les aplatir. levels: [ - { - level: 0, - format: "decimal", - text: "%1.", - alignment: AlignmentType.LEFT, - }, + { level: 0, format: "decimal", text: "%1.", alignment: AlignmentType.LEFT }, + { level: 1, format: "lowerLetter", text: "%2.", alignment: AlignmentType.LEFT }, + { level: 2, format: "lowerRoman", text: "%3.", alignment: AlignmentType.LEFT }, + { level: 3, format: "decimal", text: "%4.", alignment: AlignmentType.LEFT }, + { level: 4, format: "lowerLetter", text: "%5.", alignment: AlignmentType.LEFT }, ], }, ], diff --git a/src/lib/docgen/from-prosemirror.test.ts b/src/lib/docgen/from-prosemirror.test.ts new file mode 100644 index 0000000..d195bb4 --- /dev/null +++ b/src/lib/docgen/from-prosemirror.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect } from "vitest"; +import { editorJsonToSpec } from "./from-prosemirror"; +import { parseInline } from "./markdown-blocks"; + +/** + * Régression: le round-trip éditeur → DocumentSpec → (parseInline lors de la + * génération DOCX) ne doit JAMAIS supprimer les `_` / `*` littéraux d'un acte, + * ni les transformer en emphase. + */ +describe("editorJsonToSpec — round-trip texte littéral", () => { + const doc = { + type: "doc", + content: [ + { type: "paragraph", content: [{ type: "text", text: "Contrat de bail" }] }, + { + type: "paragraph", + content: [ + { type: "text", text: "Réf article_2 et clause 3 * 4 ; " }, + { type: "text", text: "important", marks: [{ type: "bold" }] }, + { type: "text", text: " puis fin_de_ligne." }, + ], + }, + ], + }; + + it("préserve underscores/astérisques littéraux et le gras réel", () => { + const spec = editorJsonToSpec(doc, "Sans titre"); + expect(spec.title).toBe("Contrat de bail"); + + const para = spec.sections.find((s) => s.kind === "paragraph"); + expect(para).toBeDefined(); + const content = (para as { content: string }).content; + + const runs = parseInline(content); + // Le texte visible reconstruit est intact (rien n'est avalé). + expect(runs.map((r) => r.text).join("")).toBe( + "Réf article_2 et clause 3 * 4 ; important puis fin_de_ligne." + ); + // Seul "important" est en gras, aucun italique parasite. + expect(runs.filter((r) => r.bold)).toEqual([ + { text: "important", bold: true }, + ]); + expect(runs.some((r) => r.italic)).toBe(false); + // Aucun backslash d'échappement ne fuit dans le rendu final. + expect(runs.some((r) => r.text.includes("\\"))).toBe(false); + }); + + it("conserve les `_` dans un item de liste", () => { + const listDoc = { + type: "doc", + content: [ + { type: "paragraph", content: [{ type: "text", text: "Titre" }] }, + { + type: "bulletList", + content: [ + { + type: "listItem", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "champ_obligatoire" }], + }, + ], + }, + ], + }, + ], + }; + const spec = editorJsonToSpec(listDoc, "Sans titre"); + const list = spec.sections.find((s) => s.kind === "list") as { + items: { text: string; level: number }[]; + }; + expect(list).toBeDefined(); + expect(parseInline(list.items[0].text).map((r) => r.text).join("")).toBe( + "champ_obligatoire" + ); + }); + + it("préserve les listes imbriquées (niveaux) et les items multi-paragraphes", () => { + const doc = { + type: "doc", + content: [ + { type: "paragraph", content: [{ type: "text", text: "Titre" }] }, + { + type: "orderedList", + content: [ + { + type: "listItem", + content: [ + // Item avec DEUX paragraphes — les deux doivent survivre. + { type: "paragraph", content: [{ type: "text", text: "Clause un" }] }, + { type: "paragraph", content: [{ type: "text", text: "suite clause un" }] }, + // Sous-liste imbriquée → niveau 1. + { + type: "orderedList", + content: [ + { + type: "listItem", + content: [ + { type: "paragraph", content: [{ type: "text", text: "sous-clause" }] }, + ], + }, + ], + }, + ], + }, + { + type: "listItem", + content: [ + { type: "paragraph", content: [{ type: "text", text: "Clause deux" }] }, + ], + }, + ], + }, + ], + }; + const spec = editorJsonToSpec(doc, "Sans titre"); + const list = spec.sections.find((s) => s.kind === "list") as { + ordered: boolean; + items: { text: string; level: number }[]; + }; + expect(list).toBeDefined(); + expect(list.ordered).toBe(true); + expect(list.items).toEqual([ + { text: "Clause un suite clause un", level: 0 }, + { text: "sous-clause", level: 1 }, + { text: "Clause deux", level: 0 }, + ]); + }); +}); diff --git a/src/lib/docgen/from-prosemirror.ts b/src/lib/docgen/from-prosemirror.ts new file mode 100644 index 0000000..4640572 --- /dev/null +++ b/src/lib/docgen/from-prosemirror.ts @@ -0,0 +1,149 @@ +import type { DocumentSpec, ListItem, Section } from "./types"; + +/** + * Convertit le document JSON d'un éditeur Tiptap (ProseMirror) en + * `DocumentSpec`, pour le ré-exporter en .docx via `generateDocx` — donc + * en réutilisant le style maison (police Cambria, marges, footer Page X/Y). + * + * Limites assumées (MVP) : + * - Le générateur .docx (`parseInline`) ne connaît que **gras** et _italique_. + * Le souligné et les liens sont donc rendus en texte simple à l'export. + * - Le 1er bloc de l'éditeur devient le titre (convention « titre en tête », + * naturelle pour un acte), pour round-tripper proprement avec le bloc-titre + * que `generateDocx` rend toujours en haut. + */ + +type PMMark = { type: string }; +type PMNode = { + type: string; + attrs?: Record; + content?: PMNode[]; + text?: string; + marks?: PMMark[]; +}; + +/** Texte brut d'un contenu inline (marks ignorées) — pour titres/headings. */ +function inlineToPlain(content: PMNode[] | undefined): string { + if (!content) return ""; + return content + .map((n) => (n.type === "text" ? (n.text ?? "") : n.type === "hardBreak" ? " " : "")) + .join(""); +} + +/** + * Échappe les caractères que `parseInline` interpréterait comme du markdown + * (`*`, `_`) ainsi que le backslash lui-même. Indispensable pour ne pas + * corrompre le texte littéral d'un acte (placeholders `article_2`, blancs à + * remplir `____`, expressions `3 * 4`) lors du round-trip éditeur → DOCX. + */ +function escapeInline(text: string): string { + return text.replace(/([\\*_])/g, "\\$1"); +} + +/** Contenu inline → markdown gras/italique (le reste passe en texte brut). */ +function inlineToMarkdown(content: PMNode[] | undefined): string { + if (!content) return ""; + let out = ""; + for (const node of content) { + if (node.type === "hardBreak") { + out += " "; + continue; + } + if (node.type !== "text" || !node.text) continue; + let t = escapeInline(node.text); + const marks = new Set((node.marks ?? []).map((m) => m.type)); + if (marks.has("bold")) t = `**${t}**`; + if (marks.has("italic")) t = `_${t}_`; + out += t; + } + return out; +} + +/** + * Aplatit récursivement une liste Tiptap en `ListItem[]` porteurs de leur + * niveau. Corrige deux pertes de l'ancienne version : (1) seul le 1er paragraphe + * d'un item était lu — on lit désormais TOUS les blocs de texte de l'item ; + * (2) les sous-listes étaient ignorées — on descend dedans en incrémentant le + * niveau. Le type (numéroté/à puces) du parent s'applique aux sous-niveaux. + */ +function collectListItems( + listNode: PMNode, + level: number, + out: ListItem[] +): void { + for (const li of listNode.content ?? []) { + if (li.type !== "listItem") continue; + const children = li.content ?? []; + const ownText = children + .filter((c) => c.type !== "bulletList" && c.type !== "orderedList") + .map((c) => inlineToMarkdown(c.content)) + .filter((s) => s.trim().length > 0) + .join(" "); + if (ownText.trim()) out.push({ text: ownText, level }); + for (const c of children) { + if (c.type === "bulletList" || c.type === "orderedList") { + collectListItems(c, level + 1, out); + } + } + } +} + +function blockToSections(node: PMNode): Section[] { + switch (node.type) { + case "heading": { + const lvl = Number(node.attrs?.level ?? 2); + const level = (lvl >= 1 && lvl <= 4 ? lvl : 3) as 1 | 2 | 3 | 4; + return [{ kind: "heading", level, text: inlineToPlain(node.content) }]; + } + case "paragraph": { + const content = inlineToMarkdown(node.content); + // Paragraphe vide → espace vertical (préserve le rythme de l'acte). + return content.trim() + ? [{ kind: "paragraph", content }] + : [{ kind: "spacer", lines: 1 }]; + } + case "bulletList": + case "orderedList": { + const items: ListItem[] = []; + collectListItems(node, 0, items); + return items.length + ? [{ kind: "list", ordered: node.type === "orderedList", items }] + : []; + } + case "blockquote": { + const text = (node.content ?? []) + .map((p) => inlineToMarkdown(p.content)) + .filter((s) => s.trim().length > 0) + .join(" "); + return text ? [{ kind: "blockquote", content: text }] : []; + } + case "horizontalRule": + return [{ kind: "hr" }]; + default: { + // Type inconnu : on tente de récupérer le texte plutôt que le perdre. + const t = inlineToMarkdown(node.content); + return t.trim() ? [{ kind: "paragraph", content: t }] : []; + } + } +} + +export function editorJsonToSpec( + doc: PMNode, + fallbackTitle: string +): DocumentSpec { + const blocks = doc.content ?? []; + + // Premier bloc non vide = titre ; le reste = corps. + let title = fallbackTitle; + let rest = blocks; + if (blocks.length > 0) { + const firstText = inlineToPlain(blocks[0].content).trim(); + if (firstText) { + title = firstText; + rest = blocks.slice(1); + } + } + + const sections = rest.flatMap(blockToSections); + return { title, sections, fontFamily: "serif" }; +} diff --git a/src/lib/docgen/index.ts b/src/lib/docgen/index.ts index 88e8c65..61918ad 100644 --- a/src/lib/docgen/index.ts +++ b/src/lib/docgen/index.ts @@ -64,6 +64,9 @@ export async function generateAndStore({ * Persiste un Buffer (docx ou pdf) dans S3 + row dans documents. * Utilisé par generate_document (sortie de generateDocx/Pdf) et par * edit_document (sortie de applyTrackedEdits). + * + * `parentDocumentId` + `version` permettent d'enregistrer une révision + * (édition WYSIWYG du DocPanel) rattachée à la famille du document d'origine. */ export async function storeBuffer({ buffer, @@ -72,6 +75,8 @@ export async function storeBuffer({ userId, projectId, folderId, + parentDocumentId, + version, }: { buffer: Buffer; contentType: string; @@ -79,6 +84,8 @@ export async function storeBuffer({ userId: string; projectId?: string | null; folderId?: string | null; + parentDocumentId?: string | null; + version?: number; }): Promise<{ documentId: string; filename: string; format: DocFormat }> { const baseKey = `${userId}/louis-generated/${nanoid()}-${filename}`; await uploadObject(baseKey, buffer, contentType); @@ -122,6 +129,8 @@ export async function storeBuffer({ userId, projectId: projectId ?? null, folderId: folderId ?? null, + parentDocumentId: parentDocumentId ?? null, + version: version ?? 1, filename, contentType, sizeBytes: buffer.length, diff --git a/src/lib/docgen/markdown-blocks.test.ts b/src/lib/docgen/markdown-blocks.test.ts new file mode 100644 index 0000000..243bcb0 --- /dev/null +++ b/src/lib/docgen/markdown-blocks.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from "vitest"; +import { parseInline } from "./markdown-blocks"; + +describe("parseInline", () => { + it("parse le **gras** et l'_italique_ non échappés (markdown IA)", () => { + expect(parseInline("un **mot** gras")).toEqual([ + { text: "un " }, + { text: "mot", bold: true }, + { text: " gras" }, + ]); + expect(parseInline("un _mot_ italique")).toEqual([ + { text: "un " }, + { text: "mot", italic: true }, + { text: " italique" }, + ]); + expect(parseInline("__gras__ aussi")).toEqual([ + { text: "gras", bold: true }, + { text: " aussi" }, + ]); + }); + + it("traite `\\_` et `\\*` échappés comme des littéraux (pas d'emphase)", () => { + expect(parseInline("article\\_2")).toEqual([{ text: "article_2" }]); + expect(parseInline("3 \\* 4 \\* 5")).toEqual([{ text: "3 * 4 * 5" }]); + // Un placeholder à remplir ne doit pas devenir de l'italique. + expect(parseInline("Le \\_\\_\\_\\_ soussigné")).toEqual([ + { text: "Le ____ soussigné" }, + ]); + }); + + it("préserve l'emphase réelle même au milieu de littéraux échappés", () => { + const runs = parseInline("réf article\\_2 puis **clause** finale\\_x"); + expect(runs.map((r) => r.text).join("")).toBe( + "réf article_2 puis clause finale_x" + ); + expect(runs.filter((r) => r.bold)).toEqual([{ text: "clause", bold: true }]); + expect(runs.some((r) => r.italic)).toBe(false); + expect(runs.some((r) => r.text.includes("\\"))).toBe(false); + }); + + it("retourne un run unique pour un texte sans markup", () => { + expect(parseInline("texte simple")).toEqual([{ text: "texte simple" }]); + }); + + it("ne casse pas sur un délimiteur orphelin", () => { + // Une seule étoile sans fermeture reste littérale. + expect(parseInline("a * b")).toEqual([{ text: "a * b" }]); + }); +}); diff --git a/src/lib/docgen/markdown-blocks.ts b/src/lib/docgen/markdown-blocks.ts index 5122ae7..55a1964 100644 --- a/src/lib/docgen/markdown-blocks.ts +++ b/src/lib/docgen/markdown-blocks.ts @@ -13,29 +13,85 @@ export type InlineRun = { italic?: boolean; }; +/** Retire un niveau d'échappement (`\x` → `x`). */ +function unescapeInline(s: string): string { + return s.replace(/\\([\s\S])/g, "$1"); +} + /** * Convertit un texte inline en runs typés. Supporte **gras** et _italique_ * (la version simple/legal-friendly, pas le markdown complet). + * + * Les caractères `*` / `_` précédés d'un backslash sont traités comme + * littéraux et n'ouvrent/ferment pas d'emphase — ce qui permet à + * `from-prosemirror` de préserver le texte brut d'un acte au round-trip. + * Le texte non échappé (markdown généré par l'IA) garde le comportement + * historique. */ export function parseInline(line: string): InlineRun[] { const runs: InlineRun[] = []; - const pattern = /(\*\*[^*]+\*\*|__[^_]+__|\*[^*]+\*|_[^_]+_)/g; - let lastIndex = 0; - for (const match of line.matchAll(pattern)) { - if (match.index! > lastIndex) { - runs.push({ text: line.slice(lastIndex, match.index) }); + let buf = ""; + let i = 0; + + const flush = () => { + if (buf) { + runs.push({ text: buf }); + buf = ""; } - const token = match[0]; - if (token.startsWith("**") || token.startsWith("__")) { - runs.push({ text: token.slice(2, -2), bold: true }); - } else { - runs.push({ text: token.slice(1, -1), italic: true }); + }; + + // Cherche le délimiteur de fermeture `delim` à partir de `from`, en + // ignorant les occurrences échappées (précédées d'un backslash). + const findClose = (delim: string, from: number): number => { + let j = from; + while (j <= line.length - delim.length) { + if (line[j] === "\\") { + j += 2; + continue; + } + if (line.startsWith(delim, j)) return j; + j += 1; } - lastIndex = match.index! + token.length; - } - if (lastIndex < line.length) { - runs.push({ text: line.slice(lastIndex) }); + return -1; + }; + + while (i < line.length) { + const ch = line[i]; + + // Échappement : `\x` → `x` littéral. + if (ch === "\\" && i + 1 < line.length) { + buf += line[i + 1]; + i += 2; + continue; + } + + // Gras : `**…**` ou `__…__`. + const two = line.slice(i, i + 2); + if (two === "**" || two === "__") { + const close = findClose(two, i + 2); + if (close > i + 2) { + flush(); + runs.push({ text: unescapeInline(line.slice(i + 2, close)), bold: true }); + i = close + 2; + continue; + } + } + + // Italique : `*…*` ou `_…_`. + if (ch === "*" || ch === "_") { + const close = findClose(ch, i + 1); + if (close > i + 1) { + flush(); + runs.push({ text: unescapeInline(line.slice(i + 1, close)), italic: true }); + i = close + 1; + continue; + } + } + + buf += ch; + i += 1; } + flush(); return runs.length > 0 ? runs : [{ text: line }]; } diff --git a/src/lib/docgen/pdf.ts b/src/lib/docgen/pdf.ts index 6a68a59..3e59300 100644 --- a/src/lib/docgen/pdf.ts +++ b/src/lib/docgen/pdf.ts @@ -149,15 +149,24 @@ function writeSection( }); doc.moveDown(0.6); return; - case "list": + case "list": { doc.font(fonts.regular).fontSize(11).fillColor("black"); - section.items.forEach((item, i) => { - const prefix = section.ordered ? `${i + 1}. ` : "• "; + const baseX = doc.x; + // Compteur de numérotation par niveau (un niveau plus profond repart à 1). + const counters: number[] = []; + section.items.forEach((item) => { + const level = Math.max(item.level, 0); + counters[level] = (counters[level] ?? 0) + 1; + counters.length = level + 1; + doc.x = baseX + level * 16; + const prefix = section.ordered ? `${counters[level]}. ` : "• "; doc.text(prefix, { continued: true }); - writeInline(doc, parseInline(item), fonts, { lineGap: 2 }); + writeInline(doc, parseInline(item.text), fonts, { lineGap: 2 }); }); + doc.x = baseX; doc.moveDown(0.5); return; + } case "blockquote": { doc.moveDown(0.3); doc.font(fonts.italic).fontSize(11).fillColor("#333"); diff --git a/src/lib/docgen/types.ts b/src/lib/docgen/types.ts index 1b9821e..7a53a55 100644 --- a/src/lib/docgen/types.ts +++ b/src/lib/docgen/types.ts @@ -11,6 +11,12 @@ export type Alignment = "left" | "center" | "right" | "justify"; +/** + * Item de liste avec son niveau d'imbrication (0 = racine). Permet de + * préserver les clauses multi-niveaux d'un acte au lieu de les aplatir. + */ +export type ListItem = { text: string; level: number }; + export type Section = | { kind: "heading"; @@ -28,7 +34,7 @@ export type Section = | { kind: "list"; ordered: boolean; - items: string[]; + items: ListItem[]; } | { kind: "blockquote"; diff --git a/src/lib/orchestrator/cost-estimate.ts b/src/lib/orchestrator/cost-estimate.ts index df05e8d..346a98b 100644 --- a/src/lib/orchestrator/cost-estimate.ts +++ b/src/lib/orchestrator/cost-estimate.ts @@ -1,5 +1,13 @@ import { computeCost, type Cost } from "@/lib/providers/pricing"; -import type { PipelineMode } from "./types"; +import { + DEFAULT_ITERATIVE_ROUNDS, + MAX_COUNCIL_ROUNDS, + MAX_ITERATIVE_ROUNDS, + type PipelineMode, +} from "./types"; + +const clampRounds = (rounds: number, max: number): number => + Math.max(1, Math.min(Math.floor(rounds), max)); /** * Nombre d'appels LLM qu'un run de pipeline déclenchera, selon le mode. @@ -20,15 +28,20 @@ export function estimateCalls(opts: { rounds?: number; }): number { const agents = Math.max(1, Math.floor(opts.agents)); - const rounds = Math.max(1, Math.floor(opts.rounds ?? 1)); if (opts.mode === "iterative") { + // Même défaut (2) et même plafond (4) que l'exécution (orchestrator). + const rounds = clampRounds( + opts.rounds ?? DEFAULT_ITERATIVE_ROUNDS, + MAX_ITERATIVE_ROUNDS + ); // Le chercheur (1er agent) tourne `rounds` fois ; +1 synthèse si terminal distinct. return rounds + (agents > 1 ? 1 : 0); } if (agents <= 1) return 1; switch (opts.mode) { case "council": - return rounds * (agents - 1) + 1; + // Même plafond (6) que l'exécution, sinon le coût sur-estime. + return clampRounds(opts.rounds ?? 1, MAX_COUNCIL_ROUNDS) * (agents - 1) + 1; case "parallel": return agents - 1 + 1; case "sequential": diff --git a/src/lib/orchestrator/orchestrator.ts b/src/lib/orchestrator/orchestrator.ts index 71008ad..6a20e12 100644 --- a/src/lib/orchestrator/orchestrator.ts +++ b/src/lib/orchestrator/orchestrator.ts @@ -1,6 +1,11 @@ import { nanoid } from "nanoid"; import { DefaultAgent, resolveAgentConstructor } from "./agents"; import { withRetry } from "./retry"; +import { + DEFAULT_ITERATIVE_ROUNDS, + MAX_COUNCIL_ROUNDS, + MAX_ITERATIVE_ROUNDS, +} from "./types"; import type { Agent, AgentContext, @@ -161,7 +166,10 @@ export class Orchestrator { const { ctx, writer } = args; const factory = args.agentFactory ?? defaultAgentFactory; const pipelineRunId = ctx.pipelineRunId ?? nanoid(); - const rounds = Math.max(1, Math.min(this.pipeline.rounds ?? 1, 6)); + const rounds = Math.max( + 1, + Math.min(this.pipeline.rounds ?? 1, MAX_COUNCIL_ROUNDS) + ); const agents = this.pipeline.agents; const synthesizer = agents[agents.length - 1]; const debaters = agents.slice(0, -1); @@ -363,7 +371,10 @@ export class Orchestrator { const { ctx, writer } = args; const factory = args.agentFactory ?? defaultAgentFactory; const pipelineRunId = ctx.pipelineRunId ?? nanoid(); - const rounds = Math.max(1, Math.min(this.pipeline.rounds ?? 2, 4)); + const rounds = Math.max( + 1, + Math.min(this.pipeline.rounds ?? DEFAULT_ITERATIVE_ROUNDS, MAX_ITERATIVE_ROUNDS) + ); const agents = this.pipeline.agents; const researcher = agents[0]; const synthesizer = agents[agents.length - 1]; @@ -719,10 +730,11 @@ export class Orchestrator { modelId: def.modelOverride ?? null, }); } else { - args.writer.write({ - type: "data-final-text", - data: { text: result.text }, - }); + // Agent terminal renvoyant du texte (non-stream) : on émet du VRAI texte + // (text-start/delta/end), seule voie effectivement rendue ET persistée + // par route.ts. Auparavant on écrivait data-final-text, consommé nulle + // part → la réponse était silencieusement perdue. + this.streamStaticText(args.writer, result.text); await this.emit(args, args.writer, { type: "agent_finish", pipelineRunId, diff --git a/src/lib/orchestrator/repository.ts b/src/lib/orchestrator/repository.ts index 98abc06..a8891ee 100644 --- a/src/lib/orchestrator/repository.ts +++ b/src/lib/orchestrator/repository.ts @@ -1,6 +1,7 @@ import { and, asc, eq } from "drizzle-orm"; import { db } from "@/db"; import { pipelineAgents, pipelines } from "@/db/schema"; +import { PIPELINE_MODES } from "./types"; import type { AgentDefinition, AgentRole, @@ -8,10 +9,8 @@ import type { PipelineMode, } from "./types"; -const KNOWN_MODES: PipelineMode[] = ["sequential", "council", "parallel"]; - function isPipelineMode(value: string): value is PipelineMode { - return (KNOWN_MODES as string[]).includes(value); + return (PIPELINE_MODES as readonly string[]).includes(value); } const KNOWN_ROLES: AgentRole[] = [ diff --git a/src/lib/orchestrator/types.ts b/src/lib/orchestrator/types.ts index 46ea7ae..4295bde 100644 --- a/src/lib/orchestrator/types.ts +++ b/src/lib/orchestrator/types.ts @@ -64,7 +64,22 @@ export interface AgentDefinition { * propres notes à chaque tour pour creuser les lacunes, puis * le terminal produit une note de recherche synthétique. */ -export type PipelineMode = "sequential" | "council" | "parallel" | "iterative"; +export const PIPELINE_MODES = [ + "sequential", + "council", + "parallel", + "iterative", +] as const; +export type PipelineMode = (typeof PIPELINE_MODES)[number]; + +/** + * Plafonds/défauts de tours appliqués À LA FOIS à l'exécution (orchestrator) + * et à l'estimation de coût (cost-estimate) — source unique pour qu'ils ne + * divergent pas (sinon le coût affiché sur-estime un run réellement clampé). + */ +export const MAX_COUNCIL_ROUNDS = 6; +export const MAX_ITERATIVE_ROUNDS = 4; +export const DEFAULT_ITERATIVE_ROUNDS = 2; export interface PipelineConfig { id?: string; diff --git a/src/lib/orchestrator/verify.test.ts b/src/lib/orchestrator/verify.test.ts index 676a8be..aef12c9 100644 --- a/src/lib/orchestrator/verify.test.ts +++ b/src/lib/orchestrator/verify.test.ts @@ -54,6 +54,35 @@ describe("effectfulOutcomes / assessDeliverable", () => { expect(a.failures[0].tool).toBe("edit_document"); }); + it("décode l'enveloppe AI SDK {type:json,value} (régression: faux deliverable.failed)", () => { + // L'output peut arriver enveloppé par l'AI SDK v6. La lecture brute de + // `o.ok` retournait undefined → ok:false sur un livrable pourtant réussi. + const parts: SavedPart[] = [ + toolResult("generate_document", { + type: "json", + value: { ok: true, data: { document_id: "doc1" } }, + }), + ]; + const a = assessDeliverable(parts); + expect(a.hadEffectful).toBe(true); + expect(a.allOk).toBe(true); + expect(a.failures).toEqual([]); + }); + + it("décode aussi un échec enveloppé {type:json,value:{ok:false}}", () => { + const parts: SavedPart[] = [ + toolResult("edit_document", { + type: "json", + value: { ok: false, error: "ancre introuvable" }, + }), + ]; + const a = assessDeliverable(parts); + expect(a.allOk).toBe(false); + expect(a.failures).toEqual([ + { tool: "edit_document", error: "ancre introuvable" }, + ]); + }); + it("agrège plusieurs outils effectifs", () => { const parts: SavedPart[] = [ toolResult("generate_document", { ok: true, data: {} }), diff --git a/src/lib/orchestrator/verify.ts b/src/lib/orchestrator/verify.ts index fcaf21f..8925f3a 100644 --- a/src/lib/orchestrator/verify.ts +++ b/src/lib/orchestrator/verify.ts @@ -1,4 +1,5 @@ import type { SavedPart } from "@/db/schema"; +import { toolResultOk } from "@/lib/ai/tool-result"; /** * Outils EFFECTIFS : ceux qui produisent un livrable (document) plutôt que de @@ -18,12 +19,10 @@ export function effectfulOutcomes(parts: SavedPart[]): EffectfulOutcome[] { const out: EffectfulOutcome[] = []; for (const p of parts) { if (p.type !== "tool-result" || !EFFECTFUL_TOOLS.has(p.toolName)) continue; - const o = p.output as { ok?: unknown; error?: unknown } | null | undefined; - const ok = !!(o && typeof o === "object" && o.ok === true); - const error = - o && typeof o === "object" && typeof o.error === "string" - ? o.error - : undefined; + // toolResultOk décode l'enveloppe AI SDK ({type:"json",value:{ok,...}}) + // avant de lire `ok` — sinon un livrable réussi mais enveloppé était lu + // comme ok:false (faux « deliverable.failed »). + const { ok, error } = toolResultOk(p.output); out.push({ tool: p.toolName, ok, error }); } return out; diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts index d22ec42..34ef559 100644 --- a/src/lib/rate-limit.ts +++ b/src/lib/rate-limit.ts @@ -26,7 +26,7 @@ export type RateLimitResult = { limit: number; }; -export type RateLimitBucket = "chat" | "upload" | "login"; +export type RateLimitBucket = "chat" | "upload" | "login" | "totp"; const BUCKET_CONFIG: Record< RateLimitBucket, @@ -50,6 +50,15 @@ const BUCKET_CONFIG: Record< windowSeconds: 900, label: "login/15min", }, + // Plafond PAR COMPTE sur la vérification d'un second facteur (TOTP ou code + // de secours), en complément du plafond login par IP. Bloque le brute-force + // distribué d'un code à 6 chiffres quand l'attaquant a déjà le mot de passe. + totp: { + envVar: "RATE_LIMIT_TOTP_PER_15MIN", + defaultMax: 10, + windowSeconds: 900, + label: "totp/15min", + }, }; function readLimit(bucket: RateLimitBucket): number {