mirror of
https://github.com/willchen96/mike.git
synced 2026-07-24 23:41:04 +02:00
Sync workflow, asst extra input, excel + ppt support and modal updates
This commit is contained in:
parent
93f921be90
commit
a5fe6d6e04
140 changed files with 13508 additions and 16700 deletions
120
backend/bun.lock
120
backend/bun.lock
|
|
@ -9,6 +9,7 @@
|
|||
"@aws-sdk/client-s3": "^3.787.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.787.0",
|
||||
"@google/genai": "^1.50.1",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"@supabase/supabase-js": "^2.49.4",
|
||||
"cors": "^2.8.5",
|
||||
"docx": "^9.5.0",
|
||||
|
|
@ -24,6 +25,7 @@
|
|||
"multer": "^1.4.5-lts.2",
|
||||
"pdfjs-dist": "^4.10.38",
|
||||
"resend": "^4.5.1",
|
||||
"zod": "^3.25.76",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.17",
|
||||
|
|
@ -179,6 +181,10 @@
|
|||
|
||||
"@google/genai": ["@google/genai@1.50.1", "", { "dependencies": { "google-auth-library": "^10.3.0", "p-retry": "^4.6.2", "protobufjs": "^7.5.4", "ws": "^8.18.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.25.2" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-YbkX7H9+1Pt8wOt7DDREy8XSoiL6fRDzZQRyaVBarFf8MR3zHGqVdvM4cLbDXqPhxqvegZShgfxb8kw9C7YhAQ=="],
|
||||
|
||||
"@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="],
|
||||
|
||||
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],
|
||||
|
||||
"@napi-rs/canvas": ["@napi-rs/canvas@0.1.97", "", { "optionalDependencies": { "@napi-rs/canvas-android-arm64": "0.1.97", "@napi-rs/canvas-darwin-arm64": "0.1.97", "@napi-rs/canvas-darwin-x64": "0.1.97", "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.97", "@napi-rs/canvas-linux-arm64-gnu": "0.1.97", "@napi-rs/canvas-linux-arm64-musl": "0.1.97", "@napi-rs/canvas-linux-riscv64-gnu": "0.1.97", "@napi-rs/canvas-linux-x64-gnu": "0.1.97", "@napi-rs/canvas-linux-x64-musl": "0.1.97", "@napi-rs/canvas-win32-arm64-msvc": "0.1.97", "@napi-rs/canvas-win32-x64-msvc": "0.1.97" } }, "sha512-8cFniXvrIEnVwuNSRCW9wirRZbHvrD3JVujdS2P5n5xiJZNZMOZcfOvJ1pb66c7jXMKHHglJEDVJGbm8XWFcXQ=="],
|
||||
|
||||
"@napi-rs/canvas-android-arm64": ["@napi-rs/canvas-android-arm64@0.1.97", "", { "os": "android", "cpu": "arm64" }, "sha512-V1c/WVw+NzH8vk7ZK/O8/nyBSCQimU8sfMsB/9qeSvdkGKNU7+mxy/bIF0gTgeBFmHpj30S4E9WHMSrxXGQuVQ=="],
|
||||
|
|
@ -379,6 +385,10 @@
|
|||
|
||||
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
|
||||
|
||||
"ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
|
||||
|
||||
"ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
|
||||
|
||||
"append-field": ["append-field@1.0.0", "", {}, "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw=="],
|
||||
|
||||
"argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
|
||||
|
|
@ -423,6 +433,8 @@
|
|||
|
||||
"cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="],
|
||||
|
||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||
|
||||
"data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="],
|
||||
|
||||
"debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
||||
|
|
@ -471,16 +483,22 @@
|
|||
|
||||
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
|
||||
|
||||
"eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="],
|
||||
|
||||
"eventsource-parser": ["eventsource-parser@3.1.0", "", {}, "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg=="],
|
||||
|
||||
"express": ["express@4.22.1", "", { "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", "body-parser": "~1.20.3", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", "cookie-signature": "~1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "finalhandler": "~1.3.1", "fresh": "~0.5.2", "http-errors": "~2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", "qs": "~6.14.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", "serve-static": "~1.16.2", "setprototypeof": "1.2.0", "statuses": "~2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" } }, "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g=="],
|
||||
|
||||
"express-rate-limit": ["express-rate-limit@8.5.1", "", { "dependencies": { "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-5O6KYmyJEpuPJV5hNTXKbAHWRqrzyu+OI3vUnSd2kXFubIVpG7ezpgxQy76Zo5GQZtrQBg86hF+CM/NX+cioiQ=="],
|
||||
|
||||
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@2.0.1", "", {}, "sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w=="],
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
||||
"fast-diff": ["fast-diff@1.3.0", "", {}, "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw=="],
|
||||
|
||||
"fast-uri": ["fast-uri@3.1.2", "", {}, "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ=="],
|
||||
|
||||
"fast-xml-builder": ["fast-xml-builder@1.1.5", "", { "dependencies": { "path-expression-matcher": "^1.1.3" } }, "sha512-4TJn/8FKLeslLAH3dnohXqE3QSoxkhvaMzepOIZytwJXZO69Bfz0HBdDHzOTOon6G59Zrk6VQ2bEiv1t61rfkA=="],
|
||||
|
||||
"fast-xml-parser": ["fast-xml-parser@5.7.2", "", { "dependencies": { "@nodable/entities": "^2.1.0", "fast-xml-builder": "^1.1.5", "path-expression-matcher": "^1.5.0", "strnum": "^2.2.3" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-P7oW7tLbYnhOLQk/Gv7cZgzgMPP/XN03K02/Jy6Y/NHzyIAIpxuZIM/YqAkfiXFPxA2CTm7NtCijK9EDu09u2w=="],
|
||||
|
|
@ -523,6 +541,8 @@
|
|||
|
||||
"helmet": ["helmet@8.1.0", "", {}, "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg=="],
|
||||
|
||||
"hono": ["hono@4.12.25", "", {}, "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ=="],
|
||||
|
||||
"html-to-text": ["html-to-text@9.0.5", "", { "dependencies": { "@selderee/plugin-htmlparser2": "^0.11.0", "deepmerge": "^4.3.1", "dom-serializer": "^2.0.0", "htmlparser2": "^8.0.2", "selderee": "^0.11.0" } }, "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg=="],
|
||||
|
||||
"htmlparser2": ["htmlparser2@8.0.2", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1", "entities": "^4.4.0" } }, "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA=="],
|
||||
|
|
@ -533,7 +553,7 @@
|
|||
|
||||
"iceberg-js": ["iceberg-js@0.8.1", "", {}, "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA=="],
|
||||
|
||||
"iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
|
||||
"iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
|
||||
|
||||
"immediate": ["immediate@3.0.6", "", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="],
|
||||
|
||||
|
|
@ -543,12 +563,22 @@
|
|||
|
||||
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
|
||||
|
||||
"is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
|
||||
|
||||
"isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],
|
||||
|
||||
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
|
||||
"jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="],
|
||||
|
||||
"json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="],
|
||||
|
||||
"json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="],
|
||||
|
||||
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||
|
||||
"json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
|
||||
|
||||
"jszip": ["jszip@3.10.1", "", { "dependencies": { "lie": "~3.3.0", "pako": "~1.0.2", "readable-stream": "~2.3.6", "setimmediate": "^1.0.5" } }, "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g=="],
|
||||
|
||||
"jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="],
|
||||
|
|
@ -577,9 +607,9 @@
|
|||
|
||||
"mime": ["mime@1.6.0", "", { "bin": "cli.js" }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="],
|
||||
|
||||
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||
"mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
|
||||
|
||||
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||
"mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
|
||||
|
||||
"minimalistic-assert": ["minimalistic-assert@1.0.1", "", {}, "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="],
|
||||
|
||||
|
|
@ -605,6 +635,8 @@
|
|||
|
||||
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
|
||||
|
||||
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
||||
|
||||
"option": ["option@0.2.4", "", {}, "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A=="],
|
||||
|
||||
"p-retry": ["p-retry@4.6.2", "", { "dependencies": { "@types/retry": "0.12.0", "retry": "^0.13.1" } }, "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ=="],
|
||||
|
|
@ -619,12 +651,16 @@
|
|||
|
||||
"path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="],
|
||||
|
||||
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||
|
||||
"path-to-regexp": ["path-to-regexp@0.1.13", "", {}, "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA=="],
|
||||
|
||||
"pdfjs-dist": ["pdfjs-dist@4.10.38", "", { "optionalDependencies": { "@napi-rs/canvas": "^0.1.65" } }, "sha512-/Y3fcFrXEAsMjJXeL9J8+ZG9U01LbuWaYypvDW2ycW1jL269L3js3DVBjDJ0Up9Np1uqDXsDrRihHANhZOlwdQ=="],
|
||||
|
||||
"peberminta": ["peberminta@0.9.0", "", {}, "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ=="],
|
||||
|
||||
"pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
|
||||
|
||||
"prettier": ["prettier@3.8.1", "", { "bin": "bin/prettier.cjs" }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="],
|
||||
|
||||
"process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="],
|
||||
|
|
@ -637,7 +673,7 @@
|
|||
|
||||
"range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
|
||||
|
||||
"raw-body": ["raw-body@2.5.3", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "unpipe": "~1.0.0" } }, "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA=="],
|
||||
"raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
|
||||
|
||||
"react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
|
||||
|
||||
|
|
@ -647,12 +683,16 @@
|
|||
|
||||
"readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
|
||||
|
||||
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
|
||||
|
||||
"resend": ["resend@4.8.0", "", { "dependencies": { "@react-email/render": "1.1.2" } }, "sha512-R8eBOFQDO6dzRTDmaMEdpqrkmgSjPpVXt4nGfWsZdYOet0kqra0xgbvTES6HmCriZEXbmGk3e0DiGIaLFTFSHA=="],
|
||||
|
||||
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
|
||||
|
||||
"retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="],
|
||||
|
||||
"router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
|
||||
|
||||
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
|
||||
|
||||
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
||||
|
|
@ -671,6 +711,10 @@
|
|||
|
||||
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
|
||||
|
||||
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
|
||||
|
||||
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
||||
|
||||
"side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
|
||||
|
||||
"side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="],
|
||||
|
|
@ -719,6 +763,10 @@
|
|||
|
||||
"web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
|
||||
|
||||
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
||||
|
||||
"ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="],
|
||||
|
||||
"xml": ["xml@1.0.1", "", {}, "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw=="],
|
||||
|
|
@ -729,6 +777,10 @@
|
|||
|
||||
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
|
||||
|
||||
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="],
|
||||
|
||||
"@aws-crypto/sha1-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
|
||||
|
||||
"@aws-crypto/sha256-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
|
||||
|
|
@ -737,20 +789,36 @@
|
|||
|
||||
"@aws-sdk/xml-builder/fast-xml-parser": ["fast-xml-parser@5.5.8", "", { "dependencies": { "fast-xml-builder": "^1.1.4", "path-expression-matcher": "^1.2.0", "strnum": "^2.2.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
|
||||
|
||||
"@types/serve-static/@types/send": ["@types/send@0.17.6", "", { "dependencies": { "@types/mime": "^1", "@types/node": "*" } }, "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og=="],
|
||||
|
||||
"accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||
|
||||
"body-parser/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
|
||||
|
||||
"body-parser/raw-body": ["raw-body@2.5.3", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "unpipe": "~1.0.0" } }, "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA=="],
|
||||
|
||||
"docx/@types/node": ["@types/node@25.5.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg=="],
|
||||
|
||||
"https-proxy-agent/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"protobufjs/@types/node": ["@types/node@25.5.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg=="],
|
||||
|
||||
"react-promise-suspense/fast-deep-equal": ["fast-deep-equal@2.0.1", "", {}, "sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w=="],
|
||||
|
||||
"readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
|
||||
|
||||
"router/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"router/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="],
|
||||
|
||||
"send/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"string_decoder/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
|
||||
|
||||
"type-is/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||
|
||||
"@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
|
||||
|
||||
"@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
|
||||
|
|
@ -761,16 +829,58 @@
|
|||
|
||||
"@aws-sdk/xml-builder/fast-xml-parser/path-expression-matcher": ["path-expression-matcher@1.4.0", "", {}, "sha512-s4DQMxIdhj3jLFWd9LxHOplj4p9yQ4ffMGowFf3cpEgrrJjEhN0V5nxw4Ye1EViAGDoL4/1AeO6qHpqYPOzE4Q=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/express/accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/express/body-parser": ["body-parser@2.3.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/express/content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/express/cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/express/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/express/finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/express/fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/express/merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/express/send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/express/serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/express/type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="],
|
||||
|
||||
"accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||
|
||||
"docx/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||
|
||||
"https-proxy-agent/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"protobufjs/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||
|
||||
"router/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"type-is/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||
|
||||
"@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
|
||||
|
||||
"@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
|
||||
|
||||
"@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/express/accepts/negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/express/body-parser/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/express/body-parser/qs": ["qs@6.15.2", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/express/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/express/send/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/express/type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/express/type-is/media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,19 +27,34 @@ stable
|
|||
as $$
|
||||
with owned as (
|
||||
select
|
||||
w.*,
|
||||
w.id,
|
||||
w.user_id::text as user_id,
|
||||
w.title,
|
||||
w.type,
|
||||
w.prompt_md,
|
||||
w.columns_config,
|
||||
w.practice,
|
||||
false as is_system,
|
||||
w.created_at,
|
||||
true as allow_edit,
|
||||
true as is_owner,
|
||||
null::text as shared_by_name,
|
||||
0 as sort_bucket
|
||||
from public.workflows w
|
||||
where w.user_id = p_user_id
|
||||
and w.is_system = false
|
||||
where w.user_id::text = p_user_id
|
||||
and (p_type is null or w.type = p_type)
|
||||
),
|
||||
shared as (
|
||||
select
|
||||
w.*,
|
||||
w.id,
|
||||
w.user_id::text as user_id,
|
||||
w.title,
|
||||
w.type,
|
||||
w.prompt_md,
|
||||
w.columns_config,
|
||||
w.practice,
|
||||
false as is_system,
|
||||
w.created_at,
|
||||
ws.allow_edit,
|
||||
false as is_owner,
|
||||
nullif(trim(up.display_name), '') as shared_by_name,
|
||||
|
|
@ -48,7 +63,7 @@ as $$
|
|||
join public.workflows w
|
||||
on w.id = ws.workflow_id
|
||||
left join public.user_profiles up
|
||||
on up.user_id::text = ws.shared_by_user_id
|
||||
on up.user_id::text = ws.shared_by_user_id::text
|
||||
where lower(ws.shared_with_email) = lower(coalesce(p_user_email, ''))
|
||||
and (p_type is null or w.type = p_type)
|
||||
),
|
||||
|
|
|
|||
120
backend/migrations/20260625_01_workflow_metadata.sql
Normal file
120
backend/migrations/20260625_01_workflow_metadata.sql
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
-- Migration date: 2026-06-25
|
||||
|
||||
-- Custom workflow metadata fields and workflow overview read model. System
|
||||
-- workflow versions remain generated from the repository metadata and are not
|
||||
-- stored on user workflow rows.
|
||||
|
||||
drop function if exists public.get_workflows_overview(text, text, text);
|
||||
|
||||
alter table public.workflows
|
||||
drop column if exists author,
|
||||
drop column if exists category,
|
||||
drop column if exists is_system,
|
||||
add column if not exists language text default 'English',
|
||||
add column if not exists jurisdictions text[] default array['General']::text[];
|
||||
|
||||
alter table public.workflows
|
||||
alter column language set default 'English',
|
||||
alter column practice set default 'General Transactions',
|
||||
alter column jurisdictions set default array['General']::text[];
|
||||
|
||||
update public.workflows
|
||||
set
|
||||
language = coalesce(nullif(trim(language), ''), 'English'),
|
||||
practice = coalesce(nullif(trim(practice), ''), 'General Transactions'),
|
||||
jurisdictions = coalesce(jurisdictions, array['General']::text[])
|
||||
where user_id is not null;
|
||||
|
||||
create or replace function public.get_workflows_overview(
|
||||
p_user_id text,
|
||||
p_user_email text default null,
|
||||
p_type text default null
|
||||
)
|
||||
returns table (
|
||||
id uuid,
|
||||
user_id text,
|
||||
title text,
|
||||
type text,
|
||||
prompt_md text,
|
||||
columns_config jsonb,
|
||||
language text,
|
||||
practice text,
|
||||
jurisdictions text[],
|
||||
is_system boolean,
|
||||
created_at timestamptz,
|
||||
allow_edit boolean,
|
||||
is_owner boolean,
|
||||
shared_by_name text
|
||||
)
|
||||
language sql
|
||||
stable
|
||||
as $$
|
||||
with owned as (
|
||||
select
|
||||
w.id,
|
||||
w.user_id::text as user_id,
|
||||
w.title,
|
||||
w.type,
|
||||
w.prompt_md,
|
||||
w.columns_config,
|
||||
w.language,
|
||||
w.practice,
|
||||
w.jurisdictions,
|
||||
false as is_system,
|
||||
w.created_at,
|
||||
true as allow_edit,
|
||||
true as is_owner,
|
||||
null::text as shared_by_name,
|
||||
0 as sort_bucket
|
||||
from public.workflows w
|
||||
where w.user_id::text = p_user_id
|
||||
and (p_type is null or w.type = p_type)
|
||||
),
|
||||
shared as (
|
||||
select
|
||||
w.id,
|
||||
w.user_id::text as user_id,
|
||||
w.title,
|
||||
w.type,
|
||||
w.prompt_md,
|
||||
w.columns_config,
|
||||
w.language,
|
||||
w.practice,
|
||||
w.jurisdictions,
|
||||
false as is_system,
|
||||
w.created_at,
|
||||
ws.allow_edit,
|
||||
false as is_owner,
|
||||
nullif(trim(up.display_name), '') as shared_by_name,
|
||||
1 as sort_bucket
|
||||
from public.workflow_shares ws
|
||||
join public.workflows w
|
||||
on w.id = ws.workflow_id
|
||||
left join public.user_profiles up
|
||||
on up.user_id::text = ws.shared_by_user_id::text
|
||||
where lower(ws.shared_with_email) = lower(coalesce(p_user_email, ''))
|
||||
and (p_type is null or w.type = p_type)
|
||||
),
|
||||
visible_workflows as (
|
||||
select * from owned
|
||||
union all
|
||||
select * from shared
|
||||
)
|
||||
select
|
||||
vw.id,
|
||||
vw.user_id,
|
||||
vw.title,
|
||||
vw.type,
|
||||
vw.prompt_md,
|
||||
vw.columns_config,
|
||||
vw.language,
|
||||
vw.practice,
|
||||
vw.jurisdictions,
|
||||
vw.is_system,
|
||||
vw.created_at,
|
||||
vw.allow_edit,
|
||||
vw.is_owner,
|
||||
vw.shared_by_name
|
||||
from visible_workflows vw
|
||||
order by vw.sort_bucket asc, vw.created_at desc;
|
||||
$$;
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
-- Migration date: 2026-06-29
|
||||
|
||||
-- Review queue for user-submitted workflows that may later be published to the
|
||||
-- open-source workflow repository. The backend writes with the service role.
|
||||
|
||||
create table if not exists public.workflow_open_source_submissions (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
workflow_id uuid not null references public.workflows(id) on delete cascade,
|
||||
submitted_by_user_id text not null,
|
||||
submitter_email text,
|
||||
submitter_name text,
|
||||
contributor_mode text not null default 'anonymous',
|
||||
status text not null default 'pending',
|
||||
snapshot jsonb not null,
|
||||
submitted_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
reviewed_at timestamptz,
|
||||
reviewed_by_user_id text,
|
||||
review_notes text,
|
||||
constraint workflow_open_source_submissions_status_check
|
||||
check (status in ('pending', 'approved', 'rejected')),
|
||||
constraint workflow_open_source_submissions_contributor_mode_check
|
||||
check (contributor_mode in ('named', 'anonymous'))
|
||||
);
|
||||
|
||||
create unique index if not exists idx_workflow_open_source_submissions_pending
|
||||
on public.workflow_open_source_submissions(workflow_id, submitted_by_user_id)
|
||||
where status = 'pending';
|
||||
|
||||
create index if not exists idx_workflow_open_source_submissions_reviewer_queue
|
||||
on public.workflow_open_source_submissions(status, submitted_at desc);
|
||||
|
||||
create index if not exists idx_workflow_open_source_submissions_submitter
|
||||
on public.workflow_open_source_submissions(submitted_by_user_id, submitted_at desc);
|
||||
|
||||
alter table public.workflow_open_source_submissions enable row level security;
|
||||
|
||||
revoke all privileges on table public.workflow_open_source_submissions
|
||||
from anon, authenticated;
|
||||
14
backend/package-lock.json
generated
14
backend/package-lock.json
generated
|
|
@ -7,7 +7,6 @@
|
|||
"": {
|
||||
"name": "mike-backend",
|
||||
"version": "1.0.0",
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.90.0",
|
||||
"@aws-sdk/client-s3": "^3.787.0",
|
||||
|
|
@ -29,6 +28,7 @@
|
|||
"multer": "^1.4.5-lts.2",
|
||||
"pdfjs-dist": "^4.10.38",
|
||||
"resend": "^4.5.1",
|
||||
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
@ -5473,6 +5473,18 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/xlsx": {
|
||||
"version": "0.20.3",
|
||||
"resolved": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
|
||||
"integrity": "sha512-oLDq3jw7AcLqKWH2AhCpVTZl8mf6X2YReP+Neh0SJUzV/BdZYjth94tG5toiMB1PPrYtxOCfaoUCkvtuH+3AJA==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"xlsx": "bin/xlsx.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/xml": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz",
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"prebuild": "node ../scripts/build-workflows.js",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js"
|
||||
},
|
||||
|
|
@ -28,6 +29,7 @@
|
|||
"multer": "^1.4.5-lts.2",
|
||||
"pdfjs-dist": "^4.10.38",
|
||||
"resend": "^4.5.1",
|
||||
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ create extension if not exists "pgcrypto";
|
|||
create table if not exists public.user_profiles (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
user_id uuid not null unique references auth.users(id) on delete cascade,
|
||||
email text,
|
||||
display_name text,
|
||||
organisation text,
|
||||
tier text not null default 'Free',
|
||||
|
|
@ -29,6 +30,13 @@ create table if not exists public.user_profiles (
|
|||
create index if not exists idx_user_profiles_user
|
||||
on public.user_profiles(user_id);
|
||||
|
||||
create unique index if not exists user_profiles_email_lower_unique
|
||||
on public.user_profiles (lower(email))
|
||||
where email is not null and btrim(email) <> '';
|
||||
|
||||
create index if not exists idx_user_profiles_email
|
||||
on public.user_profiles(email);
|
||||
|
||||
create or replace function public.handle_new_user()
|
||||
returns trigger
|
||||
language plpgsql
|
||||
|
|
@ -36,9 +44,11 @@ security definer
|
|||
set search_path = public
|
||||
as $$
|
||||
begin
|
||||
insert into public.user_profiles (user_id)
|
||||
values (new.id)
|
||||
on conflict (user_id) do nothing;
|
||||
insert into public.user_profiles (user_id, email)
|
||||
values (new.id, lower(new.email))
|
||||
on conflict (user_id) do update
|
||||
set email = excluded.email,
|
||||
updated_at = now();
|
||||
return new;
|
||||
exception when others then
|
||||
-- Never block signup if the profile insert fails.
|
||||
|
|
@ -186,6 +196,7 @@ create table if not exists public.projects (
|
|||
user_id text not null,
|
||||
name text not null,
|
||||
cm_number text,
|
||||
practice text,
|
||||
visibility text not null default 'private',
|
||||
shared_with jsonb not null default '[]'::jsonb,
|
||||
created_at timestamptz not null default now(),
|
||||
|
|
@ -323,8 +334,9 @@ create table if not exists public.workflows (
|
|||
type text not null,
|
||||
prompt_md text,
|
||||
columns_config jsonb,
|
||||
practice text,
|
||||
is_system boolean not null default false,
|
||||
language text default 'English',
|
||||
practice text default 'General Transactions',
|
||||
jurisdictions text[] default array['General']::text[],
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
|
|
@ -371,7 +383,9 @@ returns table (
|
|||
type text,
|
||||
prompt_md text,
|
||||
columns_config jsonb,
|
||||
language text,
|
||||
practice text,
|
||||
jurisdictions text[],
|
||||
is_system boolean,
|
||||
created_at timestamptz,
|
||||
allow_edit boolean,
|
||||
|
|
@ -383,19 +397,38 @@ stable
|
|||
as $$
|
||||
with owned as (
|
||||
select
|
||||
w.*,
|
||||
w.id,
|
||||
w.user_id::text as user_id,
|
||||
w.title,
|
||||
w.type,
|
||||
w.prompt_md,
|
||||
w.columns_config,
|
||||
w.language,
|
||||
w.practice,
|
||||
w.jurisdictions,
|
||||
false as is_system,
|
||||
w.created_at,
|
||||
true as allow_edit,
|
||||
true as is_owner,
|
||||
null::text as shared_by_name,
|
||||
0 as sort_bucket
|
||||
from public.workflows w
|
||||
where w.user_id = p_user_id
|
||||
and w.is_system = false
|
||||
where w.user_id::text = p_user_id
|
||||
and (p_type is null or w.type = p_type)
|
||||
),
|
||||
shared as (
|
||||
select
|
||||
w.*,
|
||||
w.id,
|
||||
w.user_id::text as user_id,
|
||||
w.title,
|
||||
w.type,
|
||||
w.prompt_md,
|
||||
w.columns_config,
|
||||
w.language,
|
||||
w.practice,
|
||||
w.jurisdictions,
|
||||
false as is_system,
|
||||
w.created_at,
|
||||
ws.allow_edit,
|
||||
false as is_owner,
|
||||
nullif(trim(up.display_name), '') as shared_by_name,
|
||||
|
|
@ -404,7 +437,7 @@ as $$
|
|||
join public.workflows w
|
||||
on w.id = ws.workflow_id
|
||||
left join public.user_profiles up
|
||||
on up.user_id::text = ws.shared_by_user_id
|
||||
on up.user_id::text = ws.shared_by_user_id::text
|
||||
where lower(ws.shared_with_email) = lower(coalesce(p_user_email, ''))
|
||||
and (p_type is null or w.type = p_type)
|
||||
),
|
||||
|
|
@ -420,7 +453,9 @@ as $$
|
|||
vw.type,
|
||||
vw.prompt_md,
|
||||
vw.columns_config,
|
||||
vw.language,
|
||||
vw.practice,
|
||||
vw.jurisdictions,
|
||||
vw.is_system,
|
||||
vw.created_at,
|
||||
vw.allow_edit,
|
||||
|
|
@ -489,7 +524,7 @@ create table if not exists public.chat_messages (
|
|||
role text not null,
|
||||
content jsonb,
|
||||
files jsonb,
|
||||
annotations jsonb,
|
||||
citations jsonb,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
|
|
@ -549,6 +584,7 @@ returns table (
|
|||
user_id text,
|
||||
name text,
|
||||
cm_number text,
|
||||
practice text,
|
||||
shared_with jsonb,
|
||||
created_at timestamptz,
|
||||
updated_at timestamptz,
|
||||
|
|
@ -595,6 +631,7 @@ as $$
|
|||
vp.user_id,
|
||||
vp.name,
|
||||
vp.cm_number,
|
||||
vp.practice,
|
||||
vp.shared_with,
|
||||
vp.created_at,
|
||||
vp.updated_at,
|
||||
|
|
|
|||
|
|
@ -1,76 +0,0 @@
|
|||
export const BUILTIN_WORKFLOWS: { id: string; title: string; prompt_md: string }[] = [
|
||||
{
|
||||
id: "builtin-cp-checklist",
|
||||
title: "Generate CP Checklist",
|
||||
prompt_md:
|
||||
"## Generate Conditions Precedent Checklist\n\n" +
|
||||
"Review the uploaded credit agreement or financing document and generate a comprehensive " +
|
||||
"Conditions Precedent (CP) checklist.\n\n" +
|
||||
"You MUST use the generate_docx tool to produce the checklist as a downloadable Word document. " +
|
||||
"You MUST pass landscape: true to the generate_docx tool — the document must be in landscape orientation. " +
|
||||
"Do not display the checklist inline — generate the .docx file and provide the download link.\n\n" +
|
||||
"Structure the document as follows:\n" +
|
||||
"- For each category of conditions (e.g. Corporate, Financial, Legal, Security), add a section with a heading\n" +
|
||||
"- Under each category heading, include a table with exactly these four columns in this order:\n" +
|
||||
" 1. Index — sequential number within the category (1, 2, 3…)\n" +
|
||||
" 2. Clause Number — the clause or schedule reference from the agreement\n" +
|
||||
" 3. Clause — a concise description of the condition precedent\n" +
|
||||
" 4. Status — leave blank (empty string) for the user to fill in\n\n" +
|
||||
"Use the table field in the section object (not content) for each category's rows.\n\n" +
|
||||
"Before finalizing, double-check that every table is formatted correctly: each table must have exactly the four columns above in the same order, headers must match exactly (Index, Clause Number, Clause, Status), every row must have the same number of cells as the headers, the Index column must be sequential starting from 1 within each category, and no cells should contain stray markdown, newlines, or placeholder text (use an empty string for Status).",
|
||||
},
|
||||
{
|
||||
id: "builtin-credit-summary",
|
||||
title: "Credit Agreement Summary",
|
||||
prompt_md:
|
||||
"## Credit Agreement Summary\n\n" +
|
||||
"Review the uploaded credit agreement and produce a comprehensive legal summary covering the following topics. " +
|
||||
"For each section, identify the key provisions, quote the relevant clause or schedule references, and flag any unusual, onerous, or non-market terms.\n\n" +
|
||||
"1. **Lenders** — All lenders or members of the lender syndicate, including their full legal name and role (e.g. mandated lead arranger, original lender, agent bank)\n" +
|
||||
"2. **Borrowers** — All borrowers, including their full legal name and jurisdiction of incorporation\n" +
|
||||
"3. **Guarantors** — All guarantors, including their full legal name and the scope of their guarantee obligation\n" +
|
||||
"4. **Other Parties** — Any other material parties (e.g. facility agent, security agent, hedge counterparties, issuing bank) and their roles\n" +
|
||||
"5. **Date of Agreement** — Date of the credit agreement\n" +
|
||||
"6. **Facilities** — Each facility available (e.g. Revolving Credit Facility, Term Loan A, Term Loan B, Term Loan C), the facility type, tranche name, and any key structural features\n" +
|
||||
"7. **Amount** — Total committed amount across all facilities, the currency, and breakdown by tranche if applicable\n" +
|
||||
"8. **Purpose** — Stated purpose for which borrowings may be used and any restrictions on use of proceeds\n" +
|
||||
"9. **Interest** — Applicable reference rate (e.g. SOFR, EURIBOR, base rate), the margin, any margin ratchet mechanism, and how interest periods are structured\n" +
|
||||
"10. **Commitment Fee** — Commitment or utilisation fees, the applicable rate, how they are calculated, and the basis (e.g. undrawn commitment, average utilisation)\n" +
|
||||
"11. **Repayment Schedule** — Repayment profile for each facility, whether by scheduled instalments or bullet repayment, and the repayment dates and amounts\n" +
|
||||
"12. **Maturity** — Final maturity date for each facility\n" +
|
||||
"13. **Security** — Each class of security granted or required (e.g. share pledges, fixed and floating charges, real estate mortgages, account pledges) and the assets or entities over which security is taken\n" +
|
||||
"14. **Guarantees** — Guarantee obligations, the guarantors, the scope of the guarantee, and any limitations (e.g. up-stream guarantee limitations, guarantor coverage test)\n" +
|
||||
"15. **Financial Covenants** — Each financial covenant, the metric (e.g. leverage ratio, interest cover, cashflow cover), the applicable test, testing frequency, and any equity cure rights\n" +
|
||||
"16. **Events of Default** — Each event of default, noting any grace periods, materiality thresholds, or cross-default provisions\n" +
|
||||
"17. **Assignment** — Restrictions or permissions on assignment or transfer (e.g. white/blacklists, borrower consent for lender transfers; restrictions on borrower assignment)\n" +
|
||||
"18. **Change of Control** — What constitutes a change of control, what obligations it triggers (e.g. mandatory prepayment, cancellation, lender consent), and any cure period\n" +
|
||||
"19. **Prepayment Fee** — Any prepayment fees, make-whole premiums, or soft-call protections, the applicable fee, the period during which it applies, and any exceptions (e.g. prepayment from insurance proceeds or asset disposals)\n" +
|
||||
"20. **Governing Law** — Governing law of the agreement\n" +
|
||||
"21. **Dispute Resolution** — Whether disputes go to litigation or arbitration, the chosen forum or seat, and any submission to jurisdiction provisions\n\n" +
|
||||
"Deliver the summary inline in your chat response — do NOT call generate_docx. Only produce a downloadable Word document if the user explicitly asks for one.",
|
||||
},
|
||||
{
|
||||
id: "builtin-sha-summary",
|
||||
title: "Shareholder Agreement Summary",
|
||||
prompt_md:
|
||||
"## Shareholder Agreement Summary\n\n" +
|
||||
"Review the uploaded shareholder agreement and produce a comprehensive legal summary covering the following topics. " +
|
||||
"For each section, identify the key provisions, quote the relevant clause references, and flag any unusual, onerous, or market-standard deviations.\n\n" +
|
||||
"1. **Parties & Shareholdings** — Full legal names, roles, share classes held, and percentage interests (on a fully diluted basis if stated)\n" +
|
||||
"2. **Share Classes & Rights** — For each class: voting rights, dividend rights, liquidation preference, conversion or redemption features\n" +
|
||||
"3. **Board Composition & Governance** — Board size, director appointment rights (and the shareholding thresholds required to maintain them), quorum, and casting vote\n" +
|
||||
"4. **Reserved Matters** — Decisions requiring a special majority, unanimity, or a specific shareholder's consent; note the threshold and whose consent is required for each\n" +
|
||||
"5. **Pre-emption on New Shares** — Who holds pre-emption rights, procedure, timeline, and any carve-outs (e.g. employee option schemes)\n" +
|
||||
"6. **Transfer Restrictions** — Lock-up periods, prohibited transfers, permitted transfers (e.g. to affiliates), and any board or shareholder approval requirements\n" +
|
||||
"7. **Right of First Refusal / Pre-emption on Transfer** — Trigger, procedure, pricing mechanics, and any exceptions\n" +
|
||||
"8. **Drag-Along Rights** — Who holds the right, threshold to trigger, conditions (e.g. minimum price, independent valuation), and minority protections\n" +
|
||||
"9. **Tag-Along Rights** — Who holds the right, triggering threshold, exercise procedure, and price terms\n" +
|
||||
"10. **Anti-Dilution Protections** — Type (full ratchet, weighted average), trigger events, calculation mechanics, and exceptions\n" +
|
||||
"11. **Dividend Policy** — Any obligation or target to pay dividends, preferential dividend rights, and restrictions on distributions\n" +
|
||||
"12. **Exit & Liquidity** — Agreed exit routes (trade sale, IPO, drag sale), timelines, and liquidation preferences on exit\n" +
|
||||
"13. **Deadlock** — Deadlock definition, escalation and resolution mechanisms (e.g. Russian roulette, put/call options), and consequences if unresolved\n" +
|
||||
"14. **Non-Compete & Non-Solicitation** — Who is bound, scope of activities and geography, duration, and carve-outs\n" +
|
||||
"15. **Governing Law & Dispute Resolution** — Applicable law, forum, arbitration or litigation, and any mandatory escalation steps\n\n" +
|
||||
"Generate the summary as a downloadable Word document.",
|
||||
},
|
||||
];
|
||||
310
backend/src/lib/chat/citations.ts
Normal file
310
backend/src/lib/chat/citations.ts
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
import { type DocIndex, resolveDoc } from "./types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal citation parse types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type DocumentQuote = {
|
||||
page: number | string;
|
||||
quote: string;
|
||||
// Spreadsheet sources are located by cell instead of page: `sheet` is the
|
||||
// worksheet name and `cell` is an A1 address or range (e.g. "B7" or "B7:C9").
|
||||
sheet?: string;
|
||||
cell?: string;
|
||||
};
|
||||
|
||||
type ParsedDocumentCitation = {
|
||||
kind: "document";
|
||||
ref: number;
|
||||
doc_id: string;
|
||||
page: number | string;
|
||||
quote: string;
|
||||
sheet?: string;
|
||||
cell?: string;
|
||||
quotes: DocumentQuote[];
|
||||
};
|
||||
|
||||
type ParsedCaseCitation = {
|
||||
kind: "case";
|
||||
ref: number;
|
||||
cluster_id: number;
|
||||
quotes: {
|
||||
opinionId: number | null;
|
||||
type: string | null;
|
||||
author: string | null;
|
||||
quote: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
type ParsedCitation = ParsedDocumentCitation | ParsedCaseCitation;
|
||||
|
||||
function normalizeCitation(raw: unknown): ParsedCitation | null {
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
const c = raw as Record<string, unknown>;
|
||||
const markerRef =
|
||||
typeof c.marker === "string"
|
||||
? Number(c.marker.match(/^\[(\d+)\]$/)?.[1])
|
||||
: NaN;
|
||||
const ref =
|
||||
typeof c.ref === "number"
|
||||
? c.ref
|
||||
: Number.isFinite(markerRef)
|
||||
? markerRef
|
||||
: null;
|
||||
if (typeof ref !== "number") return null;
|
||||
const quote = typeof c.quote === "string" ? c.quote : c.text;
|
||||
|
||||
const rawClusterId =
|
||||
typeof c.cluster_id === "number"
|
||||
? c.cluster_id
|
||||
: typeof c.clusterId === "number"
|
||||
? c.clusterId
|
||||
: typeof c.cluster_id === "string"
|
||||
? Number.parseInt(c.cluster_id, 10)
|
||||
: typeof c.clusterId === "string"
|
||||
? Number.parseInt(c.clusterId, 10)
|
||||
: NaN;
|
||||
if (Number.isFinite(rawClusterId) && rawClusterId > 0) {
|
||||
const quotes = normalizeCaseCitationQuotes(c);
|
||||
if (!quotes.length) {
|
||||
if (typeof quote !== "string" || !quote) return null;
|
||||
quotes.push({ opinionId: null, type: null, author: null, quote });
|
||||
}
|
||||
return { kind: "case", ref, cluster_id: Math.floor(rawClusterId), quotes };
|
||||
}
|
||||
|
||||
if (typeof c.doc_id !== "string") return null;
|
||||
const quotes = normalizeDocumentCitationQuotes(c);
|
||||
if (!quotes.length) {
|
||||
if (typeof quote !== "string" || !quote) return null;
|
||||
quotes.push({
|
||||
page: normalizeCitationPage(c.page),
|
||||
quote,
|
||||
...normalizeCellLocator(c),
|
||||
});
|
||||
}
|
||||
return {
|
||||
kind: "document",
|
||||
ref,
|
||||
doc_id: c.doc_id,
|
||||
page: quotes[0].page,
|
||||
quote: quotes[0].quote,
|
||||
sheet: quotes[0].sheet,
|
||||
cell: quotes[0].cell,
|
||||
quotes,
|
||||
};
|
||||
}
|
||||
|
||||
/** Pull an optional spreadsheet `{sheet, cell}` locator off a raw object. */
|
||||
function normalizeCellLocator(
|
||||
c: Record<string, unknown>,
|
||||
): { sheet?: string; cell?: string } {
|
||||
const out: { sheet?: string; cell?: string } = {};
|
||||
if (typeof c.sheet === "string" && c.sheet.trim()) out.sheet = c.sheet.trim();
|
||||
if (typeof c.cell === "string" && c.cell.trim()) out.cell = c.cell.trim();
|
||||
return out;
|
||||
}
|
||||
|
||||
function normalizeCitationPage(value: unknown): number | string {
|
||||
if (typeof value === "number") {
|
||||
return value;
|
||||
} else if (typeof value === "string" && /^\d+\s*-\s*\d+$/.test(value)) {
|
||||
return value;
|
||||
} else {
|
||||
const n = parseInt(String(value ?? ""), 10);
|
||||
if (!Number.isFinite(n)) return 1;
|
||||
return n;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeDocumentCitationQuotes(
|
||||
c: Record<string, unknown>,
|
||||
): DocumentQuote[] {
|
||||
if (!Array.isArray(c.quotes)) return [];
|
||||
return c.quotes
|
||||
.slice(0, 3)
|
||||
.map((raw): DocumentQuote | null => {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
||||
const row = raw as Record<string, unknown>;
|
||||
const text = typeof row.quote === "string" ? row.quote : row.text;
|
||||
if (typeof text !== "string" || !text.trim()) return null;
|
||||
// Fall back to the top-level sheet/cell so a citation can set them once.
|
||||
return {
|
||||
page: normalizeCitationPage(row.page ?? c.page),
|
||||
quote: text,
|
||||
...normalizeCellLocator({
|
||||
sheet: row.sheet ?? c.sheet,
|
||||
cell: row.cell ?? c.cell,
|
||||
}),
|
||||
};
|
||||
})
|
||||
.filter((quote): quote is DocumentQuote => !!quote);
|
||||
}
|
||||
|
||||
function normalizeCaseCitationQuotes(c: Record<string, unknown>) {
|
||||
if (!Array.isArray(c.quotes)) return [];
|
||||
return c.quotes
|
||||
.slice(0, 3)
|
||||
.map((raw) => {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
||||
const row = raw as Record<string, unknown>;
|
||||
const text = typeof row.quote === "string" ? row.quote : row.text;
|
||||
if (typeof text !== "string" || !text.trim()) return null;
|
||||
const opinionId =
|
||||
typeof row.opinion_id === "number" && Number.isFinite(row.opinion_id)
|
||||
? Math.floor(row.opinion_id)
|
||||
: typeof row.opinionId === "number" && Number.isFinite(row.opinionId)
|
||||
? Math.floor(row.opinionId)
|
||||
: null;
|
||||
return {
|
||||
opinionId,
|
||||
type: typeof row.type === "string" ? row.type : null,
|
||||
author: typeof row.author === "string" ? row.author : null,
|
||||
quote: text,
|
||||
};
|
||||
})
|
||||
.filter(
|
||||
(quote): quote is {
|
||||
opinionId: number | null;
|
||||
type: string | null;
|
||||
author: string | null;
|
||||
quote: string;
|
||||
} => !!quote,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Citation block constants and parsers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const CITATIONS_BLOCK_RE = /<CITATIONS>\s*([\s\S]*?)\s*<\/CITATIONS>/;
|
||||
export const CITATIONS_OPEN_TAG = "<CITATIONS>";
|
||||
export const CITATIONS_CLOSE_TAG = "</CITATIONS>";
|
||||
|
||||
type CitationParseDiagnostics = {
|
||||
hasBlock: boolean;
|
||||
rawLength: number;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
export function parseCitationsWithDiagnostics(text: string): {
|
||||
citations: ParsedCitation[];
|
||||
diagnostics: CitationParseDiagnostics;
|
||||
} {
|
||||
const match = text.match(CITATIONS_BLOCK_RE);
|
||||
if (!match) {
|
||||
return { citations: [], diagnostics: { hasBlock: false, rawLength: 0, error: null } };
|
||||
}
|
||||
const raw = match[1] ?? "";
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) {
|
||||
return {
|
||||
citations: [],
|
||||
diagnostics: { hasBlock: true, rawLength: raw.length, error: "CITATIONS block JSON was not an array." },
|
||||
};
|
||||
}
|
||||
return {
|
||||
citations: parsed.map(normalizeCitation).filter((c): c is ParsedCitation => c !== null),
|
||||
diagnostics: { hasBlock: true, rawLength: raw.length, error: null },
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
citations: [],
|
||||
diagnostics: {
|
||||
hasBlock: true,
|
||||
rawLength: raw.length,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function parseCitations(text: string): ParsedCitation[] {
|
||||
return parseCitationsWithDiagnostics(text).citations;
|
||||
}
|
||||
|
||||
export function parsePartialCitationObjects(text: string): ParsedCitation[] {
|
||||
const beforeClose = text.split(CITATIONS_CLOSE_TAG)[0] ?? text;
|
||||
const arrayStart = beforeClose.indexOf("[");
|
||||
if (arrayStart < 0) return [];
|
||||
|
||||
const parsed: ParsedCitation[] = [];
|
||||
let inString = false;
|
||||
let escaped = false;
|
||||
let depth = 0;
|
||||
let objectStart = -1;
|
||||
|
||||
for (let i = arrayStart + 1; i < beforeClose.length; i += 1) {
|
||||
const char = beforeClose[i];
|
||||
if (escaped) { escaped = false; continue; }
|
||||
if (char === "\\") { escaped = inString; continue; }
|
||||
if (char === '"') { inString = !inString; continue; }
|
||||
if (inString) continue;
|
||||
if (char === "{") {
|
||||
if (depth === 0) objectStart = i;
|
||||
depth += 1;
|
||||
} else if (char === "}") {
|
||||
if (depth === 0) continue;
|
||||
depth -= 1;
|
||||
if (depth === 0 && objectStart >= 0) {
|
||||
try {
|
||||
const raw = JSON.parse(beforeClose.slice(objectStart, i + 1));
|
||||
const citation = normalizeCitation(raw);
|
||||
if (citation) parsed.push(citation);
|
||||
} catch { /* ignore incomplete/malformed partial object */ }
|
||||
objectStart = -1;
|
||||
}
|
||||
} else if (char === "]" && depth === 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
type CasesByClusterId = Map<number, {
|
||||
caseName: string | null;
|
||||
citations: string[];
|
||||
url: string | null;
|
||||
pdfUrl: string | null;
|
||||
dateFiled: string | null;
|
||||
}>;
|
||||
|
||||
export function createCitation(
|
||||
citation: ParsedCitation,
|
||||
docIndex: DocIndex,
|
||||
casesByClusterId?: CasesByClusterId,
|
||||
) {
|
||||
if (citation.kind === "case") {
|
||||
const caseRecord = casesByClusterId?.get(citation.cluster_id);
|
||||
return {
|
||||
type: "citation_data",
|
||||
kind: "case",
|
||||
ref: citation.ref,
|
||||
cluster_id: citation.cluster_id,
|
||||
case_name: caseRecord?.caseName ?? null,
|
||||
citation: caseRecord?.citations[0] ?? null,
|
||||
url: caseRecord?.url ?? null,
|
||||
pdfUrl: caseRecord?.pdfUrl ?? null,
|
||||
dateFiled: caseRecord?.dateFiled ?? null,
|
||||
quotes: citation.quotes,
|
||||
};
|
||||
}
|
||||
|
||||
const docInfo = resolveDoc(citation.doc_id, docIndex);
|
||||
return {
|
||||
type: "citation_data",
|
||||
kind: "document",
|
||||
ref: citation.ref,
|
||||
doc_id: citation.doc_id,
|
||||
document_id: docInfo?.document_id,
|
||||
version_id: docInfo?.version_id ?? null,
|
||||
version_number: docInfo?.version_number ?? null,
|
||||
filename: docInfo?.filename ?? citation.doc_id,
|
||||
page: citation.page,
|
||||
quote: citation.quote,
|
||||
sheet: citation.sheet,
|
||||
cell: citation.cell,
|
||||
quotes: citation.quotes,
|
||||
};
|
||||
}
|
||||
583
backend/src/lib/chat/contextBuilders.ts
Normal file
583
backend/src/lib/chat/contextBuilders.ts
Normal file
|
|
@ -0,0 +1,583 @@
|
|||
import { createServerSupabase } from "../supabase";
|
||||
import {
|
||||
attachActiveVersionPaths,
|
||||
} from "../documentVersions";
|
||||
import {
|
||||
type DocStore,
|
||||
type DocIndex,
|
||||
type WorkflowStore,
|
||||
type ChatMessage,
|
||||
type AskInputsResponseRequest,
|
||||
type AskInputResponseItem,
|
||||
devLog,
|
||||
} from "./types";
|
||||
import { buildSystemPrompt } from "./prompts";
|
||||
import { parseCitations, createCitation } from "./citations";
|
||||
import type { AssistantEvent } from "./streaming";
|
||||
|
||||
|
||||
export async function enrichWithPriorEvents(
|
||||
messages: ChatMessage[],
|
||||
chatId: string | null | undefined,
|
||||
db: ReturnType<typeof createServerSupabase>,
|
||||
docIndex: DocIndex,
|
||||
): Promise<ChatMessage[]> {
|
||||
if (!chatId) return messages;
|
||||
const { data: rows } = await db
|
||||
.from("chat_messages")
|
||||
.select("content, created_at")
|
||||
.eq("chat_id", chatId)
|
||||
.eq("role", "assistant")
|
||||
.order("created_at", { ascending: false })
|
||||
.limit(1);
|
||||
|
||||
const lastRow = rows?.[0] as { content?: unknown } | undefined;
|
||||
const content = lastRow?.content;
|
||||
if (!Array.isArray(content)) return messages;
|
||||
|
||||
const slugByDocumentId = new Map<string, string>();
|
||||
for (const [slug, info] of Object.entries(docIndex)) {
|
||||
if (info.document_id) slugByDocumentId.set(info.document_id, slug);
|
||||
}
|
||||
const refFor = (documentId: unknown, filename: unknown) => {
|
||||
const slug =
|
||||
typeof documentId === "string"
|
||||
? slugByDocumentId.get(documentId)
|
||||
: undefined;
|
||||
return slug ? `${slug} ("${filename}")` : `"${filename}"`;
|
||||
};
|
||||
|
||||
const lines: string[] = [];
|
||||
for (const ev of content as Record<string, unknown>[]) {
|
||||
if (ev?.type === "doc_created") {
|
||||
lines.push(`- generated_document → ${refFor(ev.document_id, ev.filename)}`);
|
||||
} else if (ev?.type === "doc_edited") {
|
||||
lines.push(`- edit_document → ${refFor(ev.document_id, ev.filename)}`);
|
||||
} else if (ev?.type === "doc_read") {
|
||||
lines.push(`- read_document → ${refFor(ev.document_id, ev.filename)}`);
|
||||
} else if (ev?.type === "doc_replicated") {
|
||||
// The model needs to know what each copy resolved to so it
|
||||
// can call edit_document / read_document on them. Emit one
|
||||
// line per copy, all attributed back to the same source.
|
||||
const srcLabel =
|
||||
typeof ev.filename === "string" ? `"${ev.filename}"` : "";
|
||||
const copies = Array.isArray(ev.copies)
|
||||
? (ev.copies as {
|
||||
new_filename?: unknown;
|
||||
document_id?: unknown;
|
||||
}[])
|
||||
: [];
|
||||
for (const c of copies) {
|
||||
const ref = refFor(c.document_id, c.new_filename);
|
||||
lines.push(
|
||||
srcLabel
|
||||
? `- replicate_document → ${ref} (copy of ${srcLabel})`
|
||||
: `- replicate_document → ${ref}`,
|
||||
);
|
||||
}
|
||||
} else if (ev?.type === "workflow_applied") {
|
||||
lines.push(`- applied workflow: "${ev.title}"`);
|
||||
} else if (ev?.type === "ask_inputs") {
|
||||
const count = Array.isArray(ev.items) ? ev.items.length : 0;
|
||||
lines.push(`- asked user for ${count} input${count === 1 ? "" : "s"}`);
|
||||
} else if (ev?.type === "ask_inputs_response") {
|
||||
const responses = Array.isArray(ev.responses) ? ev.responses : [];
|
||||
for (const response of responses) {
|
||||
if (!response || typeof response !== "object") continue;
|
||||
const row = response as Record<string, unknown>;
|
||||
if (row.skipped) {
|
||||
lines.push("- user skipped an input");
|
||||
} else if (row.kind === "choice" && typeof row.answer === "string") {
|
||||
lines.push(`- user answered: "${row.answer}"`);
|
||||
} else if (
|
||||
row.kind === "documents" &&
|
||||
Array.isArray(row.filenames)
|
||||
) {
|
||||
lines.push(
|
||||
`- user attached documents: ${row.filenames.join(", ") || "none"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (lines.length === 0) return messages;
|
||||
const summary = `\n\n[Tool activity in your previous turn]\n${lines.join("\n")}`;
|
||||
|
||||
// Find the index of the last assistant message and attach the
|
||||
// summary there only.
|
||||
let lastAssistantIdx = -1;
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
if (messages[i].role === "assistant") {
|
||||
lastAssistantIdx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (lastAssistantIdx < 0) return messages;
|
||||
const enriched = messages.slice();
|
||||
const target = enriched[lastAssistantIdx];
|
||||
enriched[lastAssistantIdx] = {
|
||||
...target,
|
||||
content: (target.content ?? "") + summary,
|
||||
};
|
||||
return enriched;
|
||||
}
|
||||
|
||||
export function buildMessages(
|
||||
messages: ChatMessage[],
|
||||
docAvailability: {
|
||||
doc_id: string;
|
||||
filename: string;
|
||||
folder_path?: string;
|
||||
}[],
|
||||
systemPromptExtra?: string,
|
||||
docIndex?: DocIndex,
|
||||
includeResearchTools = true,
|
||||
) {
|
||||
const formatted: unknown[] = [];
|
||||
let systemContent = buildSystemPrompt(includeResearchTools);
|
||||
|
||||
if (systemPromptExtra) {
|
||||
systemContent += `\n\n${systemPromptExtra.trim()}`;
|
||||
}
|
||||
|
||||
if (docAvailability.length) {
|
||||
systemContent += "\n\n---\nAVAILABLE DOCUMENTS:\n";
|
||||
for (const doc of docAvailability) {
|
||||
const label = doc.folder_path
|
||||
? `${doc.folder_path} / ${doc.filename}`
|
||||
: doc.filename;
|
||||
systemContent += `- ${doc.doc_id}: ${label}\n`;
|
||||
}
|
||||
systemContent +=
|
||||
"\nYou do NOT retain document content between conversation turns. You MUST call read_document (or fetch_documents) once at the start of every response that involves a document's content, even if you have read it in a previous turn. Within the same response, do not call read_document or fetch_documents again for a document/version that has already been read; use the prior tool result, find_in_document for targeted checks, or proceed to the next required tool. Failure to read once per turn will result in hallucinated or stale content.\n---\n";
|
||||
}
|
||||
formatted.push({ role: "system", content: systemContent });
|
||||
|
||||
// Map document_id (UUID) → current-turn doc_id slug, so when we
|
||||
// inline a user attachment we hand the model the same handle it
|
||||
// would use to call read_document / fetch_documents.
|
||||
const slugByDocumentId = new Map<string, string>();
|
||||
if (docIndex) {
|
||||
for (const [slug, info] of Object.entries(docIndex)) {
|
||||
if (info.document_id) slugByDocumentId.set(info.document_id, slug);
|
||||
}
|
||||
}
|
||||
|
||||
for (const msg of messages) {
|
||||
let content = msg.content ?? "";
|
||||
if (msg.role === "user" && msg.workflow) {
|
||||
content = `[Workflow: ${msg.workflow.title} (id: ${msg.workflow.id})]\n\n${content}`;
|
||||
}
|
||||
if (msg.role === "user" && msg.files?.length) {
|
||||
const lines = msg.files.map((f) => {
|
||||
const slug = f.document_id
|
||||
? slugByDocumentId.get(f.document_id)
|
||||
: undefined;
|
||||
return slug ? `- ${slug}: ${f.filename}` : `- ${f.filename}`;
|
||||
});
|
||||
content = `[The user attached the following document(s) to this message:\n${lines.join("\n")}]\n\n${content}`;
|
||||
}
|
||||
formatted.push({ role: msg.role, content });
|
||||
}
|
||||
return formatted;
|
||||
}
|
||||
|
||||
export function extractCitations(
|
||||
fullText: string,
|
||||
docIndex: DocIndex,
|
||||
_events?: ({ type: string } & Record<string, unknown>[]) | unknown[],
|
||||
): unknown[] {
|
||||
return parseCitations(fullText).map((c) =>
|
||||
createCitation(c, docIndex),
|
||||
);
|
||||
}
|
||||
|
||||
export function stripTransientAssistantEvents(events: AssistantEvent[]) {
|
||||
return events.filter((event) => event.type !== "case_opinions");
|
||||
}
|
||||
|
||||
function cleanAskInputResponseId(value: unknown) {
|
||||
const id = typeof value === "string" ? value.trim() : "";
|
||||
return id.slice(0, 80);
|
||||
}
|
||||
|
||||
export function parseAskInputsResponsePayload(
|
||||
value: unknown,
|
||||
): AskInputsResponseRequest | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||
const row = value as Record<string, unknown>;
|
||||
const rawResponses = Array.isArray(row.responses) ? row.responses : [];
|
||||
const responses = rawResponses
|
||||
.map((item): AskInputResponseItem | null => {
|
||||
if (!item || typeof item !== "object" || Array.isArray(item)) return null;
|
||||
const current = item as Record<string, unknown>;
|
||||
const id = cleanAskInputResponseId(current.id);
|
||||
const kind = current.kind;
|
||||
const skipped = current.skipped === true;
|
||||
if (!id || (kind !== "choice" && kind !== "documents")) return null;
|
||||
if (kind === "choice") {
|
||||
const question =
|
||||
typeof current.question === "string"
|
||||
? current.question.trim().slice(0, 500)
|
||||
: "";
|
||||
const answer =
|
||||
typeof current.answer === "string"
|
||||
? current.answer.trim().slice(0, 1000)
|
||||
: "";
|
||||
if (!question || (!answer && !skipped)) return null;
|
||||
return {
|
||||
id,
|
||||
kind,
|
||||
question,
|
||||
...(answer ? { answer } : {}),
|
||||
...(skipped ? { skipped: true } : {}),
|
||||
};
|
||||
}
|
||||
const rawFilenames = Array.isArray(current.filenames)
|
||||
? current.filenames
|
||||
: [];
|
||||
const filenames = rawFilenames
|
||||
.filter((f): f is string => typeof f === "string")
|
||||
.map((f) => f.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 50);
|
||||
return {
|
||||
id,
|
||||
kind,
|
||||
filenames,
|
||||
...(skipped ? { skipped: true } : {}),
|
||||
};
|
||||
})
|
||||
.filter((item): item is AskInputResponseItem => !!item)
|
||||
.slice(0, 20);
|
||||
return responses.length > 0 ? { responses } : null;
|
||||
}
|
||||
|
||||
export async function appendAskInputsResponseToLastAssistantMessage(
|
||||
db: ReturnType<typeof createServerSupabase>,
|
||||
chatId: string,
|
||||
response: AskInputsResponseRequest,
|
||||
) {
|
||||
await appendAssistantEventsToLastAssistantMessage(db, chatId, [
|
||||
{
|
||||
type: "ask_inputs_response" as const,
|
||||
responses: response.responses,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
export async function appendAssistantEventsToLastAssistantMessage(
|
||||
db: ReturnType<typeof createServerSupabase>,
|
||||
chatId: string,
|
||||
events: AssistantEvent[],
|
||||
citations?: unknown[],
|
||||
) {
|
||||
if (events.length === 0 && (!citations || citations.length === 0)) {
|
||||
return;
|
||||
}
|
||||
const { data: rows, error: selectError } = await db
|
||||
.from("chat_messages")
|
||||
.select("id, content, citations")
|
||||
.eq("chat_id", chatId)
|
||||
.eq("role", "assistant")
|
||||
.order("created_at", { ascending: false })
|
||||
.limit(1);
|
||||
if (selectError || !rows?.[0]) {
|
||||
if (selectError) {
|
||||
console.error(
|
||||
"[assistant-events] failed to load assistant message",
|
||||
selectError,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const row = rows[0] as {
|
||||
id: string;
|
||||
content: unknown;
|
||||
citations?: unknown;
|
||||
};
|
||||
const existing = Array.isArray(row.content)
|
||||
? row.content
|
||||
: [];
|
||||
const next = [...existing, ...events];
|
||||
const existingCitations = Array.isArray(row.citations)
|
||||
? row.citations
|
||||
: [];
|
||||
const nextCitations =
|
||||
citations && citations.length > 0
|
||||
? [...existingCitations, ...citations]
|
||||
: existingCitations;
|
||||
const { error: updateError } = await db
|
||||
.from("chat_messages")
|
||||
.update({
|
||||
content: next.length ? next : null,
|
||||
citations: nextCitations.length ? nextCitations : null,
|
||||
})
|
||||
.eq("id", row.id);
|
||||
if (updateError) {
|
||||
console.error(
|
||||
"[assistant-events] failed to update assistant message",
|
||||
updateError,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function appendCancelledAssistantEvent(events: AssistantEvent[]) {
|
||||
return [...events, { type: "content" as const, text: "Cancelled by user." }];
|
||||
}
|
||||
|
||||
export function buildCancelledAssistantMessage(args: {
|
||||
fullText: string;
|
||||
events: AssistantEvent[];
|
||||
buildCitations: (fullText: string, events: AssistantEvent[]) => unknown[];
|
||||
}) {
|
||||
const events = appendCancelledAssistantEvent(
|
||||
stripTransientAssistantEvents(args.events),
|
||||
);
|
||||
return {
|
||||
events,
|
||||
citations: args.buildCitations(args.fullText, events),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Document context builder (from message file attachments)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function buildDocContext(
|
||||
messages: ChatMessage[],
|
||||
userId: string,
|
||||
db: ReturnType<typeof createServerSupabase>,
|
||||
chatId?: string | null,
|
||||
): Promise<{ docIndex: DocIndex; docStore: DocStore }> {
|
||||
const docIndex: DocIndex = {};
|
||||
const docStore: DocStore = new Map();
|
||||
|
||||
const documentIds = new Set<string>();
|
||||
for (const m of messages) {
|
||||
for (const f of m.files ?? []) {
|
||||
if (f.document_id) documentIds.add(f.document_id);
|
||||
}
|
||||
}
|
||||
|
||||
// Also pull in document_ids from prior assistant events in this chat —
|
||||
// generated docs (generate_docx) and tracked-change edits (edit_document)
|
||||
// aren't attached to user messages as files, so they only live in the
|
||||
// assistant's `doc_created` / `doc_edited` events. Without this sweep
|
||||
// the model loses access to generated docs after the turn that created
|
||||
// them, and can't call edit_document / read_document on them.
|
||||
if (chatId) {
|
||||
const { data: rows } = await db
|
||||
.from("chat_messages")
|
||||
.select("content")
|
||||
.eq("chat_id", chatId)
|
||||
.eq("role", "assistant");
|
||||
for (const row of rows ?? []) {
|
||||
const content = (row as { content?: unknown }).content;
|
||||
if (!Array.isArray(content)) continue;
|
||||
for (const ev of content as Record<string, unknown>[]) {
|
||||
if (
|
||||
(ev?.type === "doc_created" || ev?.type === "doc_edited") &&
|
||||
typeof ev.document_id === "string"
|
||||
) {
|
||||
documentIds.add(ev.document_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ids = [...documentIds];
|
||||
if (ids.length > 0) {
|
||||
const { data: docs } = await db
|
||||
.from("documents")
|
||||
.select("id, current_version_id, status")
|
||||
.in("id", ids)
|
||||
.eq("user_id", userId)
|
||||
.eq("status", "ready");
|
||||
|
||||
const docList = (docs ?? []) as unknown as {
|
||||
id: string;
|
||||
filename?: string | null;
|
||||
file_type?: string | null;
|
||||
current_version_id?: string | null;
|
||||
active_version_number?: number | null;
|
||||
storage_path?: string | null;
|
||||
}[];
|
||||
await attachActiveVersionPaths(db, docList);
|
||||
for (let i = 0; i < docList.length; i++) {
|
||||
const doc = docList[i];
|
||||
if (!doc.storage_path) continue;
|
||||
const docLabel = `doc-${i}`;
|
||||
const filename = doc.filename?.trim() || "Untitled document";
|
||||
docIndex[docLabel] = {
|
||||
document_id: doc.id,
|
||||
filename,
|
||||
version_id: doc.current_version_id ?? null,
|
||||
version_number: doc.active_version_number ?? null,
|
||||
};
|
||||
docStore.set(docLabel, {
|
||||
storage_path: doc.storage_path,
|
||||
file_type: doc.file_type ?? "",
|
||||
filename,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
devLog(
|
||||
"[buildDocContext] available docs:",
|
||||
Object.entries(docIndex).map(([label, info]) => ({
|
||||
label,
|
||||
filename: info.filename,
|
||||
document_id: info.document_id,
|
||||
})),
|
||||
);
|
||||
return { docIndex, docStore };
|
||||
}
|
||||
|
||||
export async function buildProjectDocContext(
|
||||
projectId: string,
|
||||
_userId: string,
|
||||
db: ReturnType<typeof createServerSupabase>,
|
||||
): Promise<{
|
||||
docIndex: DocIndex;
|
||||
docStore: DocStore;
|
||||
folderPaths: Map<string, string>;
|
||||
}> {
|
||||
const docIndex: DocIndex = {};
|
||||
const docStore: DocStore = new Map();
|
||||
|
||||
const [{ data: docs }, { data: folders }] = await Promise.all([
|
||||
db
|
||||
.from("documents")
|
||||
.select("id, current_version_id, status, folder_id")
|
||||
.eq("project_id", projectId)
|
||||
.eq("status", "ready")
|
||||
.order("created_at", { ascending: true }),
|
||||
db
|
||||
.from("project_subfolders")
|
||||
.select("id, name, parent_folder_id")
|
||||
.eq("project_id", projectId),
|
||||
]);
|
||||
const docList = (docs ?? []) as unknown as {
|
||||
id: string;
|
||||
filename?: string | null;
|
||||
file_type?: string | null;
|
||||
current_version_id?: string | null;
|
||||
active_version_number?: number | null;
|
||||
folder_id?: string | null;
|
||||
storage_path?: string | null;
|
||||
}[];
|
||||
await attachActiveVersionPaths(db, docList);
|
||||
|
||||
// Build folder id → full path map
|
||||
const folderMap = new Map<
|
||||
string,
|
||||
{ name: string; parent_folder_id: string | null }
|
||||
>();
|
||||
for (const f of folders ?? [])
|
||||
folderMap.set(f.id, {
|
||||
name: f.name,
|
||||
parent_folder_id: f.parent_folder_id,
|
||||
});
|
||||
|
||||
function resolvePath(folderId: string | null): string {
|
||||
if (!folderId) return "";
|
||||
const parts: string[] = [];
|
||||
let cur: string | null = folderId;
|
||||
while (cur) {
|
||||
const f = folderMap.get(cur);
|
||||
if (!f) break;
|
||||
parts.unshift(f.name);
|
||||
cur = f.parent_folder_id;
|
||||
}
|
||||
return parts.join(" / ");
|
||||
}
|
||||
|
||||
const folderPaths = new Map<string, string>(); // doc label → folder path
|
||||
|
||||
for (let i = 0; i < docList.length; i++) {
|
||||
const doc = docList[i];
|
||||
if (!doc.storage_path) continue;
|
||||
const docLabel = `doc-${i}`;
|
||||
const filename = doc.filename?.trim() || "Untitled document";
|
||||
docIndex[docLabel] = {
|
||||
document_id: doc.id,
|
||||
filename,
|
||||
version_id: doc.current_version_id ?? null,
|
||||
version_number: doc.active_version_number ?? null,
|
||||
};
|
||||
docStore.set(docLabel, {
|
||||
storage_path: doc.storage_path,
|
||||
file_type: doc.file_type ?? "",
|
||||
filename,
|
||||
});
|
||||
const path = resolvePath(doc.folder_id ?? null);
|
||||
if (path) folderPaths.set(docLabel, path);
|
||||
}
|
||||
|
||||
devLog(
|
||||
"[buildProjectDocContext] available docs:",
|
||||
Object.entries(docIndex).map(([label, info]) => ({
|
||||
label,
|
||||
filename: info.filename,
|
||||
document_id: info.document_id,
|
||||
folder: folderPaths.get(label) ?? null,
|
||||
})),
|
||||
);
|
||||
return { docIndex, docStore, folderPaths };
|
||||
}
|
||||
|
||||
export async function buildWorkflowStore(
|
||||
userId: string,
|
||||
userEmail: string | null | undefined,
|
||||
db: ReturnType<typeof createServerSupabase>,
|
||||
): Promise<WorkflowStore> {
|
||||
const { SYSTEM_ASSISTANT_WORKFLOWS } = await import("../systemWorkflows");
|
||||
const store: WorkflowStore = new Map();
|
||||
const normalizedUserEmail = (userEmail ?? "").trim().toLowerCase();
|
||||
|
||||
// Seed system workflows first.
|
||||
for (const wf of SYSTEM_ASSISTANT_WORKFLOWS) {
|
||||
store.set(wf.id, { title: wf.title, prompt_md: wf.prompt_md });
|
||||
}
|
||||
|
||||
// Then overlay user-owned assistant workflows.
|
||||
const { data: workflows } = await db
|
||||
.from("workflows")
|
||||
.select("id, title, prompt_md")
|
||||
.eq("user_id", userId)
|
||||
.eq("type", "assistant");
|
||||
for (const wf of workflows ?? []) {
|
||||
if (wf.prompt_md) {
|
||||
store.set(wf.id, { title: wf.title, prompt_md: wf.prompt_md });
|
||||
}
|
||||
}
|
||||
|
||||
// Shared assistant workflows must also be readable by workflow tools.
|
||||
if (normalizedUserEmail) {
|
||||
const { data: shares } = await db
|
||||
.from("workflow_shares")
|
||||
.select("workflow_id")
|
||||
.eq("shared_with_email", normalizedUserEmail);
|
||||
const sharedIds = [
|
||||
...new Set((shares ?? []).map((share) => share.workflow_id)),
|
||||
];
|
||||
if (sharedIds.length > 0) {
|
||||
const { data: sharedWorkflows } = await db
|
||||
.from("workflows")
|
||||
.select("id, title, prompt_md")
|
||||
.in("id", sharedIds)
|
||||
.eq("type", "assistant");
|
||||
for (const wf of sharedWorkflows ?? []) {
|
||||
if (wf.prompt_md) {
|
||||
store.set(wf.id, {
|
||||
title: wf.title,
|
||||
prompt_md: wf.prompt_md,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return store;
|
||||
}
|
||||
8
backend/src/lib/chat/index.ts
Normal file
8
backend/src/lib/chat/index.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
export * from "./types";
|
||||
export * from "./prompts";
|
||||
export * from "./tools/toolSchemas";
|
||||
export * from "./citations";
|
||||
export * from "./tools/documentOps";
|
||||
export * from "./tools/toolDispatcher";
|
||||
export * from "./streaming";
|
||||
export * from "./contextBuilders";
|
||||
82
backend/src/lib/chat/prompts.ts
Normal file
82
backend/src/lib/chat/prompts.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import { COURTLISTENER_SYSTEM_PROMPT } from "./tools/courtlistenerTools";
|
||||
|
||||
const SYSTEM_PROMPT_BEFORE_RESEARCH = `You are Mike, an AI legal assistant for lawyers and legal professionals. Help analyze documents, answer legal questions, and draft legal documents.
|
||||
|
||||
CORE RULES:
|
||||
- Be precise, professional, and evidence-aware.
|
||||
- Do not fabricate document content.
|
||||
- Use at most 10 tool-use rounds per response. Batch independent tool calls and leave room for the final answer.
|
||||
- Read each relevant document/version at most once per response. After read_document or fetch_documents returns a document's full text, do not call either tool again for that same document/version in the same response; use the prior result, call find_in_document for targeted checks, or proceed to the next required tool.
|
||||
- If the user selects a workflow with [Workflow: <title> (id: <id>)], immediately call read_workflow with that id and follow the workflow before doing anything else.
|
||||
- If you need the user to choose between options, clarify a missing premise, or attach one or more documents before you can continue, call ask_inputs with all needed choice and document-upload items in a single tool call. For document-upload items, include a document_types array with short labels for the specific categories of documents you need. After asking, do not continue the substantive task until the user responds in a later message.
|
||||
|
||||
DOCUMENT CITATIONS:
|
||||
Use document citations only for verbatim evidence from uploaded or generated documents.
|
||||
|
||||
In prose, put sequential markers [1], [2], etc. exactly where the cited claim appears. Assign citation refs in first-appearance order and increment by exactly 1 each time: [1], [2], [3], never [1], [2], [3], [4], [5], [8], [9]. The marker number is the citation "ref" value, not a page, footnote, section, clause, or document number.
|
||||
|
||||
At the very end of the response, append:
|
||||
<CITATIONS>
|
||||
[
|
||||
{"ref": 1, "doc_id": "doc-0", "quotes": [{"page": 3, "quote": "exact verbatim text"}]},
|
||||
{"ref": 2, "doc_id": "doc-1", "quotes": [{"page": "41-42", "quote": "text before page break [[PAGE_BREAK]] text after page break"}]}
|
||||
]
|
||||
</CITATIONS>
|
||||
|
||||
Citation rules:
|
||||
- Every [N] marker must have exactly one matching entry with "ref": N.
|
||||
- Citation refs must be contiguous with no skipped numbers. If the response uses N citations, the refs must be exactly 1 through N, and the <CITATIONS> array should list them in that order.
|
||||
- Bracketed numbers like [1] are only citation annotation markers. Do not add brackets to section, clause, schedule, exhibit, paragraph, or list numbering.
|
||||
- "doc_id" must be the exact chat-local label you were given, such as "doc-0". Never use a filename or document UUID in "doc_id".
|
||||
- Use one citation entry per marker. If one marker needs several passages, use "quotes" with 1 quote by default and at most 3.
|
||||
- Keep quotes short, ideally 25 words or fewer, and tightly matched to the claim.
|
||||
- "page" means the sequential [Page N] marker in the provided text, not printed page numbers inside the document. Non-spreadsheet unpaginated files may have no [Page N] markers; omit "page" (or use 1) when none is present.
|
||||
- For spreadsheet sources (content shown as "## Sheet: <name>" markdown tables with a "Row" column and column-letter headers), cite by cell instead of page: set "sheet" to the sheet name and "cell" to the A1 address or range you are quoting (e.g. "B7" or "B7:C9", combining the column-letter header with the "Row" number). Put the plain cell value in "quote" with no "Row"/column-letter labels or "|" separators. Omit "page" for spreadsheet citations.
|
||||
- For a continuous quote crossing two pages, set "page" to "N-M" and include [[PAGE_BREAK]] at the page break. Otherwise, use separate quote objects.
|
||||
- For legacy compatibility, you may also include top-level "page" and "quote" matching the first quote.
|
||||
- Omit the <CITATIONS> block when there are no citations.
|
||||
|
||||
DOCX GENERATION:
|
||||
- If the user asks you to create or draft a document, call generate_docx and provide the downloadable Word document rather than only displaying text inline.
|
||||
- If the user asks for a spreadsheet, table workbook, tracker, checklist matrix, or Excel file, call generate_excel.
|
||||
- If the user asks for slides, a presentation, pitch deck, board deck, or PowerPoint file, call generate_ppt.
|
||||
- If the user asks to revise a document you just generated, call edit_document on that document unless they explicitly want a brand-new document or the change is too broad for coherent editing.
|
||||
- Use heading levels in order; do not skip from Heading 1 to Heading 3.
|
||||
- Numbering starts at 1, never 0. The generator applies legal numbering automatically. Do not type numbering prefixes into headings.
|
||||
- Do not repeat the document title as the first section heading.
|
||||
- Contract preambles, party blocks, recitals, and WHEREAS clauses are unnumbered. Begin numbering at the first operative clause or section.
|
||||
- Contracts and agreements must end with an unnumbered signature block on a fresh page. Set pageBreak: true on the final section and include signature lines such as By, Name, Title, and Date for each party.
|
||||
|
||||
DOCUMENT EDITING:
|
||||
- For document edits, call read_document or fetch_documents once for each relevant document/version unless the exact needed text is already available in this response. Do not reread the same document/version before calling edit_document.
|
||||
When edit_document adds, deletes, moves, or reorders any numbered clause, section, schedule, exhibit, or list item:
|
||||
- Renumber all affected downstream items in the same edit.
|
||||
- Update all affected cross-references, including references in recitals, definitions, schedules, and exhibits.
|
||||
- Before editing, scan the full document with read_document or find_in_document for affected references.
|
||||
- If a reference might point to a shifted number, include the update and explain the reason.
|
||||
- When deleting square brackets, delete both "[" and "]".`;
|
||||
|
||||
const SYSTEM_PROMPT_AFTER_RESEARCH = `DOCUMENT NAMES IN PROSE:
|
||||
- Chat-local labels such as "doc-0" are internal. Use them only in tool arguments and citation JSON.
|
||||
- Never show "doc-N" labels to the user in prose, headings, lists, or tool activity text.
|
||||
- Refer to documents by filename or a natural description, such as "the NDA draft".
|
||||
|
||||
GENERAL GUIDANCE:
|
||||
- Cite the exact document or fetched opinion passage for evidence-backed claims.
|
||||
- If no documents are provided, answer from legal knowledge.
|
||||
- Do not use emojis.
|
||||
`;
|
||||
|
||||
/**
|
||||
* Assemble the chat system prompt. When `includeResearchTools` is true the
|
||||
* CourtListener (US case-law) research instructions are spliced in; when
|
||||
* false they are omitted entirely so the model is not told about tools it
|
||||
* does not have.
|
||||
*/
|
||||
export function buildSystemPrompt(includeResearchTools = true): string {
|
||||
return includeResearchTools
|
||||
? `${SYSTEM_PROMPT_BEFORE_RESEARCH}\n\n${COURTLISTENER_SYSTEM_PROMPT}\n${SYSTEM_PROMPT_AFTER_RESEARCH}`
|
||||
: `${SYSTEM_PROMPT_BEFORE_RESEARCH}\n\n${SYSTEM_PROMPT_AFTER_RESEARCH}`;
|
||||
}
|
||||
|
||||
export const SYSTEM_PROMPT = buildSystemPrompt(true);
|
||||
553
backend/src/lib/chat/streaming.ts
Normal file
553
backend/src/lib/chat/streaming.ts
Normal file
|
|
@ -0,0 +1,553 @@
|
|||
import {
|
||||
streamChatWithTools,
|
||||
resolveModel,
|
||||
DEFAULT_MAIN_MODEL,
|
||||
type LlmMessage,
|
||||
type OpenAIToolSchema,
|
||||
} from "../llm";
|
||||
import { safeErrorMessage } from "../safeError";
|
||||
import { createServerSupabase } from "../supabase";
|
||||
import {
|
||||
buildUserMcpTools,
|
||||
type McpToolEvent,
|
||||
} from "../mcpConnectors";
|
||||
import {
|
||||
COURTLISTENER_TOOLS,
|
||||
type CaseCitationEvent,
|
||||
type CourtlistenerToolEvent,
|
||||
} from "./tools/courtlistenerTools";
|
||||
import {
|
||||
type DocStore,
|
||||
type DocIndex,
|
||||
type TabularCellStore,
|
||||
type WorkflowStore,
|
||||
type ToolCall,
|
||||
type AskInputsEvent,
|
||||
type EditAnnotation,
|
||||
devLog,
|
||||
} from "./types";
|
||||
import { TOOLS, WORKFLOW_TOOLS } from "./tools/toolSchemas";
|
||||
import {
|
||||
parseCitationsWithDiagnostics,
|
||||
parsePartialCitationObjects,
|
||||
createCitation,
|
||||
CITATIONS_OPEN_TAG,
|
||||
} from "./citations";
|
||||
import {
|
||||
runToolCalls,
|
||||
type CourtlistenerTurnState,
|
||||
} from "./tools/toolDispatcher";
|
||||
import {
|
||||
type TurnEditState,
|
||||
type TurnReadState,
|
||||
} from "./tools/documentOps";
|
||||
|
||||
|
||||
export type AssistantEvent =
|
||||
| { type: "reasoning"; text: string }
|
||||
| AskInputsEvent
|
||||
| {
|
||||
type: "ask_inputs_response";
|
||||
responses: {
|
||||
id: string;
|
||||
kind: "choice" | "documents";
|
||||
question?: string;
|
||||
answer?: string;
|
||||
filenames?: string[];
|
||||
skipped?: boolean;
|
||||
}[];
|
||||
}
|
||||
| { type: "doc_read"; filename: string; document_id?: string }
|
||||
| {
|
||||
type: "doc_find";
|
||||
filename: string;
|
||||
query: string;
|
||||
total_matches: number;
|
||||
}
|
||||
| {
|
||||
type: "doc_created";
|
||||
filename: string;
|
||||
download_url: string;
|
||||
document_id?: string;
|
||||
version_id?: string;
|
||||
version_number?: number | null;
|
||||
}
|
||||
| { type: "doc_download"; filename: string; download_url: string }
|
||||
| {
|
||||
type: "doc_replicated";
|
||||
/** Source document being copied. */
|
||||
filename: string;
|
||||
count: number;
|
||||
copies: {
|
||||
new_filename: string;
|
||||
document_id: string;
|
||||
version_id: string;
|
||||
}[];
|
||||
}
|
||||
| { type: "workflow_applied"; workflow_id: string; title: string }
|
||||
| {
|
||||
type: "doc_edited";
|
||||
filename: string;
|
||||
document_id: string;
|
||||
version_id: string;
|
||||
/** Per-document monotonic Vn; null if backend couldn't determine it. */
|
||||
version_number: number | null;
|
||||
download_url: string;
|
||||
annotations: EditAnnotation[];
|
||||
}
|
||||
| CaseCitationEvent
|
||||
| CourtlistenerToolEvent
|
||||
| McpToolEvent
|
||||
| { type: "case_opinions"; cluster_id: number; case: unknown }
|
||||
| { type: "content"; text: string }
|
||||
| { type: "error"; message: string };
|
||||
|
||||
export class AssistantStreamError extends Error {
|
||||
fullText: string;
|
||||
events: AssistantEvent[];
|
||||
|
||||
constructor(message: string, fullText: string, events: AssistantEvent[]) {
|
||||
super(message);
|
||||
this.name = "AssistantStreamError";
|
||||
this.fullText = fullText;
|
||||
this.events = events;
|
||||
}
|
||||
}
|
||||
|
||||
export class AssistantStreamAbortError extends AssistantStreamError {
|
||||
constructor(fullText: string, events: AssistantEvent[]) {
|
||||
super("Stream aborted.", fullText, events);
|
||||
this.name = "AbortError";
|
||||
}
|
||||
}
|
||||
|
||||
class AssistantStreamAskInputsPause extends Error {
|
||||
constructor() {
|
||||
super("Waiting for user input.");
|
||||
this.name = "AssistantStreamAskInputsPause";
|
||||
}
|
||||
}
|
||||
|
||||
export function isAbortError(error: unknown): boolean {
|
||||
if (!error || typeof error !== "object") return false;
|
||||
const record = error as { name?: unknown; message?: unknown };
|
||||
return (
|
||||
record.name === "AbortError" || record.message === "Stream aborted."
|
||||
);
|
||||
}
|
||||
|
||||
function throwIfAborted(signal?: AbortSignal) {
|
||||
if (!signal?.aborted) return;
|
||||
const err = new Error("Stream aborted.");
|
||||
err.name = "AbortError";
|
||||
throw err;
|
||||
}
|
||||
|
||||
export async function runLLMStream(params: {
|
||||
apiMessages: unknown[];
|
||||
docStore: DocStore;
|
||||
docIndex: DocIndex;
|
||||
userId: string;
|
||||
db: ReturnType<typeof createServerSupabase>;
|
||||
write: (s: string) => void;
|
||||
extraTools?: unknown[];
|
||||
includeResearchTools?: boolean;
|
||||
workflowStore?: WorkflowStore;
|
||||
tabularStore?: TabularCellStore;
|
||||
buildCitations?: (fullText: string) => unknown[];
|
||||
model?: string;
|
||||
apiKeys?: import("../llm").UserApiKeys;
|
||||
signal?: AbortSignal;
|
||||
/**
|
||||
* If set, generate_docx will attach created docs to this project so
|
||||
* they appear in the project sidebar. Leave null for general chats —
|
||||
* generated docs still get persisted, but as standalone documents.
|
||||
*/
|
||||
projectId?: string | null;
|
||||
}): Promise<{
|
||||
fullText: string;
|
||||
events: AssistantEvent[];
|
||||
citations: unknown[];
|
||||
}> {
|
||||
const {
|
||||
apiMessages,
|
||||
docStore,
|
||||
docIndex,
|
||||
userId,
|
||||
db,
|
||||
write,
|
||||
extraTools,
|
||||
includeResearchTools = true,
|
||||
workflowStore,
|
||||
tabularStore,
|
||||
buildCitations,
|
||||
model,
|
||||
apiKeys,
|
||||
signal,
|
||||
projectId,
|
||||
} = params;
|
||||
const researchTools = includeResearchTools ? COURTLISTENER_TOOLS : [];
|
||||
const mcpTools = await buildUserMcpTools(userId, db);
|
||||
const baseTools = [...TOOLS, ...researchTools, ...WORKFLOW_TOOLS];
|
||||
const activeTools = extraTools?.length
|
||||
? [...baseTools, ...mcpTools, ...extraTools]
|
||||
: [...baseTools, ...mcpTools];
|
||||
|
||||
// Extract system prompt; pass remaining turns to the adapter as
|
||||
// plain user/assistant messages.
|
||||
const rawMsgs = apiMessages as { role: string; content: string | null }[];
|
||||
const systemPrompt =
|
||||
rawMsgs[0]?.role === "system" ? (rawMsgs[0].content ?? "") : "";
|
||||
const chatMessages: LlmMessage[] = rawMsgs
|
||||
.filter((m) => m.role !== "system")
|
||||
.map((m) => ({
|
||||
role: m.role === "assistant" ? "assistant" : "user",
|
||||
content: m.content ?? "",
|
||||
}));
|
||||
|
||||
const events: AssistantEvent[] = [];
|
||||
// One assistant turn produces at most one document_versions row per
|
||||
// edited doc. `runToolCalls` fires once per tool-call batch; the model
|
||||
// may emit multiple batches in a single turn, so this map persists
|
||||
// across batches to let subsequent edit_document calls overwrite the
|
||||
// turn's existing version instead of creating a new one.
|
||||
const turnEditState: TurnEditState = new Map();
|
||||
// Suppress repeated full-document reads for the same document/version in
|
||||
// one assistant response. The guard is invalidated when edit_document
|
||||
// changes that document so a post-edit verification read can still happen.
|
||||
const turnReadState: TurnReadState = new Map();
|
||||
const courtlistenerTurnState: CourtlistenerTurnState = {
|
||||
casesByClusterId: new Map(),
|
||||
};
|
||||
let fullText = "";
|
||||
let iterText = "";
|
||||
let iterVisibleText = "";
|
||||
let iterReasoning = "";
|
||||
let visibleTailBuffer = "";
|
||||
let citationsOpenSeen = false;
|
||||
let streamingCitationsBuffer = "";
|
||||
let streamedCitationCount = 0;
|
||||
|
||||
const emitCitationStreamSnapshot = (
|
||||
status: "started" | "partial",
|
||||
citations: unknown[],
|
||||
) => {
|
||||
if (buildCitations) return;
|
||||
write(`data: ${JSON.stringify({ type: "citations", status, citations })}\n\n`);
|
||||
};
|
||||
|
||||
const streamHiddenCitationContent = (delta: string) => {
|
||||
if (buildCitations || !delta) return;
|
||||
streamingCitationsBuffer += delta;
|
||||
const partial = parsePartialCitationObjects(streamingCitationsBuffer);
|
||||
if (partial.length <= streamedCitationCount) return;
|
||||
streamedCitationCount = partial.length;
|
||||
const citations = partial.map((c) =>
|
||||
createCitation(
|
||||
c,
|
||||
docIndex,
|
||||
courtlistenerTurnState.casesByClusterId,
|
||||
),
|
||||
);
|
||||
emitCitationStreamSnapshot("partial", citations);
|
||||
};
|
||||
|
||||
const streamVisibleContent = (delta: string) => {
|
||||
if (!delta) return;
|
||||
if (citationsOpenSeen) {
|
||||
streamHiddenCitationContent(delta);
|
||||
return;
|
||||
}
|
||||
|
||||
const combined = visibleTailBuffer + delta;
|
||||
const markerIdx = combined.indexOf(CITATIONS_OPEN_TAG);
|
||||
if (markerIdx >= 0) {
|
||||
const visible = combined.slice(0, markerIdx);
|
||||
if (visible) {
|
||||
iterVisibleText += visible;
|
||||
write(
|
||||
`data: ${JSON.stringify({ type: "content_delta", text: visible })}\n\n`,
|
||||
);
|
||||
}
|
||||
visibleTailBuffer = "";
|
||||
citationsOpenSeen = true;
|
||||
streamingCitationsBuffer = "";
|
||||
streamedCitationCount = 0;
|
||||
emitCitationStreamSnapshot("started", []);
|
||||
streamHiddenCitationContent(
|
||||
combined.slice(markerIdx + CITATIONS_OPEN_TAG.length),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const keep = Math.min(CITATIONS_OPEN_TAG.length - 1, combined.length);
|
||||
const visible = combined.slice(0, combined.length - keep);
|
||||
visibleTailBuffer = combined.slice(combined.length - keep);
|
||||
if (visible) {
|
||||
iterVisibleText += visible;
|
||||
write(
|
||||
`data: ${JSON.stringify({ type: "content_delta", text: visible })}\n\n`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const flushVisibleTail = (opts: { emit?: boolean } = {}) => {
|
||||
const emit = opts.emit ?? true;
|
||||
if (citationsOpenSeen || !visibleTailBuffer) {
|
||||
visibleTailBuffer = "";
|
||||
return;
|
||||
}
|
||||
iterVisibleText += visibleTailBuffer;
|
||||
if (emit) {
|
||||
write(
|
||||
`data: ${JSON.stringify({ type: "content_delta", text: visibleTailBuffer })}\n\n`,
|
||||
);
|
||||
}
|
||||
visibleTailBuffer = "";
|
||||
};
|
||||
|
||||
const flushText = (opts: { emit?: boolean } = {}) => {
|
||||
if (!iterText) return;
|
||||
fullText += iterText;
|
||||
flushVisibleTail(opts);
|
||||
if (iterVisibleText) {
|
||||
events.push({ type: "content", text: iterVisibleText });
|
||||
}
|
||||
iterText = "";
|
||||
iterVisibleText = "";
|
||||
visibleTailBuffer = "";
|
||||
citationsOpenSeen = false;
|
||||
streamingCitationsBuffer = "";
|
||||
streamedCitationCount = 0;
|
||||
};
|
||||
|
||||
const flushPartialTurn = (opts: { emit?: boolean } = {}) => {
|
||||
flushText(opts);
|
||||
if (iterReasoning) {
|
||||
events.push({ type: "reasoning", text: iterReasoning });
|
||||
iterReasoning = "";
|
||||
}
|
||||
};
|
||||
|
||||
const selectedModel = resolveModel(model, DEFAULT_MAIN_MODEL);
|
||||
|
||||
try {
|
||||
throwIfAborted(signal);
|
||||
await streamChatWithTools({
|
||||
model: selectedModel,
|
||||
systemPrompt,
|
||||
messages: chatMessages,
|
||||
tools: activeTools as OpenAIToolSchema[],
|
||||
maxIterations: 10,
|
||||
apiKeys,
|
||||
enableThinking: true,
|
||||
abortSignal: signal,
|
||||
callbacks: {
|
||||
onContentDelta: (delta) => {
|
||||
iterText += delta;
|
||||
streamVisibleContent(delta);
|
||||
},
|
||||
onReasoningDelta: (delta) => {
|
||||
iterReasoning += delta;
|
||||
write(
|
||||
`data: ${JSON.stringify({ type: "reasoning_delta", text: delta })}\n\n`,
|
||||
);
|
||||
},
|
||||
onReasoningBlockEnd: () => {
|
||||
if (!iterReasoning) return;
|
||||
events.push({ type: "reasoning", text: iterReasoning });
|
||||
write(`data: ${JSON.stringify({ type: "reasoning_block_end" })}\n\n`);
|
||||
iterReasoning = "";
|
||||
},
|
||||
// Fires after Claude's turn ends with stop_reason=tool_use, before
|
||||
// the tool actually runs. Flushes any buffered assistant text so
|
||||
// it's emitted in chronological order, then signals the client so
|
||||
// it can open a fresh PreResponseWrapper (shows "Working…") while
|
||||
// the tool executes — avoids the dead gap between message_stop
|
||||
// and the first tool-specific event.
|
||||
onToolCallStart: (call) => {
|
||||
flushText();
|
||||
write(
|
||||
`data: ${JSON.stringify({
|
||||
type: "tool_call_start",
|
||||
name: call.name,
|
||||
})}\n\n`,
|
||||
);
|
||||
},
|
||||
},
|
||||
runTools: async (calls) => {
|
||||
throwIfAborted(signal);
|
||||
// Emit any text the model produced before this tool turn so the
|
||||
// UI sees it before the tool results stream in.
|
||||
flushText();
|
||||
|
||||
const toolCalls: ToolCall[] = calls.map((c) => ({
|
||||
id: c.id,
|
||||
function: {
|
||||
name: c.name,
|
||||
arguments: JSON.stringify(c.input),
|
||||
},
|
||||
}));
|
||||
const {
|
||||
toolResults,
|
||||
docsRead,
|
||||
docsFound,
|
||||
docsCreated,
|
||||
docsReplicated,
|
||||
workflowsApplied,
|
||||
docsEdited,
|
||||
askInputsEvents,
|
||||
courtlistenerEvents,
|
||||
caseCitationEvents,
|
||||
mcpEvents,
|
||||
} = await runToolCalls(
|
||||
toolCalls,
|
||||
docStore,
|
||||
userId,
|
||||
db,
|
||||
write,
|
||||
workflowStore,
|
||||
tabularStore,
|
||||
docIndex,
|
||||
turnEditState,
|
||||
turnReadState,
|
||||
projectId,
|
||||
courtlistenerTurnState,
|
||||
apiKeys,
|
||||
);
|
||||
throwIfAborted(signal);
|
||||
for (const r of docsRead) {
|
||||
events.push({
|
||||
type: "doc_read",
|
||||
filename: r.filename,
|
||||
document_id: r.document_id,
|
||||
});
|
||||
}
|
||||
for (const f of docsFound) {
|
||||
events.push({
|
||||
type: "doc_find",
|
||||
filename: f.filename,
|
||||
query: f.query,
|
||||
total_matches: f.total_matches,
|
||||
});
|
||||
}
|
||||
for (const dl of docsCreated) {
|
||||
events.push({
|
||||
type: "doc_created",
|
||||
filename: dl.filename,
|
||||
download_url: dl.download_url,
|
||||
document_id: dl.document_id,
|
||||
version_id: dl.version_id,
|
||||
version_number: dl.version_number ?? null,
|
||||
});
|
||||
}
|
||||
for (const r of docsReplicated) {
|
||||
events.push({
|
||||
type: "doc_replicated",
|
||||
filename: r.filename,
|
||||
count: r.count,
|
||||
copies: r.copies,
|
||||
});
|
||||
}
|
||||
for (const wf of workflowsApplied) {
|
||||
events.push({
|
||||
type: "workflow_applied",
|
||||
workflow_id: wf.workflow_id,
|
||||
title: wf.title,
|
||||
});
|
||||
}
|
||||
for (const e of docsEdited) {
|
||||
events.push({
|
||||
type: "doc_edited",
|
||||
filename: e.filename,
|
||||
document_id: e.document_id,
|
||||
version_id: e.version_id,
|
||||
version_number: e.version_number,
|
||||
download_url: e.download_url,
|
||||
annotations: e.annotations,
|
||||
});
|
||||
}
|
||||
for (const askInputsEvent of askInputsEvents) {
|
||||
write(`data: ${JSON.stringify(askInputsEvent)}\n\n`);
|
||||
events.push(askInputsEvent);
|
||||
}
|
||||
for (const event of courtlistenerEvents) {
|
||||
events.push(event);
|
||||
}
|
||||
for (const event of mcpEvents) {
|
||||
events.push(event);
|
||||
}
|
||||
for (const event of caseCitationEvents) {
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
if (askInputsEvents.length > 0) {
|
||||
throw new AssistantStreamAskInputsPause();
|
||||
}
|
||||
|
||||
// Index alignment would break if any tool branch skips its
|
||||
// push (unhandled tool name, disabled store, guard failure).
|
||||
// Each tool_result already carries its tool_call_id, so key off
|
||||
// that directly — and fall back to an error result for any
|
||||
// tool_use that didn't produce one, so Claude's next request
|
||||
// has a tool_result for every tool_use it sent.
|
||||
const resultByCallId = new Map<string, string>();
|
||||
for (const r of toolResults) {
|
||||
const row = r as { tool_call_id: string; content?: unknown };
|
||||
resultByCallId.set(row.tool_call_id, String(row.content ?? ""));
|
||||
}
|
||||
return toolCalls.map((c) => ({
|
||||
tool_use_id: c.id,
|
||||
content:
|
||||
resultByCallId.get(c.id) ??
|
||||
JSON.stringify({
|
||||
error: `Tool '${c.function.name}' is not available.`,
|
||||
}),
|
||||
}));
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof AssistantStreamAskInputsPause) {
|
||||
// The ask_inputs event has already been emitted and persisted in `events`.
|
||||
// Stop this assistant turn here so the model does not add redundant
|
||||
// prose telling the user to answer the picker or attach documents.
|
||||
} else if (isAbortError(err)) {
|
||||
flushPartialTurn({ emit: false });
|
||||
throw new AssistantStreamAbortError(fullText, events);
|
||||
} else {
|
||||
flushPartialTurn();
|
||||
const message = safeErrorMessage(err, "Stream error");
|
||||
events.push({ type: "error", message });
|
||||
throw new AssistantStreamError(message, fullText, events);
|
||||
}
|
||||
}
|
||||
|
||||
flushText();
|
||||
|
||||
// Parse and emit citations from <CITATIONS> block
|
||||
const { citations: parsedCitations, diagnostics: citationDiagnostics } =
|
||||
parseCitationsWithDiagnostics(fullText);
|
||||
const citations = buildCitations
|
||||
? buildCitations(fullText)
|
||||
: parsedCitations.map((c) =>
|
||||
createCitation(
|
||||
c,
|
||||
docIndex,
|
||||
courtlistenerTurnState.casesByClusterId,
|
||||
),
|
||||
);
|
||||
devLog("[chat/stream] final citations", {
|
||||
hasCitationsBlock: citationDiagnostics.hasBlock,
|
||||
citationsBlockLength: citationDiagnostics.rawLength,
|
||||
parseError: citationDiagnostics.error,
|
||||
parsedCitationCount: parsedCitations.length,
|
||||
emittedCitationCount: citations.length,
|
||||
usedCustomCitationBuilder: !!buildCitations,
|
||||
});
|
||||
write(
|
||||
`data: ${JSON.stringify({ type: "citations", status: "final", citations })}\n\n`,
|
||||
);
|
||||
write("data: [DONE]\n\n");
|
||||
|
||||
return { fullText, events, citations };
|
||||
}
|
||||
1813
backend/src/lib/chat/tools/documentOps.ts
Normal file
1813
backend/src/lib/chat/tools/documentOps.ts
Normal file
File diff suppressed because it is too large
Load diff
1896
backend/src/lib/chat/tools/toolDispatcher.ts
Normal file
1896
backend/src/lib/chat/tools/toolDispatcher.ts
Normal file
File diff suppressed because it is too large
Load diff
471
backend/src/lib/chat/tools/toolSchemas.ts
Normal file
471
backend/src/lib/chat/tools/toolSchemas.ts
Normal file
|
|
@ -0,0 +1,471 @@
|
|||
export const PROJECT_EXTRA_TOOLS = [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "list_documents",
|
||||
description:
|
||||
"List all documents available in the project. Returns each document's ID, filename, and file type. Call this to discover what documents are available before deciding which ones to read.",
|
||||
parameters: { type: "object", properties: {} },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "fetch_documents",
|
||||
description:
|
||||
"Read the full text content of multiple documents in a single call. Use this instead of calling read_document repeatedly when you need to read several documents at once. In one response, fetch each document/version at most once; after it has been fetched, use the prior tool result or find_in_document for targeted checks.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
doc_ids: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description:
|
||||
"Array of document IDs to read (e.g. ['doc-0', 'doc-2'])",
|
||||
},
|
||||
},
|
||||
required: ["doc_ids"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "replicate_document",
|
||||
description:
|
||||
"Make byte-for-byte copies of an existing project document as new project documents. Use when the user wants standalone copies to edit (e.g. 'use this NDA as a template', 'give me three drafts I can adapt') without modifying the original. Pass `count` to create multiple copies in a single call rather than calling the tool repeatedly. Returns the new doc_id slugs so you can immediately call edit_document / read_document on them.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
doc_id: {
|
||||
type: "string",
|
||||
description: "ID of the source document to copy (e.g. 'doc-0').",
|
||||
},
|
||||
count: {
|
||||
type: "integer",
|
||||
description:
|
||||
"How many copies to create. Defaults to 1. Maximum 20.",
|
||||
minimum: 1,
|
||||
maximum: 20,
|
||||
},
|
||||
new_filename: {
|
||||
type: "string",
|
||||
description:
|
||||
"Optional base filename. With count > 1, copies are suffixed (e.g. 'Foo (1).docx', 'Foo (2).docx'). Extension is forced to match the source.",
|
||||
},
|
||||
},
|
||||
required: ["doc_id"],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const TABULAR_TOOLS = [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "read_table_cells",
|
||||
description:
|
||||
"Read the extracted cell content from the tabular review. Each cell contains the value extracted for a specific column from a specific document. Pass col_indices and/or row_indices (0-based) to read a subset; omit either to read all columns or all rows.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
col_indices: {
|
||||
type: "array",
|
||||
items: { type: "integer" },
|
||||
description:
|
||||
"0-based column indices to read (e.g. [0, 2]). Omit to read all columns.",
|
||||
},
|
||||
row_indices: {
|
||||
type: "array",
|
||||
items: { type: "integer" },
|
||||
description:
|
||||
"0-based document (row) indices to read (e.g. [0, 1]). Omit to read all rows.",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const WORKFLOW_TOOLS = [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "list_workflows",
|
||||
description:
|
||||
"List all workflows available to the user. Returns each workflow's ID and title. Call this when the user asks to run a workflow, apply a template, or you need to discover what workflows exist.",
|
||||
parameters: { type: "object", properties: {} },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "read_workflow",
|
||||
description:
|
||||
"Read the full instructions (prompt) of a workflow by its ID. Call this after list_workflows to load a specific workflow's prompt, then follow those instructions.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
workflow_id: {
|
||||
type: "string",
|
||||
description: "The workflow ID to read",
|
||||
},
|
||||
},
|
||||
required: ["workflow_id"],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const TOOLS = [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "ask_inputs",
|
||||
description:
|
||||
"Ask the user for one or more decisions, clarifications, or document uploads before continuing. Use this when guessing would materially affect the answer or when required documents have not been attached. Put all needed questions and document requests in one items array. After calling ask_inputs, do not continue the substantive task until the user responds in a later message.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
items: {
|
||||
type: "array",
|
||||
minItems: 1,
|
||||
maxItems: 12,
|
||||
description:
|
||||
"The list of user inputs needed before continuing. Use choice items for decisions/clarifications and documents items for required uploads.",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: {
|
||||
type: "string",
|
||||
description:
|
||||
"Stable short ID for this input, unique within this tool call.",
|
||||
},
|
||||
kind: {
|
||||
type: "string",
|
||||
enum: ["choice", "documents"],
|
||||
},
|
||||
question: {
|
||||
type: "string",
|
||||
description:
|
||||
"For choice items only: the concise question to show to the user.",
|
||||
},
|
||||
options: {
|
||||
type: "array",
|
||||
description:
|
||||
"For choice items only: selectable choices to show. Each choice has a single user-facing value, which is also sent back if selected.",
|
||||
minItems: 1,
|
||||
maxItems: 8,
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
value: {
|
||||
type: "string",
|
||||
description: "The user-facing choice text.",
|
||||
},
|
||||
},
|
||||
required: ["value"],
|
||||
},
|
||||
},
|
||||
allow_other: {
|
||||
type: "boolean",
|
||||
description:
|
||||
"For choice items only: whether to show an Other option with a text field. Defaults to true.",
|
||||
},
|
||||
other_label: {
|
||||
type: "string",
|
||||
description:
|
||||
"For choice items only: label for the free-text option. Defaults to Other.",
|
||||
},
|
||||
document_types: {
|
||||
type: "array",
|
||||
description:
|
||||
"For documents items only: readable labels for the types of documents you need the user to attach.",
|
||||
minItems: 1,
|
||||
maxItems: 8,
|
||||
items: {
|
||||
type: "string",
|
||||
},
|
||||
},
|
||||
response_prefix: {
|
||||
type: "string",
|
||||
description:
|
||||
"Optional prefix the UI should include when sending this response back as the next message.",
|
||||
},
|
||||
},
|
||||
required: ["id", "kind"],
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ["items"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "read_document",
|
||||
description:
|
||||
"Read the full text content of a document attached by the user. Always call this before answering questions about, summarising, citing from, or editing a document, but call it at most once per document/version in a single response. After this returns, use the prior tool result or find_in_document for targeted checks instead of reading the same document/version again.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
doc_id: {
|
||||
type: "string",
|
||||
description: "The document ID to read (e.g. 'doc-0', 'doc-1')",
|
||||
},
|
||||
},
|
||||
required: ["doc_id"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "find_in_document",
|
||||
description:
|
||||
"Search for specific strings inside a document — a Ctrl+F equivalent. Returns each match with surrounding context so you can locate and quote the exact text without reading the whole document. Matching is case-insensitive and whitespace-tolerant. Use this for targeted lookups (e.g. finding a clause title, party name, or a specific phrase) rather than reading the whole document.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
doc_id: {
|
||||
type: "string",
|
||||
description: "The document ID to search (e.g. 'doc-0').",
|
||||
},
|
||||
query: {
|
||||
type: "string",
|
||||
description:
|
||||
"The string to search for. Matching is case-insensitive and collapses runs of whitespace, so 'Section 4.2' matches 'section 4.2'.",
|
||||
},
|
||||
max_results: {
|
||||
type: "integer",
|
||||
description:
|
||||
"Maximum number of matches to return (default 20). Use a smaller value for common terms.",
|
||||
},
|
||||
context_chars: {
|
||||
type: "integer",
|
||||
description:
|
||||
"Characters of surrounding context to include on each side of a match (default 80).",
|
||||
},
|
||||
},
|
||||
required: ["doc_id", "query"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "generate_docx",
|
||||
description:
|
||||
"Generate a Word (.docx) document from structured content. Use this when the user asks you to draft, create, or produce a legal document. Returns a download URL for the generated file.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
title: {
|
||||
type: "string",
|
||||
description: "Document title (used as filename and heading)",
|
||||
},
|
||||
landscape: {
|
||||
type: "boolean",
|
||||
description:
|
||||
"Set to true for landscape page orientation. Default is portrait.",
|
||||
},
|
||||
sections: {
|
||||
type: "array",
|
||||
description:
|
||||
"List of document sections. Each section may contain a heading, prose content, or a table.",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
heading: {
|
||||
type: "string",
|
||||
description: "Optional section heading",
|
||||
},
|
||||
level: {
|
||||
type: "integer",
|
||||
description: "Heading level: 1, 2, or 3",
|
||||
},
|
||||
content: {
|
||||
type: "string",
|
||||
description:
|
||||
"Prose text content (paragraphs separated by double newlines)",
|
||||
},
|
||||
pageBreak: {
|
||||
type: "boolean",
|
||||
description:
|
||||
"Set to true to start this section on a new page. Use for contract signature pages.",
|
||||
},
|
||||
table: {
|
||||
type: "object",
|
||||
description: "Optional table to render in this section",
|
||||
properties: {
|
||||
headers: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "Column header labels",
|
||||
},
|
||||
rows: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
},
|
||||
description:
|
||||
"Array of rows, each row is an array of cell strings matching the headers order",
|
||||
},
|
||||
},
|
||||
required: ["headers", "rows"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ["title", "sections"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "generate_excel",
|
||||
description:
|
||||
"Generate an Excel (.xlsx) workbook from structured sheet data. Use this when the user asks for a spreadsheet, tracker, matrix, checklist, schedule, or Excel file. Returns a download URL for the generated file.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
title: {
|
||||
type: "string",
|
||||
description: "Workbook title, used as the filename.",
|
||||
},
|
||||
sheets: {
|
||||
type: "array",
|
||||
description:
|
||||
"Workbook sheets. Each sheet has a name, columns, and rows. Row values should follow the columns order.",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: {
|
||||
type: "string",
|
||||
description: "Sheet tab name. Keep it short.",
|
||||
},
|
||||
columns: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "Column header labels.",
|
||||
},
|
||||
rows: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
},
|
||||
description:
|
||||
"Array of rows, each row an array of cell strings matching the columns order.",
|
||||
},
|
||||
},
|
||||
required: ["name", "columns", "rows"],
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ["title", "sheets"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "generate_ppt",
|
||||
description:
|
||||
"Generate a PowerPoint (.pptx) presentation from structured slides. Use this when the user asks for slides, a deck, presentation, or PowerPoint file. Returns a download URL for the generated file.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
title: {
|
||||
type: "string",
|
||||
description: "Presentation title, used as the filename.",
|
||||
},
|
||||
slides: {
|
||||
type: "array",
|
||||
description:
|
||||
"Slides in order. Each slide may have a title, bullets, and optional speaker notes.",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
title: {
|
||||
type: "string",
|
||||
description: "Slide title.",
|
||||
},
|
||||
bullets: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description:
|
||||
"Main bullet points for the slide. Keep each bullet concise.",
|
||||
},
|
||||
notes: {
|
||||
type: "string",
|
||||
description:
|
||||
"Optional speaker notes. Included as text on a notes slide placeholder is not supported; use only for generation context.",
|
||||
},
|
||||
},
|
||||
required: ["title", "bullets"],
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ["title", "slides"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "edit_document",
|
||||
description:
|
||||
"Propose edits to a user-attached .docx as tracked changes. Each edit is a precise, minimal substitution of specific words/characters, NOT a whole-line or paragraph replacement. Use read_document first unless this same document/version has already been read in the current response. Anchor each edit with short before/after context so it can be located unambiguously. Returns per-edit annotations the UI will render as Accept/Reject cards and a download link to the edited document.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
doc_id: {
|
||||
type: "string",
|
||||
description: "Document slug (e.g. 'doc-0').",
|
||||
},
|
||||
edits: {
|
||||
type: "array",
|
||||
description: "List of precise substitutions.",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
find: {
|
||||
type: "string",
|
||||
description:
|
||||
"Exact substring to replace (keep it as short as possible — ideally just the words/chars being changed).",
|
||||
},
|
||||
replace: {
|
||||
type: "string",
|
||||
description:
|
||||
"Replacement text. Empty string = pure deletion.",
|
||||
},
|
||||
context_before: {
|
||||
type: "string",
|
||||
description:
|
||||
"~40 chars immediately preceding `find`, used to disambiguate.",
|
||||
},
|
||||
context_after: {
|
||||
type: "string",
|
||||
description: "~40 chars immediately following `find`.",
|
||||
},
|
||||
reason: {
|
||||
type: "string",
|
||||
description:
|
||||
"Short explanation shown to the user on the card.",
|
||||
},
|
||||
},
|
||||
required: ["find", "replace", "context_before", "context_after"],
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ["doc_id", "edits"],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
153
backend/src/lib/chat/types.ts
Normal file
153
backend/src/lib/chat/types.ts
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
import path from "path";
|
||||
|
||||
export const STANDARD_FONT_DATA_URL = (() => {
|
||||
try {
|
||||
const pkgPath = require.resolve("pdfjs-dist/package.json");
|
||||
return path.join(path.dirname(pkgPath), "standard_fonts") + path.sep;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
})();
|
||||
|
||||
const isDev = process.env.NODE_ENV !== "production";
|
||||
export const devLog = (...args: Parameters<typeof console.log>) => {
|
||||
if (isDev) console.log(...args);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type DocStore = Map<
|
||||
string,
|
||||
{ storage_path: string; file_type: string; filename: string }
|
||||
>;
|
||||
|
||||
export type WorkflowStore = Map<string, { title: string; prompt_md: string }>;
|
||||
|
||||
export type DocIndex = Record<
|
||||
string,
|
||||
{
|
||||
document_id: string;
|
||||
filename: string;
|
||||
version_id?: string | null;
|
||||
version_number?: number | null;
|
||||
}
|
||||
>;
|
||||
|
||||
export type TabularCellStore = {
|
||||
columns: { index: number; name: string }[];
|
||||
documents: { id: string; filename: string }[];
|
||||
/** key: `${colIndex}:${docId}` */
|
||||
cells: Map<
|
||||
string,
|
||||
{ summary: string; flag?: string; reasoning?: string } | null
|
||||
>;
|
||||
};
|
||||
|
||||
export type ToolCall = {
|
||||
id: string;
|
||||
function: { name: string; arguments: string };
|
||||
};
|
||||
|
||||
export type ChatMessage = {
|
||||
role: string;
|
||||
content: string | null;
|
||||
files?: { filename: string; document_id?: string }[];
|
||||
workflow?: { id: string; title: string };
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Doc resolution helpers (used by citations + documentOps)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function resolveDoc(rawId: string, docIndex: DocIndex) {
|
||||
return docIndex[rawId];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve whatever identifier the model passed (`doc-N` slug, filename, or
|
||||
* document UUID) back to a chat-local doc label.
|
||||
*/
|
||||
export function resolveDocLabel(
|
||||
rawId: string,
|
||||
docStore: DocStore,
|
||||
docIndex?: DocIndex,
|
||||
): string | null {
|
||||
if (docStore.has(rawId)) return rawId;
|
||||
for (const [label, info] of docStore.entries()) {
|
||||
if (info.filename === rawId) return label;
|
||||
}
|
||||
if (docIndex) {
|
||||
for (const [label, info] of Object.entries(docIndex)) {
|
||||
if (info.document_id === rawId) return label;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Event / annotation types (shared between toolDispatcher and streaming)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type AskInputOption = {
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type AskInputItem =
|
||||
| {
|
||||
id: string;
|
||||
kind: "choice";
|
||||
question: string;
|
||||
options: AskInputOption[];
|
||||
allow_other: boolean;
|
||||
other_label: string;
|
||||
response_prefix?: string;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
kind: "documents";
|
||||
document_types: string[];
|
||||
response_prefix?: string;
|
||||
};
|
||||
|
||||
export type AskInputsEvent = {
|
||||
type: "ask_inputs";
|
||||
items: AskInputItem[];
|
||||
};
|
||||
|
||||
export type AskInputResponseItem =
|
||||
| {
|
||||
id: string;
|
||||
kind: "choice";
|
||||
question: string;
|
||||
answer?: string;
|
||||
skipped?: boolean;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
kind: "documents";
|
||||
filenames: string[];
|
||||
skipped?: boolean;
|
||||
};
|
||||
|
||||
export type AskInputsResponseRequest = {
|
||||
responses: AskInputResponseItem[];
|
||||
};
|
||||
|
||||
export type EditAnnotation = {
|
||||
kind: "edit";
|
||||
edit_id: string;
|
||||
document_id: string;
|
||||
version_id: string;
|
||||
version_number?: number | null;
|
||||
change_id: string;
|
||||
del_w_id?: string;
|
||||
ins_w_id?: string;
|
||||
deleted_text: string;
|
||||
inserted_text: string;
|
||||
context_before: string;
|
||||
context_after: string;
|
||||
reason?: string;
|
||||
status: "pending" | "accepted" | "rejected";
|
||||
};
|
||||
File diff suppressed because it is too large
Load diff
60
backend/src/lib/documentTypes.ts
Normal file
60
backend/src/lib/documentTypes.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
export const ALLOWED_DOCUMENT_TYPES = new Set([
|
||||
"pdf",
|
||||
"docx",
|
||||
"doc",
|
||||
"xlsx",
|
||||
"xlsm",
|
||||
"xls",
|
||||
"pptx",
|
||||
"ppt",
|
||||
]);
|
||||
|
||||
export const ALLOWED_DOCUMENT_TYPES_LABEL =
|
||||
"pdf, docx, doc, xlsx, xlsm, xls, pptx, ppt";
|
||||
|
||||
const WORD_TYPES = new Set(["docx", "doc"]);
|
||||
const SPREADSHEET_TYPES = new Set(["xlsx", "xlsm", "xls"]);
|
||||
const PRESENTATION_TYPES = new Set(["pptx", "ppt"]);
|
||||
|
||||
export function isWordDocumentType(fileType: string | null | undefined) {
|
||||
return WORD_TYPES.has((fileType ?? "").toLowerCase());
|
||||
}
|
||||
|
||||
export function isSpreadsheetDocumentType(fileType: string | null | undefined) {
|
||||
return SPREADSHEET_TYPES.has((fileType ?? "").toLowerCase());
|
||||
}
|
||||
|
||||
export function isPresentationDocumentType(fileType: string | null | undefined) {
|
||||
return PRESENTATION_TYPES.has((fileType ?? "").toLowerCase());
|
||||
}
|
||||
|
||||
export function shouldConvertToPdf(fileType: string | null | undefined) {
|
||||
const normalized = (fileType ?? "").toLowerCase();
|
||||
// Spreadsheets are intentionally excluded: they are rendered natively as a
|
||||
// grid in the frontend (Fortune-sheet) from the raw file bytes rather than a
|
||||
// PDF rendition, which clipped wide/large sheets.
|
||||
return (
|
||||
isWordDocumentType(normalized) || isPresentationDocumentType(normalized)
|
||||
);
|
||||
}
|
||||
|
||||
export function contentTypeForDocumentType(fileType: string | null | undefined) {
|
||||
switch ((fileType ?? "").toLowerCase()) {
|
||||
case "pdf":
|
||||
return "application/pdf";
|
||||
case "docx":
|
||||
return "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
||||
case "xlsx":
|
||||
return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
||||
case "xlsm":
|
||||
return "application/vnd.ms-excel.sheet.macroEnabled.12";
|
||||
case "xls":
|
||||
return "application/vnd.ms-excel";
|
||||
case "pptx":
|
||||
return "application/vnd.openxmlformats-officedocument.presentationml.presentation";
|
||||
case "ppt":
|
||||
return "application/vnd.ms-powerpoint";
|
||||
default:
|
||||
return "application/octet-stream";
|
||||
}
|
||||
}
|
||||
|
|
@ -28,7 +28,7 @@ export type GeminiFunctionDeclaration = {
|
|||
|
||||
export function toGeminiTools(tools: OpenAIToolSchema[]): GeminiFunctionDeclaration[] {
|
||||
return tools.map((t) => {
|
||||
const params = normalizeSchema(t.function.parameters);
|
||||
const params = normalizeGeminiSchema(t.function.parameters);
|
||||
// Gemini rejects `{ type: "object", properties: {} }` with no fields
|
||||
// present; omit the parameters key entirely when empty.
|
||||
const hasProps =
|
||||
|
|
@ -72,3 +72,121 @@ function normalizeSchema(schema: unknown): Record<string, unknown> {
|
|||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const GEMINI_SCHEMA_KEYS = new Set([
|
||||
"type",
|
||||
"description",
|
||||
"enum",
|
||||
"format",
|
||||
"items",
|
||||
"nullable",
|
||||
"properties",
|
||||
"required",
|
||||
]);
|
||||
|
||||
function normalizeGeminiSchema(schema: unknown): Record<string, unknown> {
|
||||
return normalizeGeminiSchemaNode(schema, schema, new Set());
|
||||
}
|
||||
|
||||
function normalizeGeminiSchemaNode(
|
||||
schema: unknown,
|
||||
root: unknown,
|
||||
seenRefs: Set<string>,
|
||||
): Record<string, unknown> {
|
||||
if (!schema || typeof schema !== "object" || Array.isArray(schema)) {
|
||||
return { type: "object", properties: {} };
|
||||
}
|
||||
|
||||
const s = schema as Record<string, unknown>;
|
||||
const ref = typeof s.$ref === "string" ? s.$ref : null;
|
||||
if (ref) {
|
||||
const resolved = resolveLocalJsonRef(root, ref);
|
||||
if (resolved && !seenRefs.has(ref)) {
|
||||
return normalizeGeminiSchemaNode(
|
||||
resolved,
|
||||
root,
|
||||
new Set([...seenRefs, ref]),
|
||||
);
|
||||
}
|
||||
return { type: "string" };
|
||||
}
|
||||
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(s)) {
|
||||
if (!GEMINI_SCHEMA_KEYS.has(key)) continue;
|
||||
if (key.startsWith("$") || key.startsWith("x-")) continue;
|
||||
out[key] = value;
|
||||
}
|
||||
|
||||
const type = normalizeGeminiType(out.type);
|
||||
out.type = type;
|
||||
|
||||
if (type === "object") {
|
||||
const props =
|
||||
s.properties && typeof s.properties === "object" && !Array.isArray(s.properties)
|
||||
? (s.properties as Record<string, unknown>)
|
||||
: {};
|
||||
const normProps: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(props)) {
|
||||
normProps[key] = normalizeGeminiSchemaNode(value, root, seenRefs);
|
||||
}
|
||||
out.properties = normProps;
|
||||
if (Array.isArray(s.required)) {
|
||||
out.required = s.required.filter(
|
||||
(name): name is string =>
|
||||
typeof name === "string" && Object.prototype.hasOwnProperty.call(normProps, name),
|
||||
);
|
||||
} else {
|
||||
delete out.required;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
delete out.properties;
|
||||
delete out.required;
|
||||
|
||||
if (type === "array") {
|
||||
out.items = normalizeGeminiSchemaNode(s.items, root, seenRefs);
|
||||
} else {
|
||||
delete out.items;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function normalizeGeminiType(value: unknown): string {
|
||||
if (Array.isArray(value)) {
|
||||
const nonNull = value.find(
|
||||
(item): item is string => typeof item === "string" && item !== "null",
|
||||
);
|
||||
return normalizeGeminiType(nonNull);
|
||||
}
|
||||
if (typeof value !== "string" || !value) return "object";
|
||||
if (value === "integer") return "number";
|
||||
if (
|
||||
value === "object" ||
|
||||
value === "array" ||
|
||||
value === "string" ||
|
||||
value === "number" ||
|
||||
value === "boolean"
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
return "string";
|
||||
}
|
||||
|
||||
function resolveLocalJsonRef(root: unknown, ref: string): unknown {
|
||||
if (!ref.startsWith("#/")) return null;
|
||||
const parts = ref
|
||||
.slice(2)
|
||||
.split("/")
|
||||
.map((part) => part.replace(/~1/g, "/").replace(/~0/g, "~"));
|
||||
let cursor = root;
|
||||
for (const part of parts) {
|
||||
if (!cursor || typeof cursor !== "object" || Array.isArray(cursor)) {
|
||||
return null;
|
||||
}
|
||||
cursor = (cursor as Record<string, unknown>)[part];
|
||||
}
|
||||
return cursor;
|
||||
}
|
||||
|
|
|
|||
53
backend/src/lib/officeText.ts
Normal file
53
backend/src/lib/officeText.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import JSZip from "jszip";
|
||||
|
||||
function decodeXml(text: string) {
|
||||
return text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&#(\d+);/g, (_, code) => String.fromCharCode(Number(code)))
|
||||
.replace(/&#x([0-9a-f]+);/gi, (_, code) =>
|
||||
String.fromCharCode(Number.parseInt(code, 16)),
|
||||
);
|
||||
}
|
||||
|
||||
function extractTagText(xml: string, tagName: string) {
|
||||
const parts: string[] = [];
|
||||
const re = new RegExp(
|
||||
`<${tagName}\\b[^>]*>([\\s\\S]*?)<\\/${tagName}>`,
|
||||
"gi",
|
||||
);
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = re.exec(xml))) parts.push(decodeXml(match[1]));
|
||||
return parts;
|
||||
}
|
||||
|
||||
function naturalSort(a: string, b: string) {
|
||||
return a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" });
|
||||
}
|
||||
|
||||
async function readZipText(zip: JSZip, path: string) {
|
||||
const entry = zip.file(path);
|
||||
return entry ? entry.async("text") : null;
|
||||
}
|
||||
|
||||
export async function extractPresentationText(buffer: Buffer) {
|
||||
const zip = await JSZip.loadAsync(buffer);
|
||||
const slidePaths = Object.keys(zip.files)
|
||||
.filter((name) => /^ppt\/slides\/slide\d+\.xml$/i.test(name))
|
||||
.sort(naturalSort);
|
||||
|
||||
const slides: string[] = [];
|
||||
for (let index = 0; index < slidePaths.length; index++) {
|
||||
const xml = await readZipText(zip, slidePaths[index]);
|
||||
if (!xml) continue;
|
||||
const text = extractTagText(xml, "a:t")
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
if (text) slides.push(`## Slide ${index + 1}\n\n${text}`);
|
||||
}
|
||||
return slides.join("\n\n").trim();
|
||||
}
|
||||
100
backend/src/lib/spreadsheet.ts
Normal file
100
backend/src/lib/spreadsheet.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import * as XLSX from "xlsx";
|
||||
|
||||
/**
|
||||
* Spreadsheet parsing for the LLM read path.
|
||||
*
|
||||
* Replaces the old regex-over-OOXML extractor (`extractSpreadsheetText`) with a
|
||||
* real reader. SheetJS handles `.xlsx`, `.xlsm`, and legacy `.xls` uniformly and
|
||||
* exposes `cell.w` — the Excel-formatted display string — so dates and currency
|
||||
* reach the model the way a human sees them (`3/1/26`, `$1,200`) rather than as
|
||||
* raw serial numbers. Cached formula results are used (we never show formulas).
|
||||
*
|
||||
* The output is a compact, cell-addressed markdown table per sheet: a header row
|
||||
* of column letters plus a leftmost row-number column. That lets the model name
|
||||
* any cell as `Sheet!<col><row>` (e.g. `Q3 Budget!B7`) for cell-level citations,
|
||||
* with none of the old `Row N:` / `|`-separator noise.
|
||||
*/
|
||||
|
||||
/** Formatted display text for a cell (`w`), falling back to the raw value. */
|
||||
function cellDisplayText(cell: XLSX.CellObject | undefined): string {
|
||||
if (!cell) return "";
|
||||
if (typeof cell.w === "string" && cell.w.length > 0) return cell.w;
|
||||
if (cell.v == null) return "";
|
||||
return String(cell.v);
|
||||
}
|
||||
|
||||
/** Escape a cell value so it can't break the markdown table layout. */
|
||||
function sanitizeCellText(value: string): string {
|
||||
return value.replace(/\r?\n/g, " ").replace(/\|/g, "\\|").trim();
|
||||
}
|
||||
|
||||
function renderSheet(sheetName: string, ws: XLSX.WorkSheet): string | null {
|
||||
const ref = ws["!ref"];
|
||||
if (!ref) return null;
|
||||
const range = XLSX.utils.decode_range(ref);
|
||||
|
||||
// Build a trimmed grid: capture formatted text for every cell in the used
|
||||
// range, then drop trailing empty columns and fully empty rows so we don't
|
||||
// emit oceans of blank cells.
|
||||
const rows: { rowNumber: number; cells: string[] }[] = [];
|
||||
let lastNonEmptyCol = -1;
|
||||
|
||||
for (let r = range.s.r; r <= range.e.r; r++) {
|
||||
const cells: string[] = [];
|
||||
let rowHasContent = false;
|
||||
for (let c = range.s.c; c <= range.e.c; c++) {
|
||||
const addr = XLSX.utils.encode_cell({ r, c });
|
||||
const text = sanitizeCellText(cellDisplayText(ws[addr]));
|
||||
cells[c - range.s.c] = text;
|
||||
if (text) {
|
||||
rowHasContent = true;
|
||||
if (c - range.s.c > lastNonEmptyCol) lastNonEmptyCol = c - range.s.c;
|
||||
}
|
||||
}
|
||||
if (rowHasContent) rows.push({ rowNumber: r + 1, cells });
|
||||
}
|
||||
|
||||
if (rows.length === 0 || lastNonEmptyCol < 0) return null;
|
||||
|
||||
// Column-letter header, e.g. ["A", "B", "C"] for the used columns.
|
||||
const colLetters: string[] = [];
|
||||
for (let c = 0; c <= lastNonEmptyCol; c++) {
|
||||
colLetters.push(XLSX.utils.encode_col(range.s.c + c));
|
||||
}
|
||||
|
||||
const headerRow = `| Row | ${colLetters.join(" | ")} |`;
|
||||
const separator = `| --- | ${colLetters.map(() => "---").join(" | ")} |`;
|
||||
const bodyRows = rows.map(({ rowNumber, cells }) => {
|
||||
const padded: string[] = [];
|
||||
for (let c = 0; c <= lastNonEmptyCol; c++) padded.push(cells[c] ?? "");
|
||||
return `| ${rowNumber} | ${padded.join(" | ")} |`;
|
||||
});
|
||||
|
||||
const lines = [`## Sheet: ${sheetName}`, "", headerRow, separator, ...bodyRows];
|
||||
|
||||
// Note merged ranges once so the model understands spanned headers/labels.
|
||||
const merges = ws["!merges"];
|
||||
if (merges && merges.length > 0) {
|
||||
const encoded = merges.map((m) => XLSX.utils.encode_range(m));
|
||||
lines.push("", `Merged ranges: ${encoded.join(", ")}`);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a spreadsheet as cell-addressed markdown for the LLM. Handles
|
||||
* `.xlsx`, `.xlsm`, and legacy `.xls` (SheetJS reads all three), so callers no
|
||||
* longer need the LibreOffice→PDF→text detour for spreadsheets.
|
||||
*/
|
||||
export function spreadsheetToLLMText(buffer: Buffer): string {
|
||||
const wb = XLSX.read(buffer, { type: "buffer" });
|
||||
const sheets: string[] = [];
|
||||
for (const sheetName of wb.SheetNames) {
|
||||
const ws = wb.Sheets[sheetName];
|
||||
if (!ws) continue;
|
||||
const rendered = renderSheet(sheetName, ws);
|
||||
if (rendered) sheets.push(rendered);
|
||||
}
|
||||
return sheets.join("\n\n").trim();
|
||||
}
|
||||
1596
backend/src/lib/systemWorkflows.ts
Normal file
1596
backend/src/lib/systemWorkflows.ts
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -321,6 +321,10 @@ export async function deleteUserAccountData(
|
|||
db.from("chats").delete().eq("user_id", userId),
|
||||
db.from("project_subfolders").delete().eq("user_id", userId),
|
||||
db.from("hidden_workflows").delete().eq("user_id", userId),
|
||||
db
|
||||
.from("workflow_open_source_submissions")
|
||||
.delete()
|
||||
.eq("submitted_by_user_id", userId),
|
||||
db.from("workflow_shares").delete().eq("shared_by_user_id", userId),
|
||||
userEmail
|
||||
? db
|
||||
|
|
|
|||
|
|
@ -171,6 +171,7 @@ export async function buildUserAccountExport(
|
|||
projects,
|
||||
standaloneDocuments,
|
||||
workflows,
|
||||
workflowOpenSourceSubmissions,
|
||||
hiddenWorkflows,
|
||||
workflowSharesByUser,
|
||||
workflowSharesWithUser,
|
||||
|
|
@ -194,6 +195,11 @@ export async function buildUserAccountExport(
|
|||
selectAll(db, "workflows", (query) =>
|
||||
query.eq("user_id", userId).order("created_at", { ascending: true }),
|
||||
),
|
||||
selectAll(db, "workflow_open_source_submissions", (query) =>
|
||||
query
|
||||
.eq("submitted_by_user_id", userId)
|
||||
.order("submitted_at", { ascending: true }),
|
||||
),
|
||||
selectAll(db, "hidden_workflows", (query) =>
|
||||
query.eq("user_id", userId).order("created_at", { ascending: true }),
|
||||
),
|
||||
|
|
@ -263,6 +269,7 @@ export async function buildUserAccountExport(
|
|||
document_versions: versions,
|
||||
document_edits: edits,
|
||||
workflows,
|
||||
workflow_open_source_submissions: workflowOpenSourceSubmissions,
|
||||
hidden_workflows: hiddenWorkflows,
|
||||
workflow_shares_by_user: workflowSharesByUser,
|
||||
workflow_shares_with_user: workflowSharesWithUser,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { Request, Response, NextFunction } from "express";
|
||||
import { createClient, type SupabaseClient } from "@supabase/supabase-js";
|
||||
import { syncProfileEmail } from "../lib/userLookup";
|
||||
|
||||
const isDev = process.env.NODE_ENV !== "production";
|
||||
const devLog = (...args: Parameters<typeof console.log>) => {
|
||||
|
|
@ -118,6 +119,19 @@ export async function requireAuth(
|
|||
res.locals.userId = data.user.id;
|
||||
res.locals.userEmail = data.user.email?.toLowerCase() ?? "";
|
||||
res.locals.token = token;
|
||||
const syncError = await syncProfileEmail(
|
||||
admin,
|
||||
data.user.id,
|
||||
data.user.email,
|
||||
);
|
||||
if (syncError) {
|
||||
devLog("[auth/profile-email] sync failed", {
|
||||
method: req.method,
|
||||
path: req.originalUrl,
|
||||
userId: data.user.id,
|
||||
error: syncError.message,
|
||||
});
|
||||
}
|
||||
if (!(await enforceLoginMfaIfEnabled(req, res, admin, token))) {
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,14 +6,17 @@ import {
|
|||
buildMessages,
|
||||
enrichWithPriorEvents,
|
||||
buildWorkflowStore,
|
||||
appendAskInputsResponseToLastAssistantMessage,
|
||||
appendAssistantEventsToLastAssistantMessage,
|
||||
AssistantStreamError,
|
||||
buildCancelledAssistantMessage,
|
||||
extractAnnotations,
|
||||
extractCitations,
|
||||
isAbortError,
|
||||
runLLMStream,
|
||||
stripTransientAssistantEvents,
|
||||
parseAskInputsResponsePayload,
|
||||
type ChatMessage,
|
||||
} from "../lib/chatTools";
|
||||
} from "../lib/chat";
|
||||
import { completeText } from "../lib/llm";
|
||||
import {
|
||||
getUserModelSettings,
|
||||
|
|
@ -225,8 +228,6 @@ chatRouter.get("/:chatId", requireAuth, async (req, res) => {
|
|||
// produced the edit (always "pending"). If the user later accepts or rejects,
|
||||
// `document_edits.status` is updated but the stored event is not. On chat load
|
||||
// we merge the current DB status in so EditCards render with the real state.
|
||||
// Legacy rows may also have duplicate edit_data in top-level annotations, so
|
||||
// keep patching that path until old data no longer matters.
|
||||
async function hydrateEditStatuses(
|
||||
messages: Record<string, unknown>[],
|
||||
db: ReturnType<typeof createServerSupabase>,
|
||||
|
|
@ -242,7 +243,6 @@ async function hydrateEditStatuses(
|
|||
}
|
||||
};
|
||||
for (const m of messages) {
|
||||
collectFromAnnList(m.annotations);
|
||||
const content = m.content;
|
||||
if (Array.isArray(content)) {
|
||||
for (const ev of content as Record<string, unknown>[]) {
|
||||
|
|
@ -312,7 +312,6 @@ async function hydrateEditStatuses(
|
|||
};
|
||||
return messages.map((m) => {
|
||||
const next: Record<string, unknown> = { ...m };
|
||||
next.annotations = patchAnnList(m.annotations);
|
||||
if (Array.isArray(m.content)) {
|
||||
next.content = (m.content as Record<string, unknown>[]).map(
|
||||
(ev) => {
|
||||
|
|
@ -439,6 +438,9 @@ chatRouter.post("/", requireAuth, async (req, res) => {
|
|||
if (!parsedModel.ok) {
|
||||
return void res.status(400).json({ detail: parsedModel.detail });
|
||||
}
|
||||
const askInputsResponse = parseAskInputsResponsePayload(
|
||||
body.ask_inputs_response,
|
||||
);
|
||||
|
||||
const messages = parsedMessages.messages;
|
||||
const chat_id = parsedChatId.chatId;
|
||||
|
|
@ -509,7 +511,13 @@ chatRouter.post("/", requireAuth, async (req, res) => {
|
|||
devLog("[chat/stream] resolved chatId", chatId);
|
||||
|
||||
const lastUser = [...messages].reverse().find((m) => m.role === "user");
|
||||
if (lastUser) {
|
||||
if (askInputsResponse) {
|
||||
await appendAskInputsResponseToLastAssistantMessage(
|
||||
db,
|
||||
chatId,
|
||||
askInputsResponse,
|
||||
);
|
||||
} else if (lastUser) {
|
||||
await db.from("chat_messages").insert({
|
||||
chat_id: chatId,
|
||||
role: "user",
|
||||
|
|
@ -571,7 +579,7 @@ chatRouter.post("/", requireAuth, async (req, res) => {
|
|||
try {
|
||||
write(`data: ${JSON.stringify({ type: "chat_id", chatId })}\n\n`);
|
||||
|
||||
const { fullText, events, annotations } = await runLLMStream({
|
||||
const { fullText, events, citations } = await runLLMStream({
|
||||
apiMessages,
|
||||
docStore,
|
||||
docIndex,
|
||||
|
|
@ -592,12 +600,21 @@ chatRouter.post("/", requireAuth, async (req, res) => {
|
|||
});
|
||||
|
||||
const persistedEvents = stripTransientAssistantEvents(events);
|
||||
await db.from("chat_messages").insert({
|
||||
chat_id: chatId,
|
||||
role: "assistant",
|
||||
content: persistedEvents.length ? persistedEvents : null,
|
||||
annotations: annotations.length ? annotations : null,
|
||||
});
|
||||
if (askInputsResponse) {
|
||||
await appendAssistantEventsToLastAssistantMessage(
|
||||
db,
|
||||
chatId,
|
||||
persistedEvents,
|
||||
citations,
|
||||
);
|
||||
} else {
|
||||
await db.from("chat_messages").insert({
|
||||
chat_id: chatId,
|
||||
role: "assistant",
|
||||
content: persistedEvents.length ? persistedEvents : null,
|
||||
citations: citations.length ? citations : null,
|
||||
});
|
||||
}
|
||||
|
||||
if (!chatTitle && lastUser?.content) {
|
||||
await db
|
||||
|
|
@ -612,17 +629,31 @@ chatRouter.post("/", requireAuth, async (req, res) => {
|
|||
const partial = buildCancelledAssistantMessage({
|
||||
fullText: err.fullText,
|
||||
events: err.events,
|
||||
buildAnnotations: (fullText, events) =>
|
||||
extractAnnotations(fullText, docIndex, events),
|
||||
});
|
||||
const { error: saveError } = await db.from("chat_messages").insert({
|
||||
chat_id: chatId,
|
||||
role: "assistant",
|
||||
content: partial.events.length ? partial.events : null,
|
||||
annotations: partial.annotations.length
|
||||
? partial.annotations
|
||||
: null,
|
||||
buildCitations: (fullText, events) =>
|
||||
extractCitations(fullText, docIndex, events),
|
||||
});
|
||||
const saveError = askInputsResponse
|
||||
? null
|
||||
: (
|
||||
await db.from("chat_messages").insert({
|
||||
chat_id: chatId,
|
||||
role: "assistant",
|
||||
content: partial.events.length
|
||||
? partial.events
|
||||
: null,
|
||||
citations: partial.citations.length
|
||||
? partial.citations
|
||||
: null,
|
||||
})
|
||||
).error;
|
||||
if (askInputsResponse) {
|
||||
await appendAssistantEventsToLastAssistantMessage(
|
||||
db,
|
||||
chatId,
|
||||
partial.events,
|
||||
partial.citations,
|
||||
);
|
||||
}
|
||||
if (saveError) {
|
||||
console.error(
|
||||
"[chat/stream] failed to save aborted stream",
|
||||
|
|
@ -640,17 +671,29 @@ chatRouter.post("/", requireAuth, async (req, res) => {
|
|||
const errorFullText =
|
||||
err instanceof AssistantStreamError ? err.fullText : "";
|
||||
try {
|
||||
const annotations = extractAnnotations(
|
||||
const citations = extractCitations(
|
||||
errorFullText,
|
||||
docIndex,
|
||||
errorEvents,
|
||||
);
|
||||
const { error: saveError } = await db.from("chat_messages").insert({
|
||||
chat_id: chatId,
|
||||
role: "assistant",
|
||||
content: errorEvents.length ? errorEvents : null,
|
||||
annotations: annotations.length ? annotations : null,
|
||||
});
|
||||
const saveError = askInputsResponse
|
||||
? null
|
||||
: (
|
||||
await db.from("chat_messages").insert({
|
||||
chat_id: chatId,
|
||||
role: "assistant",
|
||||
content: errorEvents.length ? errorEvents : null,
|
||||
citations: citations.length ? citations : null,
|
||||
})
|
||||
).error;
|
||||
if (askInputsResponse) {
|
||||
await appendAssistantEventsToLastAssistantMessage(
|
||||
db,
|
||||
chatId,
|
||||
errorEvents,
|
||||
citations,
|
||||
);
|
||||
}
|
||||
if (saveError)
|
||||
console.error("[chat/stream] failed to save error", saveError);
|
||||
} catch (saveErr) {
|
||||
|
|
|
|||
|
|
@ -23,9 +23,14 @@ import {
|
|||
} from "../lib/documentVersions";
|
||||
import { ensureDocAccess } from "../lib/access";
|
||||
import { singleFileUpload } from "../lib/upload";
|
||||
import {
|
||||
ALLOWED_DOCUMENT_TYPES,
|
||||
ALLOWED_DOCUMENT_TYPES_LABEL,
|
||||
contentTypeForDocumentType,
|
||||
shouldConvertToPdf,
|
||||
} from "../lib/documentTypes";
|
||||
|
||||
export const documentsRouter = Router();
|
||||
const ALLOWED_TYPES = new Set(["pdf", "docx", "doc"]);
|
||||
const isDev = process.env.NODE_ENV !== "production";
|
||||
const devLog = (...args: Parameters<typeof console.log>) => {
|
||||
if (isDev) console.log(...args);
|
||||
|
|
@ -129,16 +134,16 @@ documentsRouter.get("/:documentId/display", requireAuth, async (req, res) => {
|
|||
return void res.status(404).json({ detail: "No file available" });
|
||||
|
||||
const fileType = active.file_type ?? "";
|
||||
const isDocx = fileType === "docx" || fileType === "doc";
|
||||
const isConvertibleOffice = shouldConvertToPdf(fileType);
|
||||
const displayFilename = downloadFilenameForVersion(
|
||||
active.filename,
|
||||
active.version_number,
|
||||
active.source === "assistant_edit",
|
||||
);
|
||||
|
||||
// For DOCX, prefer the per-version PDF rendition if one exists.
|
||||
// For Office files, prefer the per-version PDF rendition if one exists.
|
||||
const servePath =
|
||||
isDocx && active.pdf_storage_path
|
||||
isConvertibleOffice && active.pdf_storage_path
|
||||
? active.pdf_storage_path
|
||||
: active.storage_path;
|
||||
const raw = await downloadFile(servePath);
|
||||
|
|
@ -147,7 +152,7 @@ documentsRouter.get("/:documentId/display", requireAuth, async (req, res) => {
|
|||
.status(404)
|
||||
.json({ detail: "Document not found in storage" });
|
||||
|
||||
if (fileType === "pdf" || (isDocx && active.pdf_storage_path)) {
|
||||
if (fileType === "pdf" || (isConvertibleOffice && active.pdf_storage_path)) {
|
||||
res.setHeader("Content-Type", "application/pdf");
|
||||
res.setHeader(
|
||||
"Content-Disposition",
|
||||
|
|
@ -155,11 +160,8 @@ documentsRouter.get("/:documentId/display", requireAuth, async (req, res) => {
|
|||
);
|
||||
res.send(Buffer.from(raw));
|
||||
} else {
|
||||
// Fallback: serve raw DOCX (mammoth will handle it client-side)
|
||||
res.setHeader(
|
||||
"Content-Type",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
);
|
||||
// Fallback: serve raw Office bytes when PDF conversion was unavailable.
|
||||
res.setHeader("Content-Type", contentTypeForDocumentType(fileType));
|
||||
res.setHeader(
|
||||
"Content-Disposition",
|
||||
buildContentDisposition("inline", displayFilename),
|
||||
|
|
@ -455,10 +457,7 @@ documentsRouter.post(
|
|||
(filename.includes(".") ? filename.split(".").pop()!.toLowerCase() : "");
|
||||
const versionSlug = crypto.randomUUID().replace(/-/g, "");
|
||||
const key = versionStorageKey(userId, documentId, versionSlug, filename);
|
||||
const contentType =
|
||||
suffix === "pdf"
|
||||
? "application/pdf"
|
||||
: "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
||||
const contentType = contentTypeForDocumentType(suffix);
|
||||
|
||||
try {
|
||||
await uploadFile(key, bytes, contentType);
|
||||
|
|
@ -483,7 +482,7 @@ documentsRouter.post(
|
|||
pdfStoragePath = pdfKey;
|
||||
}
|
||||
}
|
||||
} else if (suffix === "docx" || suffix === "doc") {
|
||||
} else if (shouldConvertToPdf(suffix)) {
|
||||
try {
|
||||
const pdfBuf = await docxToPdf(Buffer.from(bytes));
|
||||
const pdfKey = `converted-pdfs/${userId}/${documentId}/${versionSlug}.pdf`;
|
||||
|
|
@ -498,7 +497,7 @@ documentsRouter.post(
|
|||
pdfStoragePath = pdfKey;
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[versions/copy] DOCX→PDF conversion failed for ${filename}:`,
|
||||
`[versions/copy] Office→PDF conversion failed for ${filename}:`,
|
||||
err,
|
||||
);
|
||||
}
|
||||
|
|
@ -599,9 +598,9 @@ documentsRouter.post(
|
|||
const suffix = file.originalname.includes(".")
|
||||
? file.originalname.split(".").pop()!.toLowerCase()
|
||||
: "";
|
||||
if (!ALLOWED_TYPES.has(suffix)) {
|
||||
if (!ALLOWED_DOCUMENT_TYPES.has(suffix)) {
|
||||
return void res.status(400).json({
|
||||
detail: `Unsupported file type: ${suffix}. Allowed: pdf, docx, doc`,
|
||||
detail: `Unsupported file type: ${suffix}. Allowed: ${ALLOWED_DOCUMENT_TYPES_LABEL}`,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -614,10 +613,7 @@ documentsRouter.post(
|
|||
versionSlug,
|
||||
file.originalname,
|
||||
);
|
||||
const contentType =
|
||||
suffix === "pdf"
|
||||
? "application/pdf"
|
||||
: "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
||||
const contentType = contentTypeForDocumentType(suffix);
|
||||
try {
|
||||
await uploadFile(
|
||||
key,
|
||||
|
|
@ -638,7 +634,7 @@ documentsRouter.post(
|
|||
// historical versions without on-demand conversion. Same logic as the
|
||||
// initial-upload pipeline; failures don't block the version row.
|
||||
let pdfStoragePath: string | null = null;
|
||||
if (suffix === "docx" || suffix === "doc") {
|
||||
if (shouldConvertToPdf(suffix)) {
|
||||
try {
|
||||
const pdfBuf = await docxToPdf(file.buffer);
|
||||
const pdfKey = `converted-pdfs/${userId}/${documentId}/${versionSlug}.pdf`;
|
||||
|
|
@ -653,7 +649,7 @@ documentsRouter.post(
|
|||
pdfStoragePath = pdfKey;
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[versions/upload] DOCX→PDF conversion failed for ${file.originalname}:`,
|
||||
`[versions/upload] Office→PDF conversion failed for ${file.originalname}:`,
|
||||
err,
|
||||
);
|
||||
}
|
||||
|
|
@ -814,9 +810,9 @@ documentsRouter.put(
|
|||
const suffix = file.originalname.includes(".")
|
||||
? file.originalname.split(".").pop()!.toLowerCase()
|
||||
: "";
|
||||
if (!ALLOWED_TYPES.has(suffix)) {
|
||||
if (!ALLOWED_DOCUMENT_TYPES.has(suffix)) {
|
||||
return void res.status(400).json({
|
||||
detail: `Unsupported file type: ${suffix}. Allowed: pdf, docx, doc`,
|
||||
detail: `Unsupported file type: ${suffix}. Allowed: ${ALLOWED_DOCUMENT_TYPES_LABEL}`,
|
||||
});
|
||||
}
|
||||
if (target.file_type && target.file_type !== suffix) {
|
||||
|
|
@ -832,10 +828,7 @@ documentsRouter.put(
|
|||
versionSlug,
|
||||
file.originalname,
|
||||
);
|
||||
const contentType =
|
||||
suffix === "pdf"
|
||||
? "application/pdf"
|
||||
: "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
||||
const contentType = contentTypeForDocumentType(suffix);
|
||||
|
||||
try {
|
||||
await uploadFile(
|
||||
|
|
@ -854,7 +847,7 @@ documentsRouter.put(
|
|||
}
|
||||
|
||||
let pdfStoragePath: string | null = null;
|
||||
if (suffix === "docx" || suffix === "doc") {
|
||||
if (shouldConvertToPdf(suffix)) {
|
||||
try {
|
||||
const pdfBuf = await docxToPdf(file.buffer);
|
||||
const pdfKey = `converted-pdfs/${userId}/${documentId}/${versionSlug}.pdf`;
|
||||
|
|
@ -869,7 +862,7 @@ documentsRouter.put(
|
|||
pdfStoragePath = pdfKey;
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[versions/replace] DOCX→PDF conversion failed for ${file.originalname}:`,
|
||||
`[versions/replace] Office→PDF conversion failed for ${file.originalname}:`,
|
||||
err,
|
||||
);
|
||||
}
|
||||
|
|
@ -1304,11 +1297,11 @@ async function handleDocumentUpload(
|
|||
const suffix = filename.includes(".")
|
||||
? filename.split(".").pop()!.toLowerCase()
|
||||
: "";
|
||||
if (!ALLOWED_TYPES.has(suffix))
|
||||
if (!ALLOWED_DOCUMENT_TYPES.has(suffix))
|
||||
return void res
|
||||
.status(400)
|
||||
.json({
|
||||
detail: `Unsupported file type: ${suffix}. Allowed: pdf, docx, doc`,
|
||||
detail: `Unsupported file type: ${suffix}. Allowed: ${ALLOWED_DOCUMENT_TYPES_LABEL}`,
|
||||
});
|
||||
|
||||
const content = file.buffer;
|
||||
|
|
@ -1338,10 +1331,7 @@ async function handleDocumentUpload(
|
|||
try {
|
||||
const docId = doc.id as string;
|
||||
const key = storageKey(userId, docId, filename);
|
||||
const contentType =
|
||||
suffix === "pdf"
|
||||
? "application/pdf"
|
||||
: "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
||||
const contentType = contentTypeForDocumentType(suffix);
|
||||
await uploadFile(
|
||||
key,
|
||||
content.buffer.slice(
|
||||
|
|
@ -1357,9 +1347,9 @@ async function handleDocumentUpload(
|
|||
) as ArrayBuffer;
|
||||
const pageCount = suffix === "pdf" ? await countPdfPages(rawBuf) : null;
|
||||
|
||||
// Convert DOCX/DOC → PDF for display. PDFs are their own rendition.
|
||||
// Convert Office files → PDF for display. PDFs are their own rendition.
|
||||
let pdfStoragePath: string | null = null;
|
||||
if (suffix === "docx" || suffix === "doc") {
|
||||
if (shouldConvertToPdf(suffix)) {
|
||||
try {
|
||||
const pdfBuf = await docxToPdf(content);
|
||||
const pdfKey = convertedPdfKey(userId, docId);
|
||||
|
|
@ -1374,7 +1364,7 @@ async function handleDocumentUpload(
|
|||
pdfStoragePath = pdfKey;
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[upload] DOCX→PDF conversion failed for ${filename}:`,
|
||||
`[upload] Office→PDF conversion failed for ${filename}:`,
|
||||
err,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,17 +4,15 @@ import { createServerSupabase } from "../lib/supabase";
|
|||
import { buildContentDisposition, downloadFile } from "../lib/storage";
|
||||
import { verifyDownload } from "../lib/downloadTokens";
|
||||
import { ensureDocAccess } from "../lib/access";
|
||||
import { contentTypeForDocumentType } from "../lib/documentTypes";
|
||||
|
||||
export const downloadsRouter = Router();
|
||||
|
||||
function contentTypeFor(filename: string): string {
|
||||
const lower = filename.toLowerCase();
|
||||
if (lower.endsWith(".docx"))
|
||||
return "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
||||
if (lower.endsWith(".pdf")) return "application/pdf";
|
||||
if (lower.endsWith(".xlsx"))
|
||||
return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
||||
return "application/octet-stream";
|
||||
const suffix = filename.includes(".")
|
||||
? filename.split(".").pop()?.toLowerCase()
|
||||
: "";
|
||||
return contentTypeForDocumentType(suffix);
|
||||
}
|
||||
|
||||
// GET /download/:token
|
||||
|
|
|
|||
|
|
@ -6,15 +6,18 @@ import {
|
|||
buildMessages,
|
||||
buildWorkflowStore,
|
||||
enrichWithPriorEvents,
|
||||
appendAskInputsResponseToLastAssistantMessage,
|
||||
appendAssistantEventsToLastAssistantMessage,
|
||||
AssistantStreamError,
|
||||
buildCancelledAssistantMessage,
|
||||
extractAnnotations,
|
||||
extractCitations,
|
||||
isAbortError,
|
||||
runLLMStream,
|
||||
stripTransientAssistantEvents,
|
||||
PROJECT_EXTRA_TOOLS,
|
||||
parseAskInputsResponsePayload,
|
||||
type ChatMessage,
|
||||
} from "../lib/chatTools";
|
||||
} from "../lib/chat";
|
||||
import {
|
||||
getUserModelSettings,
|
||||
} from "../lib/userSettings";
|
||||
|
|
@ -36,14 +39,25 @@ projectChatRouter.post("/", requireAuth, async (req, res) => {
|
|||
const userId = res.locals.userId as string;
|
||||
const userEmail = res.locals.userEmail as string | undefined;
|
||||
const { projectId } = req.params;
|
||||
const { messages, chat_id, model, displayed_doc, attached_documents } =
|
||||
const {
|
||||
messages,
|
||||
chat_id,
|
||||
model,
|
||||
displayed_doc,
|
||||
attached_documents,
|
||||
ask_inputs_response,
|
||||
} =
|
||||
req.body as {
|
||||
messages: ChatMessage[];
|
||||
chat_id?: string;
|
||||
model?: string;
|
||||
displayed_doc?: { filename: string; document_id: string };
|
||||
attached_documents?: { filename: string; document_id: string }[];
|
||||
ask_inputs_response?: unknown;
|
||||
};
|
||||
const askInputsResponse = parseAskInputsResponsePayload(
|
||||
ask_inputs_response,
|
||||
);
|
||||
|
||||
const db = createServerSupabase();
|
||||
|
||||
|
|
@ -86,7 +100,13 @@ projectChatRouter.post("/", requireAuth, async (req, res) => {
|
|||
}
|
||||
|
||||
const lastUser = [...messages].reverse().find((m) => m.role === "user");
|
||||
if (lastUser) {
|
||||
if (askInputsResponse) {
|
||||
await appendAskInputsResponseToLastAssistantMessage(
|
||||
db,
|
||||
chatId,
|
||||
askInputsResponse,
|
||||
);
|
||||
} else if (lastUser) {
|
||||
await db.from("chat_messages").insert({
|
||||
chat_id: chatId,
|
||||
role: "user",
|
||||
|
|
@ -173,7 +193,7 @@ projectChatRouter.post("/", requireAuth, async (req, res) => {
|
|||
try {
|
||||
write(`data: ${JSON.stringify({ type: "chat_id", chatId })}\n\n`);
|
||||
|
||||
const { events, annotations } = await runLLMStream({
|
||||
const { events, citations } = await runLLMStream({
|
||||
apiMessages,
|
||||
docStore,
|
||||
docIndex,
|
||||
|
|
@ -190,12 +210,21 @@ projectChatRouter.post("/", requireAuth, async (req, res) => {
|
|||
});
|
||||
|
||||
const persistedEvents = stripTransientAssistantEvents(events);
|
||||
await db.from("chat_messages").insert({
|
||||
chat_id: chatId,
|
||||
role: "assistant",
|
||||
content: persistedEvents.length ? persistedEvents : null,
|
||||
annotations: annotations.length ? annotations : null,
|
||||
});
|
||||
if (askInputsResponse) {
|
||||
await appendAssistantEventsToLastAssistantMessage(
|
||||
db,
|
||||
chatId,
|
||||
persistedEvents,
|
||||
citations,
|
||||
);
|
||||
} else {
|
||||
await db.from("chat_messages").insert({
|
||||
chat_id: chatId,
|
||||
role: "assistant",
|
||||
content: persistedEvents.length ? persistedEvents : null,
|
||||
citations: citations.length ? citations : null,
|
||||
});
|
||||
}
|
||||
|
||||
if (!chatTitle && lastUser?.content) {
|
||||
await db
|
||||
|
|
@ -212,17 +241,31 @@ projectChatRouter.post("/", requireAuth, async (req, res) => {
|
|||
const partial = buildCancelledAssistantMessage({
|
||||
fullText: err.fullText,
|
||||
events: err.events,
|
||||
buildAnnotations: (fullText, events) =>
|
||||
extractAnnotations(fullText, docIndex, events),
|
||||
});
|
||||
const { error: saveError } = await db.from("chat_messages").insert({
|
||||
chat_id: chatId,
|
||||
role: "assistant",
|
||||
content: partial.events.length ? partial.events : null,
|
||||
annotations: partial.annotations.length
|
||||
? partial.annotations
|
||||
: null,
|
||||
buildCitations: (fullText, events) =>
|
||||
extractCitations(fullText, docIndex, events),
|
||||
});
|
||||
const saveError = askInputsResponse
|
||||
? null
|
||||
: (
|
||||
await db.from("chat_messages").insert({
|
||||
chat_id: chatId,
|
||||
role: "assistant",
|
||||
content: partial.events.length
|
||||
? partial.events
|
||||
: null,
|
||||
citations: partial.citations.length
|
||||
? partial.citations
|
||||
: null,
|
||||
})
|
||||
).error;
|
||||
if (askInputsResponse) {
|
||||
await appendAssistantEventsToLastAssistantMessage(
|
||||
db,
|
||||
chatId,
|
||||
partial.events,
|
||||
partial.citations,
|
||||
);
|
||||
}
|
||||
if (saveError) {
|
||||
console.error(
|
||||
"[project-chat/stream] failed to save aborted stream",
|
||||
|
|
@ -240,17 +283,29 @@ projectChatRouter.post("/", requireAuth, async (req, res) => {
|
|||
const errorFullText =
|
||||
err instanceof AssistantStreamError ? err.fullText : "";
|
||||
try {
|
||||
const annotations = extractAnnotations(
|
||||
const citations = extractCitations(
|
||||
errorFullText,
|
||||
docIndex,
|
||||
errorEvents,
|
||||
);
|
||||
const { error: saveError } = await db.from("chat_messages").insert({
|
||||
chat_id: chatId,
|
||||
role: "assistant",
|
||||
content: errorEvents.length ? errorEvents : null,
|
||||
annotations: annotations.length ? annotations : null,
|
||||
});
|
||||
const saveError = askInputsResponse
|
||||
? null
|
||||
: (
|
||||
await db.from("chat_messages").insert({
|
||||
chat_id: chatId,
|
||||
role: "assistant",
|
||||
content: errorEvents.length ? errorEvents : null,
|
||||
citations: citations.length ? citations : null,
|
||||
})
|
||||
).error;
|
||||
if (askInputsResponse) {
|
||||
await appendAssistantEventsToLastAssistantMessage(
|
||||
db,
|
||||
chatId,
|
||||
errorEvents,
|
||||
citations,
|
||||
);
|
||||
}
|
||||
if (saveError)
|
||||
console.error("[project-chat/stream] failed to save error", saveError);
|
||||
} catch (saveErr) {
|
||||
|
|
|
|||
|
|
@ -16,9 +16,24 @@ import { docxToPdf, convertedPdfKey } from "../lib/convert";
|
|||
import { checkProjectAccess } from "../lib/access";
|
||||
import { singleFileUpload } from "../lib/upload";
|
||||
import { deleteUserProjects } from "../lib/userDataCleanup";
|
||||
import {
|
||||
ALLOWED_DOCUMENT_TYPES,
|
||||
ALLOWED_DOCUMENT_TYPES_LABEL,
|
||||
contentTypeForDocumentType,
|
||||
shouldConvertToPdf,
|
||||
} from "../lib/documentTypes";
|
||||
import {
|
||||
findMissingUserEmails,
|
||||
loadProfileUsersByEmail,
|
||||
} from "../lib/userLookup";
|
||||
|
||||
export const projectsRouter = Router();
|
||||
const ALLOWED_TYPES = new Set(["pdf", "docx", "doc"]);
|
||||
|
||||
function normalizeOptionalString(value: unknown) {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function normalizeDocumentFilename(nextName: unknown, currentName: string) {
|
||||
if (typeof nextName !== "string") return null;
|
||||
|
|
@ -155,9 +170,10 @@ projectsRouter.get("/", requireAuth, async (req, res) => {
|
|||
projectsRouter.post("/", requireAuth, async (req, res) => {
|
||||
const userId = res.locals.userId as string;
|
||||
const userEmail = res.locals.userEmail as string | undefined;
|
||||
const { name, cm_number, shared_with } = req.body as {
|
||||
const { name, cm_number, practice, shared_with } = req.body as {
|
||||
name: string;
|
||||
cm_number?: string;
|
||||
practice?: string;
|
||||
shared_with?: string[];
|
||||
};
|
||||
if (!name?.trim())
|
||||
|
|
@ -181,12 +197,20 @@ projectsRouter.post("/", requireAuth, async (req, res) => {
|
|||
}
|
||||
|
||||
const db = createServerSupabase();
|
||||
const missingSharedUsers = await findMissingUserEmails(db, cleanedSharedWith);
|
||||
if (missingSharedUsers.length > 0) {
|
||||
return void res.status(400).json({
|
||||
detail: `${missingSharedUsers[0]} does not belong to a Mike user.`,
|
||||
});
|
||||
}
|
||||
|
||||
const { data, error } = await db
|
||||
.from("projects")
|
||||
.insert({
|
||||
user_id: userId,
|
||||
name: name.trim(),
|
||||
cm_number: cm_number ?? null,
|
||||
cm_number: normalizeOptionalString(cm_number),
|
||||
practice: normalizeOptionalString(practice),
|
||||
shared_with: cleanedSharedWith,
|
||||
})
|
||||
.select("*")
|
||||
|
|
@ -266,60 +290,18 @@ projectsRouter.get("/:projectId/people", requireAuth, async (req, res) => {
|
|||
if (!isOwner && !isShared)
|
||||
return void res.status(404).json({ detail: "Project not found" });
|
||||
|
||||
// Pull every auth user (matching the lookup endpoint's pattern). For
|
||||
// larger deployments this should page or be replaced with a bulk-by-id
|
||||
// RPC, but it keeps things simple while user counts are modest.
|
||||
const { data: usersData } = await db.auth.admin.listUsers({ perPage: 1000 });
|
||||
const allUsers = usersData?.users ?? [];
|
||||
const userByEmail = new Map<string, { id: string; email: string }>();
|
||||
const userById = new Map<string, { id: string; email: string }>();
|
||||
for (const u of allUsers) {
|
||||
if (!u.email) continue;
|
||||
const lower = u.email.toLowerCase();
|
||||
userByEmail.set(lower, { id: u.id, email: u.email });
|
||||
userById.set(u.id, { id: u.id, email: u.email });
|
||||
}
|
||||
|
||||
const memberUserIds: string[] = [];
|
||||
for (const email of sharedWith) {
|
||||
const u = userByEmail.get(email);
|
||||
if (u) memberUserIds.push(u.id);
|
||||
}
|
||||
|
||||
const profileIds = [
|
||||
project.user_id as string,
|
||||
...memberUserIds,
|
||||
].filter((x, i, arr) => arr.indexOf(x) === i);
|
||||
|
||||
const profileByUserId = new Map<
|
||||
string,
|
||||
{ display_name: string | null; organisation: string | null }
|
||||
>();
|
||||
if (profileIds.length > 0) {
|
||||
const { data: profiles } = await db
|
||||
.from("user_profiles")
|
||||
.select("user_id, display_name, organisation")
|
||||
.in("user_id", profileIds);
|
||||
for (const p of profiles ?? []) {
|
||||
profileByUserId.set(p.user_id as string, {
|
||||
display_name: (p.display_name as string | null) ?? null,
|
||||
organisation: (p.organisation as string | null) ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
// Use the mirrored profile email so sharing checks do not scan auth.users.
|
||||
const { userByEmail, userById } = await loadProfileUsersByEmail(db);
|
||||
|
||||
const ownerInfo = userById.get(project.user_id as string);
|
||||
const owner = {
|
||||
user_id: project.user_id,
|
||||
email: ownerInfo?.email ?? null,
|
||||
display_name:
|
||||
profileByUserId.get(project.user_id as string)?.display_name ?? null,
|
||||
display_name: ownerInfo?.display_name ?? null,
|
||||
};
|
||||
const members = sharedWith.map((email) => {
|
||||
const u = userByEmail.get(email);
|
||||
const display_name = u
|
||||
? profileByUserId.get(u.id)?.display_name ?? null
|
||||
: null;
|
||||
const display_name = u?.display_name ?? null;
|
||||
return { email, display_name };
|
||||
});
|
||||
|
||||
|
|
@ -334,6 +316,9 @@ projectsRouter.patch("/:projectId", requireAuth, async (req, res) => {
|
|||
const updates: Record<string, unknown> = {};
|
||||
if (req.body.name != null) updates.name = req.body.name;
|
||||
if (req.body.cm_number != null) updates.cm_number = req.body.cm_number;
|
||||
if ("practice" in req.body) {
|
||||
updates.practice = normalizeOptionalString(req.body.practice);
|
||||
}
|
||||
if (Array.isArray(req.body.shared_with)) {
|
||||
// Normalise: lowercase + dedupe + drop empties.
|
||||
const normalizedUserEmail = userEmail?.trim().toLowerCase();
|
||||
|
|
@ -355,6 +340,18 @@ projectsRouter.patch("/:projectId", requireAuth, async (req, res) => {
|
|||
}
|
||||
|
||||
const db = createServerSupabase();
|
||||
if (Array.isArray(updates.shared_with)) {
|
||||
const missingSharedUsers = await findMissingUserEmails(
|
||||
db,
|
||||
updates.shared_with as string[],
|
||||
);
|
||||
if (missingSharedUsers.length > 0) {
|
||||
return void res.status(400).json({
|
||||
detail: `${missingSharedUsers[0]} does not belong to a Mike user.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const { data, error } = await db
|
||||
.from("projects")
|
||||
.update({ ...updates, updated_at: new Date().toISOString() })
|
||||
|
|
@ -519,10 +516,9 @@ projectsRouter.post(
|
|||
);
|
||||
let newPdfPath: string | null = null;
|
||||
try {
|
||||
const contentType =
|
||||
((srcV.file_type as string | null) ?? doc.file_type) === "pdf"
|
||||
? "application/pdf"
|
||||
: "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
||||
const contentType = contentTypeForDocumentType(
|
||||
(srcV.file_type as string | null) ?? doc.file_type,
|
||||
);
|
||||
await uploadFile(newKey, srcBytes, contentType);
|
||||
|
||||
// PDFs share one object for source + display rendition. DOCX
|
||||
|
|
@ -885,11 +881,11 @@ export async function handleDocumentUpload(
|
|||
const suffix = filename.includes(".")
|
||||
? filename.split(".").pop()!.toLowerCase()
|
||||
: "";
|
||||
if (!ALLOWED_TYPES.has(suffix))
|
||||
if (!ALLOWED_DOCUMENT_TYPES.has(suffix))
|
||||
return void res
|
||||
.status(400)
|
||||
.json({
|
||||
detail: `Unsupported file type: ${suffix}. Allowed: pdf, docx, doc`,
|
||||
detail: `Unsupported file type: ${suffix}. Allowed: ${ALLOWED_DOCUMENT_TYPES_LABEL}`,
|
||||
});
|
||||
|
||||
const content = file.buffer;
|
||||
|
|
@ -911,10 +907,7 @@ export async function handleDocumentUpload(
|
|||
try {
|
||||
const docId = doc.id as string;
|
||||
const key = storageKey(userId, docId, filename);
|
||||
const contentType =
|
||||
suffix === "pdf"
|
||||
? "application/pdf"
|
||||
: "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
||||
const contentType = contentTypeForDocumentType(suffix);
|
||||
await uploadFile(
|
||||
key,
|
||||
content.buffer.slice(
|
||||
|
|
@ -930,9 +923,9 @@ export async function handleDocumentUpload(
|
|||
) as ArrayBuffer;
|
||||
const pageCount = suffix === "pdf" ? await countPdfPages(rawBuf) : null;
|
||||
|
||||
// Convert DOCX/DOC → PDF for display. PDFs are their own rendition.
|
||||
// Convert Office files → PDF for display. PDFs are their own rendition.
|
||||
let pdfStoragePath: string | null = null;
|
||||
if (suffix === "docx" || suffix === "doc") {
|
||||
if (shouldConvertToPdf(suffix)) {
|
||||
try {
|
||||
const pdfBuf = await docxToPdf(content);
|
||||
const pdfKey = convertedPdfKey(userId, docId);
|
||||
|
|
@ -947,7 +940,7 @@ export async function handleDocumentUpload(
|
|||
pdfStoragePath = pdfKey;
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[upload] DOCX→PDF conversion failed for ${filename}:`,
|
||||
`[upload] Office→PDF conversion failed for ${filename}:`,
|
||||
err,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,14 @@ import {
|
|||
attachActiveVersionPaths,
|
||||
loadActiveVersion,
|
||||
} from "../lib/documentVersions";
|
||||
import { normalizeDocxZipPaths } from "../lib/convert";
|
||||
import { docxToPdf, normalizeDocxZipPaths } from "../lib/convert";
|
||||
import {
|
||||
isPresentationDocumentType,
|
||||
isSpreadsheetDocumentType,
|
||||
isWordDocumentType,
|
||||
} from "../lib/documentTypes";
|
||||
import { extractPresentationText } from "../lib/officeText";
|
||||
import { spreadsheetToLLMText } from "../lib/spreadsheet";
|
||||
import {
|
||||
AssistantStreamError,
|
||||
buildCancelledAssistantMessage,
|
||||
|
|
@ -16,7 +23,7 @@ import {
|
|||
TABULAR_TOOLS,
|
||||
type ChatMessage,
|
||||
type TabularCellStore,
|
||||
} from "../lib/chatTools";
|
||||
} from "../lib/chat";
|
||||
import {
|
||||
completeText,
|
||||
providerForModel,
|
||||
|
|
@ -31,6 +38,10 @@ import {
|
|||
filterAccessibleDocumentIds,
|
||||
} from "../lib/access";
|
||||
import { safeErrorLog, safeErrorMessage } from "../lib/safeError";
|
||||
import {
|
||||
findMissingUserEmails,
|
||||
loadProfileUsersByEmail,
|
||||
} from "../lib/userLookup";
|
||||
|
||||
function formatPromptSuffix(format?: string, tags?: string[]): string {
|
||||
switch (format) {
|
||||
|
|
@ -307,55 +318,19 @@ tabularRouter.get("/:reviewId/people", requireAuth, async (req, res) => {
|
|||
: []
|
||||
).map((e) => (e ?? "").toLowerCase());
|
||||
|
||||
// Same pattern as /projects/:id/people: walk auth.users to map emails
|
||||
// to user_ids, then pull display_names from user_profiles by user_id.
|
||||
const { data: usersData } = await db.auth.admin.listUsers({
|
||||
perPage: 1000,
|
||||
});
|
||||
const allUsers = usersData?.users ?? [];
|
||||
const userByEmail = new Map<string, { id: string; email: string }>();
|
||||
const userById = new Map<string, { id: string; email: string }>();
|
||||
for (const u of allUsers) {
|
||||
if (!u.email) continue;
|
||||
const lower = u.email.toLowerCase();
|
||||
userByEmail.set(lower, { id: u.id, email: u.email });
|
||||
userById.set(u.id, { id: u.id, email: u.email });
|
||||
}
|
||||
|
||||
const memberUserIds: string[] = [];
|
||||
for (const email of sharedWith) {
|
||||
const u = userByEmail.get(email);
|
||||
if (u) memberUserIds.push(u.id);
|
||||
}
|
||||
|
||||
const profileIds = [review.user_id as string, ...memberUserIds].filter(
|
||||
(x, i, arr) => arr.indexOf(x) === i,
|
||||
);
|
||||
|
||||
const profileByUserId = new Map<string, string | null>();
|
||||
if (profileIds.length > 0) {
|
||||
const { data: profiles } = await db
|
||||
.from("user_profiles")
|
||||
.select("user_id, display_name")
|
||||
.in("user_id", profileIds);
|
||||
for (const p of profiles ?? []) {
|
||||
profileByUserId.set(
|
||||
p.user_id as string,
|
||||
(p.display_name as string | null) ?? null,
|
||||
);
|
||||
}
|
||||
}
|
||||
// Use the mirrored profile email so sharing checks do not scan auth.users.
|
||||
const { userByEmail, userById } = await loadProfileUsersByEmail(db);
|
||||
|
||||
const ownerInfo = userById.get(review.user_id as string);
|
||||
res.json({
|
||||
owner: {
|
||||
user_id: review.user_id,
|
||||
email: ownerInfo?.email ?? null,
|
||||
display_name: profileByUserId.get(review.user_id as string) ?? null,
|
||||
display_name: ownerInfo?.display_name ?? null,
|
||||
},
|
||||
members: sharedWith.map((email) => {
|
||||
const u = userByEmail.get(email);
|
||||
const display_name = u ? (profileByUserId.get(u.id) ?? null) : null;
|
||||
const display_name = u?.display_name ?? null;
|
||||
return { email, display_name };
|
||||
}),
|
||||
});
|
||||
|
|
@ -433,6 +408,15 @@ tabularRouter.patch("/:reviewId", requireAuth, async (req, res) => {
|
|||
return void res
|
||||
.status(403)
|
||||
.json({ detail: "Only the review owner can change sharing" });
|
||||
const missingSharedUsers = await findMissingUserEmails(
|
||||
db,
|
||||
sharedWithUpdate,
|
||||
);
|
||||
if (missingSharedUsers.length > 0) {
|
||||
return void res.status(400).json({
|
||||
detail: `${missingSharedUsers[0]} does not belong to a Mike user.`,
|
||||
});
|
||||
}
|
||||
updates.shared_with = sharedWithUpdate;
|
||||
}
|
||||
if (projectIdUpdateProvided) {
|
||||
|
|
@ -712,10 +696,10 @@ tabularRouter.post(
|
|||
const buf = await downloadFile(docActive.storage_path);
|
||||
if (buf) {
|
||||
try {
|
||||
markdown =
|
||||
docActive.file_type === "pdf"
|
||||
? await extractPdfMarkdown(buf)
|
||||
: await extractDocxMarkdown(buf);
|
||||
markdown = await extractDocumentMarkdown(
|
||||
buf,
|
||||
docActive.file_type,
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[regenerate-cell] extraction error doc=${document_id}`,
|
||||
|
|
@ -858,10 +842,10 @@ tabularRouter.post("/:reviewId/generate", requireAuth, async (req, res) => {
|
|||
const buf = await downloadFile(storagePath);
|
||||
if (buf) {
|
||||
try {
|
||||
markdown =
|
||||
fileType === "pdf"
|
||||
? await extractPdfMarkdown(buf)
|
||||
: await extractDocxMarkdown(buf);
|
||||
markdown = await extractDocumentMarkdown(
|
||||
buf,
|
||||
fileType,
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[tabular/generate] extraction error doc=${docId}`,
|
||||
|
|
@ -1379,17 +1363,18 @@ tabularRouter.post("/:reviewId/chat", requireAuth, async (req, res) => {
|
|||
const partial = buildCancelledAssistantMessage({
|
||||
fullText: err.fullText,
|
||||
events: err.events,
|
||||
buildAnnotations: (fullText) =>
|
||||
buildCitations: (fullText) =>
|
||||
extractTabularAnnotations(fullText, tabularStore),
|
||||
});
|
||||
const annotations = partial.citations;
|
||||
const { error: saveError } = await db
|
||||
.from("tabular_review_chat_messages")
|
||||
.insert({
|
||||
chat_id: chatId,
|
||||
role: "assistant",
|
||||
content: partial.events.length ? partial.events : null,
|
||||
annotations: partial.annotations.length
|
||||
? partial.annotations
|
||||
annotations: annotations.length
|
||||
? annotations
|
||||
: null,
|
||||
});
|
||||
if (saveError) {
|
||||
|
|
@ -1737,6 +1722,34 @@ Rules:
|
|||
await Promise.all(pending);
|
||||
}
|
||||
|
||||
async function extractDocumentMarkdown(
|
||||
buf: ArrayBuffer,
|
||||
fileType: string | null | undefined,
|
||||
): Promise<string> {
|
||||
const normalizedType = (fileType ?? "").toLowerCase();
|
||||
if (normalizedType === "pdf") return extractPdfMarkdown(buf);
|
||||
if (normalizedType === "docx") return extractDocxMarkdown(buf);
|
||||
if (isSpreadsheetDocumentType(normalizedType)) {
|
||||
// SheetJS handles .xlsx/.xlsm/.xls directly, no PDF detour.
|
||||
return spreadsheetToLLMText(Buffer.from(buf));
|
||||
}
|
||||
if (normalizedType === "pptx") {
|
||||
return extractPresentationText(Buffer.from(buf));
|
||||
}
|
||||
if (
|
||||
isPresentationDocumentType(normalizedType) ||
|
||||
isWordDocumentType(normalizedType)
|
||||
) {
|
||||
const pdfBuf = await docxToPdf(Buffer.from(buf));
|
||||
const pdfArrayBuffer = pdfBuf.buffer.slice(
|
||||
pdfBuf.byteOffset,
|
||||
pdfBuf.byteOffset + pdfBuf.byteLength,
|
||||
) as ArrayBuffer;
|
||||
return extractPdfMarkdown(pdfArrayBuffer);
|
||||
}
|
||||
return extractDocxMarkdown(buf);
|
||||
}
|
||||
|
||||
async function extractPdfMarkdown(buf: ArrayBuffer): Promise<string> {
|
||||
try {
|
||||
const pdfjsLib = await import(
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ import {
|
|||
buildUserTabularReviewsExport,
|
||||
userExportFilename,
|
||||
} from "../lib/userDataExport";
|
||||
import { findProfileUserByEmail } from "../lib/userLookup";
|
||||
|
||||
export const userRouter = Router();
|
||||
|
||||
|
|
@ -505,6 +506,22 @@ userRouter.post("/profile", requireAuth, async (_req, res) => {
|
|||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// GET /user/lookup?email=person@example.com
|
||||
userRouter.get("/lookup", requireAuth, async (req, res) => {
|
||||
const email = typeof req.query.email === "string" ? req.query.email : "";
|
||||
if (!email.trim()) {
|
||||
return void res.status(400).json({ detail: "email is required" });
|
||||
}
|
||||
|
||||
const db = createServerSupabase();
|
||||
const user = await findProfileUserByEmail(db, email);
|
||||
res.json({
|
||||
exists: !!user,
|
||||
email: user?.email ?? email.trim().toLowerCase(),
|
||||
display_name: user?.display_name ?? null,
|
||||
});
|
||||
});
|
||||
|
||||
// GET /user/profile
|
||||
userRouter.get("/profile", requireAuth, async (_req, res) => {
|
||||
const userId = res.locals.userId as string;
|
||||
|
|
|
|||
|
|
@ -1,18 +1,80 @@
|
|||
import { Router, type NextFunction, type Request, type Response } from "express";
|
||||
import { requireAuth } from "../middleware/auth";
|
||||
import { createServerSupabase } from "../lib/supabase";
|
||||
import {
|
||||
SYSTEM_WORKFLOW_IDS,
|
||||
SYSTEM_WORKFLOWS,
|
||||
type SystemWorkflow,
|
||||
} from "../lib/systemWorkflows";
|
||||
import { findMissingUserEmails } from "../lib/userLookup";
|
||||
|
||||
export const workflowsRouter = Router();
|
||||
|
||||
type Db = ReturnType<typeof createServerSupabase>;
|
||||
const isDev = process.env.NODE_ENV !== "production";
|
||||
const devLog = (...args: Parameters<typeof console.log>) => {
|
||||
if (isDev) console.log(...args);
|
||||
};
|
||||
|
||||
type WorkflowRecord = {
|
||||
id: string;
|
||||
user_id: string | null;
|
||||
is_system: boolean;
|
||||
is_system?: boolean;
|
||||
title?: string;
|
||||
type?: string;
|
||||
prompt_md?: string | null;
|
||||
columns_config?: unknown;
|
||||
language?: string | null;
|
||||
version?: string | null;
|
||||
practice?: string | null;
|
||||
jurisdictions?: string[] | null;
|
||||
created_at?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
type WorkflowContributor = {
|
||||
name: string;
|
||||
organisation: string | null;
|
||||
role: string | null;
|
||||
linkedin: string | null;
|
||||
};
|
||||
type OpenSourceSubmissionStatus = "pending" | "approved" | "rejected";
|
||||
|
||||
type OpenSourceSubmissionRow = {
|
||||
id: string;
|
||||
workflow_id: string;
|
||||
submitted_by_user_id: string;
|
||||
submitter_email: string | null;
|
||||
submitter_name: string | null;
|
||||
contributor_mode?: "named" | "anonymous";
|
||||
status: OpenSourceSubmissionStatus;
|
||||
snapshot: unknown;
|
||||
submitted_at: string;
|
||||
updated_at: string;
|
||||
reviewed_at?: string | null;
|
||||
reviewed_by_user_id?: string | null;
|
||||
review_notes?: string | null;
|
||||
};
|
||||
|
||||
type OpenSourceSubmissionSummary = Pick<
|
||||
OpenSourceSubmissionRow,
|
||||
"id" | "status" | "submitted_at" | "updated_at"
|
||||
> & {
|
||||
reviewed_at?: string | null;
|
||||
};
|
||||
|
||||
const DEFAULT_WORKFLOW_CONTRIBUTOR: WorkflowContributor = {
|
||||
name: "Mike",
|
||||
organisation: null,
|
||||
role: null,
|
||||
linkedin: null,
|
||||
};
|
||||
const DEFAULT_WORKFLOW_LANGUAGE = "English";
|
||||
const DEFAULT_WORKFLOW_PRACTICE = "General Transactions";
|
||||
const DEFAULT_WORKFLOW_JURISDICTIONS = ["General"];
|
||||
const WORKFLOW_CONTRIBUTIONS_ENABLED =
|
||||
process.env.WORKFLOW_CONTRIBUTIONS_ENABLED === "true";
|
||||
|
||||
type WorkflowAccess =
|
||||
| {
|
||||
workflow: WorkflowRecord;
|
||||
|
|
@ -29,7 +91,7 @@ function asyncRoute(handler: AsyncRoute) {
|
|||
};
|
||||
}
|
||||
|
||||
function withWorkflowAccess<T extends Record<string, unknown>>(
|
||||
function withWorkflowAccess<T extends object>(
|
||||
workflow: T,
|
||||
access: { allowEdit: boolean; isOwner: boolean; sharedByName?: string | null },
|
||||
) {
|
||||
|
|
@ -41,6 +103,70 @@ function withWorkflowAccess<T extends Record<string, unknown>>(
|
|||
};
|
||||
}
|
||||
|
||||
function withOpenSourceSubmission<T extends object>(
|
||||
workflow: T,
|
||||
submission: OpenSourceSubmissionSummary | null,
|
||||
) {
|
||||
return {
|
||||
...workflow,
|
||||
open_source_submission: submission,
|
||||
};
|
||||
}
|
||||
|
||||
function withSystemWorkflowAccess(workflow: SystemWorkflow) {
|
||||
return withWorkflowAccess(workflow, {
|
||||
allowEdit: false,
|
||||
isOwner: false,
|
||||
});
|
||||
}
|
||||
|
||||
function withDatabaseWorkflow<T extends object>(workflow: T) {
|
||||
return {
|
||||
...workflow,
|
||||
is_system: false,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeOptionalString(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed || null;
|
||||
}
|
||||
|
||||
function normalizeJurisdictions(value: unknown): string[] | null {
|
||||
if (!Array.isArray(value)) return null;
|
||||
const items = value
|
||||
.map((item) => normalizeOptionalString(item))
|
||||
.filter((item): item is string => !!item);
|
||||
return items.length > 0 ? Array.from(new Set(items)) : null;
|
||||
}
|
||||
|
||||
function normalizeContributors(value: unknown): WorkflowContributor[] | null {
|
||||
if (!Array.isArray(value)) return null;
|
||||
const contributors = value
|
||||
.map((item): WorkflowContributor | null => {
|
||||
if (!item || typeof item !== "object" || Array.isArray(item)) return null;
|
||||
const record = item as Record<string, unknown>;
|
||||
const name = normalizeOptionalString(record.name);
|
||||
if (!name) return null;
|
||||
return {
|
||||
name,
|
||||
organisation: normalizeOptionalString(record.organisation),
|
||||
role: normalizeOptionalString(record.role),
|
||||
linkedin: normalizeOptionalString(record.linkedin),
|
||||
};
|
||||
})
|
||||
.filter((item): item is WorkflowContributor => !!item);
|
||||
return contributors.length ? contributors : null;
|
||||
}
|
||||
|
||||
function contributorFromName(name: unknown): WorkflowContributor {
|
||||
return {
|
||||
...DEFAULT_WORKFLOW_CONTRIBUTOR,
|
||||
name: normalizeOptionalString(name) ?? DEFAULT_WORKFLOW_CONTRIBUTOR.name,
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveWorkflowAccess(
|
||||
workflowId: string,
|
||||
userId: string,
|
||||
|
|
@ -53,7 +179,7 @@ async function resolveWorkflowAccess(
|
|||
.eq("id", workflowId)
|
||||
.single();
|
||||
if (!workflow) return null;
|
||||
const workflowRecord = workflow as WorkflowRecord;
|
||||
const workflowRecord = withDatabaseWorkflow(workflow as WorkflowRecord);
|
||||
if (workflowRecord.user_id === userId) {
|
||||
return { workflow: workflowRecord, allowEdit: true, isOwner: true };
|
||||
}
|
||||
|
|
@ -72,32 +198,116 @@ async function resolveWorkflowAccess(
|
|||
return { workflow: workflowRecord, allowEdit: !!share.allow_edit, isOwner: false };
|
||||
}
|
||||
|
||||
function toOpenSourceSubmissionSummary(
|
||||
row: OpenSourceSubmissionRow,
|
||||
): OpenSourceSubmissionSummary {
|
||||
return {
|
||||
id: row.id,
|
||||
status: row.status,
|
||||
submitted_at: row.submitted_at,
|
||||
updated_at: row.updated_at,
|
||||
reviewed_at: row.reviewed_at ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async function getLatestOpenSourceSubmission(
|
||||
db: Db,
|
||||
workflowId: string,
|
||||
userId: string,
|
||||
): Promise<OpenSourceSubmissionSummary | null> {
|
||||
const { data, error } = await db
|
||||
.from("workflow_open_source_submissions")
|
||||
.select("id, status, submitted_at, updated_at, reviewed_at")
|
||||
.eq("workflow_id", workflowId)
|
||||
.eq("submitted_by_user_id", userId)
|
||||
.order("submitted_at", { ascending: false })
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
if (error) throw error;
|
||||
return data ? toOpenSourceSubmissionSummary(data as OpenSourceSubmissionRow) : null;
|
||||
}
|
||||
|
||||
function buildOpenSourceSnapshot(
|
||||
workflow: WorkflowRecord,
|
||||
contributors: WorkflowContributor[],
|
||||
contributorMode: "named" | "anonymous",
|
||||
) {
|
||||
return {
|
||||
workflow_id: workflow.id,
|
||||
title: workflow.title ?? "",
|
||||
type: workflow.type ?? "",
|
||||
prompt_md: workflow.prompt_md ?? null,
|
||||
columns_config: workflow.columns_config ?? null,
|
||||
contributors,
|
||||
contributor_mode: contributorMode,
|
||||
language: workflow.language ?? DEFAULT_WORKFLOW_LANGUAGE,
|
||||
version: workflow.version ?? null,
|
||||
practice: workflow.practice ?? DEFAULT_WORKFLOW_PRACTICE,
|
||||
jurisdictions: workflow.jurisdictions ?? DEFAULT_WORKFLOW_JURISDICTIONS,
|
||||
created_at: workflow.created_at ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function validateOpenSourceWorkflow(workflow: WorkflowRecord): string | null {
|
||||
if (workflow.type === "assistant") {
|
||||
return typeof workflow.prompt_md === "string" && workflow.prompt_md.trim()
|
||||
? null
|
||||
: "Assistant workflows need instructions before they can be opened source.";
|
||||
}
|
||||
if (workflow.type === "tabular") {
|
||||
return Array.isArray(workflow.columns_config) && workflow.columns_config.length > 0
|
||||
? null
|
||||
: "Tabular workflows need at least one column before they can be opened source.";
|
||||
}
|
||||
return "Workflow type must be 'assistant' or 'tabular'.";
|
||||
}
|
||||
|
||||
// GET /workflows
|
||||
workflowsRouter.get("/", requireAuth, asyncRoute(async (req, res) => {
|
||||
const userId = res.locals.userId as string;
|
||||
const userEmail = res.locals.userEmail as string | undefined;
|
||||
const { type } = req.query as { type?: string };
|
||||
const db = createServerSupabase();
|
||||
const workflowType = typeof type === "string" && type ? type : null;
|
||||
|
||||
const { data, error } = await db.rpc("get_workflows_overview", {
|
||||
p_user_id: userId,
|
||||
p_user_email: userEmail ?? null,
|
||||
p_type: typeof type === "string" && type ? type : null,
|
||||
p_type: workflowType,
|
||||
});
|
||||
if (error) return void res.status(500).json({ detail: error.message });
|
||||
if (error) {
|
||||
return void res.status(500).json({ detail: error.message });
|
||||
}
|
||||
|
||||
res.json(data ?? []);
|
||||
const systemWorkflows = SYSTEM_WORKFLOWS.filter(
|
||||
(workflow) => !workflowType || workflow.type === workflowType,
|
||||
).map(withSystemWorkflowAccess);
|
||||
const databaseWorkflows = ((data ?? []) as WorkflowRecord[]).filter(
|
||||
(workflow) => !SYSTEM_WORKFLOW_IDS.has(workflow.id),
|
||||
);
|
||||
|
||||
res.json([...systemWorkflows, ...databaseWorkflows]);
|
||||
}));
|
||||
|
||||
// POST /workflows
|
||||
workflowsRouter.post("/", requireAuth, asyncRoute(async (req, res) => {
|
||||
const userId = res.locals.userId as string;
|
||||
const { title, type, prompt_md, columns_config, practice } = req.body as {
|
||||
const {
|
||||
title,
|
||||
type,
|
||||
prompt_md,
|
||||
columns_config,
|
||||
language,
|
||||
practice,
|
||||
jurisdictions,
|
||||
} = req.body as {
|
||||
title: string;
|
||||
type: string;
|
||||
prompt_md?: string;
|
||||
columns_config?: unknown;
|
||||
language?: unknown;
|
||||
practice?: string | null;
|
||||
jurisdictions?: unknown;
|
||||
};
|
||||
if (!title?.trim())
|
||||
return void res.status(400).json({ detail: "title is required" });
|
||||
|
|
@ -107,6 +317,17 @@ workflowsRouter.post("/", requireAuth, asyncRoute(async (req, res) => {
|
|||
.json({ detail: "type must be 'assistant' or 'tabular'" });
|
||||
|
||||
const db = createServerSupabase();
|
||||
devLog("[workflows/create] request", {
|
||||
userId,
|
||||
title: title.trim(),
|
||||
type,
|
||||
hasPrompt: typeof prompt_md === "string" && prompt_md.length > 0,
|
||||
columnCount: Array.isArray(columns_config) ? columns_config.length : null,
|
||||
language: normalizeOptionalString(language) ?? DEFAULT_WORKFLOW_LANGUAGE,
|
||||
practice: practice ?? null,
|
||||
jurisdictions:
|
||||
normalizeJurisdictions(jurisdictions) ?? DEFAULT_WORKFLOW_JURISDICTIONS,
|
||||
});
|
||||
const { data, error } = await db
|
||||
.from("workflows")
|
||||
.insert({
|
||||
|
|
@ -115,13 +336,33 @@ workflowsRouter.post("/", requireAuth, asyncRoute(async (req, res) => {
|
|||
type,
|
||||
prompt_md: prompt_md ?? null,
|
||||
columns_config: columns_config ?? null,
|
||||
practice: practice ?? null,
|
||||
is_system: false,
|
||||
language: normalizeOptionalString(language) ?? DEFAULT_WORKFLOW_LANGUAGE,
|
||||
practice:
|
||||
normalizeOptionalString(practice) ?? DEFAULT_WORKFLOW_PRACTICE,
|
||||
jurisdictions:
|
||||
normalizeJurisdictions(jurisdictions) ?? DEFAULT_WORKFLOW_JURISDICTIONS,
|
||||
})
|
||||
.select("*")
|
||||
.single();
|
||||
if (error) return void res.status(500).json({ detail: error.message });
|
||||
res.status(201).json(data);
|
||||
if (error) {
|
||||
devLog("[workflows/create] insert error", {
|
||||
userId,
|
||||
title: title.trim(),
|
||||
type,
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
details: error.details,
|
||||
hint: error.hint,
|
||||
});
|
||||
return void res.status(500).json({ detail: error.message });
|
||||
}
|
||||
devLog("[workflows/create] inserted", {
|
||||
id: data?.id,
|
||||
user_id: data?.user_id,
|
||||
title: data?.title,
|
||||
type: data?.type,
|
||||
});
|
||||
res.status(201).json(withDatabaseWorkflow(data));
|
||||
}));
|
||||
|
||||
async function handleWorkflowUpdate(req: Request, res: Response) {
|
||||
|
|
@ -133,11 +374,15 @@ async function handleWorkflowUpdate(req: Request, res: Response) {
|
|||
if (req.body.prompt_md != null) updates.prompt_md = req.body.prompt_md;
|
||||
if (req.body.columns_config != null)
|
||||
updates.columns_config = req.body.columns_config;
|
||||
if ("language" in req.body)
|
||||
updates.language = normalizeOptionalString(req.body.language);
|
||||
if ("practice" in req.body) updates.practice = req.body.practice ?? null;
|
||||
if ("jurisdictions" in req.body)
|
||||
updates.jurisdictions = normalizeJurisdictions(req.body.jurisdictions);
|
||||
|
||||
const db = createServerSupabase();
|
||||
const access = await resolveWorkflowAccess(workflowId, userId, userEmail, db);
|
||||
if (!access || access.workflow.is_system || !access.allowEdit) {
|
||||
if (!access || !access.allowEdit) {
|
||||
return void res
|
||||
.status(404)
|
||||
.json({ detail: "Workflow not found or not editable" });
|
||||
|
|
@ -146,7 +391,6 @@ async function handleWorkflowUpdate(req: Request, res: Response) {
|
|||
.from("workflows")
|
||||
.update(updates)
|
||||
.eq("id", workflowId)
|
||||
.eq("is_system", false)
|
||||
.select("*")
|
||||
.single();
|
||||
if (error || !data)
|
||||
|
|
@ -154,7 +398,7 @@ async function handleWorkflowUpdate(req: Request, res: Response) {
|
|||
.status(404)
|
||||
.json({ detail: "Workflow not found or not editable" });
|
||||
res.json(
|
||||
withWorkflowAccess(data, {
|
||||
withWorkflowAccess(withDatabaseWorkflow(data), {
|
||||
allowEdit: access.allowEdit,
|
||||
isOwner: access.isOwner,
|
||||
}),
|
||||
|
|
@ -171,13 +415,19 @@ workflowsRouter.patch("/:workflowId", requireAuth, asyncRoute(handleWorkflowUpda
|
|||
workflowsRouter.delete("/:workflowId", requireAuth, asyncRoute(async (req, res) => {
|
||||
const userId = res.locals.userId as string;
|
||||
const { workflowId } = req.params;
|
||||
const systemWorkflow = SYSTEM_WORKFLOWS.find(
|
||||
(workflow) => workflow.id === workflowId,
|
||||
);
|
||||
if (systemWorkflow) {
|
||||
return void res.json(withSystemWorkflowAccess(systemWorkflow));
|
||||
}
|
||||
|
||||
const db = createServerSupabase();
|
||||
const { error } = await db
|
||||
.from("workflows")
|
||||
.delete()
|
||||
.eq("id", workflowId)
|
||||
.eq("user_id", userId)
|
||||
.eq("is_system", false);
|
||||
.eq("user_id", userId);
|
||||
if (error) return void res.status(500).json({ detail: error.message });
|
||||
res.status(204).send();
|
||||
}));
|
||||
|
|
@ -222,20 +472,160 @@ workflowsRouter.delete("/hidden/:workflowId", requireAuth, asyncRoute(async (req
|
|||
res.status(204).send();
|
||||
}));
|
||||
|
||||
// POST /workflows/:workflowId/open-source
|
||||
workflowsRouter.post("/:workflowId/open-source", requireAuth, asyncRoute(async (req, res) => {
|
||||
if (!WORKFLOW_CONTRIBUTIONS_ENABLED) {
|
||||
return void res.status(404).json({ detail: "Workflow contributions are disabled" });
|
||||
}
|
||||
|
||||
const userId = res.locals.userId as string;
|
||||
const userEmail = res.locals.userEmail as string | undefined;
|
||||
const { workflowId } = req.params;
|
||||
const openSourceBody = req.body as {
|
||||
contributor_mode?: unknown;
|
||||
contributor?: unknown;
|
||||
};
|
||||
const requestedContributorMode =
|
||||
openSourceBody.contributor_mode === "named"
|
||||
? "named"
|
||||
: "anonymous";
|
||||
const db = createServerSupabase();
|
||||
|
||||
const { data: workflow, error: workflowError } = await db
|
||||
.from("workflows")
|
||||
.select("*")
|
||||
.eq("id", workflowId)
|
||||
.eq("user_id", userId)
|
||||
.maybeSingle();
|
||||
if (workflowError) {
|
||||
return void res.status(500).json({ detail: workflowError.message });
|
||||
}
|
||||
if (!workflow) {
|
||||
return void res
|
||||
.status(404)
|
||||
.json({ detail: "Workflow not found or not open-sourceable" });
|
||||
}
|
||||
|
||||
const workflowRecord = withDatabaseWorkflow(workflow as WorkflowRecord);
|
||||
const validationError = validateOpenSourceWorkflow(workflowRecord);
|
||||
if (validationError) {
|
||||
return void res.status(400).json({ detail: validationError });
|
||||
}
|
||||
|
||||
const { data: profile } = await db
|
||||
.from("user_profiles")
|
||||
.select("display_name")
|
||||
.eq("user_id", userId)
|
||||
.maybeSingle();
|
||||
const submitterName =
|
||||
typeof profile?.display_name === "string" && profile.display_name.trim()
|
||||
? profile.display_name.trim()
|
||||
: null;
|
||||
const submittedContributor =
|
||||
normalizeContributors([openSourceBody.contributor])?.[0] ??
|
||||
contributorFromName(submitterName || userEmail);
|
||||
const publicContributors =
|
||||
requestedContributorMode === "named"
|
||||
? [submittedContributor]
|
||||
: [DEFAULT_WORKFLOW_CONTRIBUTOR];
|
||||
const now = new Date().toISOString();
|
||||
const snapshot = buildOpenSourceSnapshot(
|
||||
workflowRecord,
|
||||
publicContributors,
|
||||
requestedContributorMode,
|
||||
);
|
||||
|
||||
const { data: pendingSubmission, error: pendingError } = await db
|
||||
.from("workflow_open_source_submissions")
|
||||
.select("*")
|
||||
.eq("workflow_id", workflowId)
|
||||
.eq("submitted_by_user_id", userId)
|
||||
.eq("status", "pending")
|
||||
.maybeSingle();
|
||||
if (pendingError) {
|
||||
return void res.status(500).json({ detail: pendingError.message });
|
||||
}
|
||||
|
||||
if (pendingSubmission) {
|
||||
const { data: updated, error: updateError } = await db
|
||||
.from("workflow_open_source_submissions")
|
||||
.update({
|
||||
submitter_email: userEmail ?? null,
|
||||
submitter_name:
|
||||
requestedContributorMode === "named" ? submitterName : null,
|
||||
contributor_mode: requestedContributorMode,
|
||||
snapshot,
|
||||
updated_at: now,
|
||||
})
|
||||
.eq("id", pendingSubmission.id)
|
||||
.select("id, status, submitted_at, updated_at, reviewed_at")
|
||||
.single();
|
||||
if (updateError || !updated) {
|
||||
return void res.status(500).json({
|
||||
detail: updateError?.message ?? "Failed to update submission",
|
||||
});
|
||||
}
|
||||
return void res.json({
|
||||
...toOpenSourceSubmissionSummary(updated as OpenSourceSubmissionRow),
|
||||
mode: "updated",
|
||||
});
|
||||
}
|
||||
|
||||
const { data: created, error: createError } = await db
|
||||
.from("workflow_open_source_submissions")
|
||||
.insert({
|
||||
workflow_id: workflowId,
|
||||
submitted_by_user_id: userId,
|
||||
submitter_email: userEmail ?? null,
|
||||
submitter_name:
|
||||
requestedContributorMode === "named" ? submitterName : null,
|
||||
contributor_mode: requestedContributorMode,
|
||||
status: "pending",
|
||||
snapshot,
|
||||
submitted_at: now,
|
||||
updated_at: now,
|
||||
})
|
||||
.select("id, status, submitted_at, updated_at, reviewed_at")
|
||||
.single();
|
||||
if (createError || !created) {
|
||||
return void res.status(500).json({
|
||||
detail: createError?.message ?? "Failed to create submission",
|
||||
});
|
||||
}
|
||||
|
||||
res.status(201).json({
|
||||
...toOpenSourceSubmissionSummary(created as OpenSourceSubmissionRow),
|
||||
mode: "created",
|
||||
});
|
||||
}));
|
||||
|
||||
// GET /workflows/:workflowId
|
||||
workflowsRouter.get("/:workflowId", requireAuth, asyncRoute(async (req, res) => {
|
||||
const userId = res.locals.userId as string;
|
||||
const userEmail = res.locals.userEmail as string | undefined;
|
||||
const { workflowId } = req.params;
|
||||
const systemWorkflow = SYSTEM_WORKFLOWS.find(
|
||||
(workflow) => workflow.id === workflowId,
|
||||
);
|
||||
if (systemWorkflow) {
|
||||
return void res.json(withSystemWorkflowAccess(systemWorkflow));
|
||||
}
|
||||
|
||||
const db = createServerSupabase();
|
||||
const access = await resolveWorkflowAccess(workflowId, userId, userEmail, db);
|
||||
if (!access)
|
||||
return void res.status(404).json({ detail: "Workflow not found" });
|
||||
const openSourceSubmission = access.isOwner
|
||||
? await getLatestOpenSourceSubmission(db, workflowId, userId)
|
||||
: null;
|
||||
res.json(
|
||||
withWorkflowAccess(access.workflow, {
|
||||
allowEdit: access.allowEdit,
|
||||
isOwner: access.isOwner,
|
||||
}),
|
||||
withOpenSourceSubmission(
|
||||
withWorkflowAccess(access.workflow, {
|
||||
allowEdit: access.allowEdit,
|
||||
isOwner: access.isOwner,
|
||||
}),
|
||||
openSourceSubmission,
|
||||
),
|
||||
);
|
||||
}));
|
||||
|
||||
|
|
@ -250,7 +640,6 @@ workflowsRouter.get("/:workflowId/shares", requireAuth, asyncRoute(async (req, r
|
|||
.select("id")
|
||||
.eq("id", workflowId)
|
||||
.eq("user_id", userId)
|
||||
.eq("is_system", false)
|
||||
.single();
|
||||
if (!wf) return void res.status(404).json({ detail: "Workflow not found or not editable" });
|
||||
|
||||
|
|
@ -308,13 +697,19 @@ workflowsRouter.post("/:workflowId/share", requireAuth, asyncRoute(async (req, r
|
|||
}
|
||||
|
||||
const db = createServerSupabase();
|
||||
const missingSharedUsers = await findMissingUserEmails(db, normalizedEmails);
|
||||
if (missingSharedUsers.length > 0) {
|
||||
return void res.status(400).json({
|
||||
detail: `${missingSharedUsers[0]} does not belong to a Mike user.`,
|
||||
});
|
||||
}
|
||||
|
||||
// Verify ownership
|
||||
const { data: wf } = await db
|
||||
.from("workflows")
|
||||
.select("id")
|
||||
.eq("id", workflowId)
|
||||
.eq("user_id", userId)
|
||||
.eq("is_system", false)
|
||||
.single();
|
||||
if (!wf) return void res.status(404).json({ detail: "Workflow not found or not editable" });
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue