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
|
|
@ -20,6 +20,19 @@ Thanks for helping improve Mike. Please keep contributions small, focused, and e
|
|||
- why
|
||||
- testing
|
||||
|
||||
## System Workflows
|
||||
|
||||
System workflows live in `workflows/`. Update `metadata.json` for structured
|
||||
metadata like author, language, version, practice area, or jurisdictions,
|
||||
update `SKILL.md` for workflow instructions, and use `table-config.json` for
|
||||
tabular review columns.
|
||||
|
||||
After changing system workflows, regenerate the app files:
|
||||
|
||||
```bash
|
||||
node scripts/build-workflows.js
|
||||
```
|
||||
|
||||
## Security
|
||||
|
||||
Do not open a public issue for security vulnerabilities. Use [GitHub's private vulnerability reporting](https://github.com/willchen96/mike/security/advisories/new) instead.
|
||||
|
|
|
|||
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" });
|
||||
|
||||
|
|
|
|||
171
frontend/package-lock.json
generated
171
frontend/package-lock.json
generated
|
|
@ -10,6 +10,8 @@
|
|||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1025.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1025.0",
|
||||
"@fortune-sheet/core": "^1.0.4",
|
||||
"@fortune-sheet/react": "^1.0.4",
|
||||
"@opennextjs/cloudflare": "^1.19.9",
|
||||
"@openrouter/sdk": "^0.3.11",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
|
|
@ -18,6 +20,8 @@
|
|||
"@supabase/auth-helpers-nextjs": "^0.10.0",
|
||||
"@supabase/auth-js": "^2.101.1",
|
||||
"@supabase/supabase-js": "^2.81.1",
|
||||
"@tiptap/core": "^3.22.3",
|
||||
"@tiptap/extension-table": "^3.22.3",
|
||||
"@tiptap/pm": "^3.22.3",
|
||||
"@tiptap/react": "^3.22.3",
|
||||
"@tiptap/starter-kit": "^3.22.3",
|
||||
|
|
@ -31,6 +35,7 @@
|
|||
"exceljs": "^4.4.0",
|
||||
"katex": "^0.16.27",
|
||||
"lucide-react": "^0.553.0",
|
||||
"luckyexcel": "^1.0.1",
|
||||
"mammoth": "^1.11.0",
|
||||
"marked": "^17.0.1",
|
||||
"next": "^16.2.6",
|
||||
|
|
@ -2538,6 +2543,90 @@
|
|||
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@formulajs/formulajs": {
|
||||
"version": "2.9.3",
|
||||
"resolved": "https://registry.npmjs.org/@formulajs/formulajs/-/formulajs-2.9.3.tgz",
|
||||
"integrity": "sha512-WpgiuJaBl/Hcda9Ti8a7mlnw/vUZkJrtjABvojz6P6mo6d8EscudyA7iAt/kTLsgi0c8zfmzQd/Yjb4ASFkw+g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bessel": "^1.0.2",
|
||||
"jstat": "^1.9.2"
|
||||
},
|
||||
"bin": {
|
||||
"implementation-stats": "bin/implementation-stats"
|
||||
}
|
||||
},
|
||||
"node_modules/@fortune-sheet/core": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@fortune-sheet/core/-/core-1.0.4.tgz",
|
||||
"integrity": "sha512-CDnUVebfvtT++CymNRw0qnY4sz6zYO3KZazlH+nG6otkRkZ05Bvt7NXqVhmKKSSxIvJR4R6h50abu/dVGBpjpw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fortune-sheet/formula-parser": "^0.2.13",
|
||||
"dayjs": "^1.11.0",
|
||||
"immer": "^9.0.12",
|
||||
"lodash": "^4.17.21",
|
||||
"numeral": "^2.0.6",
|
||||
"uuid": "^8.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@fortune-sheet/core/node_modules/immer": {
|
||||
"version": "9.0.21",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-9.0.21.tgz",
|
||||
"integrity": "sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/@fortune-sheet/core/node_modules/uuid": {
|
||||
"version": "8.3.2",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
||||
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
|
||||
"deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/@fortune-sheet/formula-parser": {
|
||||
"version": "0.2.13",
|
||||
"resolved": "https://registry.npmjs.org/@fortune-sheet/formula-parser/-/formula-parser-0.2.13.tgz",
|
||||
"integrity": "sha512-za2ZVQ5ZfMSPCtL8MdqhCthPip7AoeU4MPyd5UDo4RCl9/arrv1Jz0y755mACVVi4suSZxrzWoJGDkLmofoSmw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@formulajs/formulajs": "^2.9.3",
|
||||
"tiny-emitter": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fortune-sheet/react": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@fortune-sheet/react/-/react-1.0.4.tgz",
|
||||
"integrity": "sha512-v+BU5mmp2hhUe4gRF+vOJ3j8zZkzHU0kzhTZzp+Nn00wA/RArMr+CO+SAluKlCPAsTZgPwmgOEK685WPqJ5XNw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fortune-sheet/core": "^1.0.4",
|
||||
"@types/regenerator-runtime": "^0.13.6",
|
||||
"immer": "^9.0.12",
|
||||
"lodash": "^4.17.21",
|
||||
"regenerator-runtime": "^0.14.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">= 18.2",
|
||||
"react-dom": ">= 18.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@fortune-sheet/react/node_modules/immer": {
|
||||
"version": "9.0.21",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-9.0.21.tgz",
|
||||
"integrity": "sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanfs/core": {
|
||||
"version": "0.19.1",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
||||
|
|
@ -6061,6 +6150,20 @@
|
|||
"@tiptap/core": "^3.22.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-table": {
|
||||
"version": "3.22.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-table/-/extension-table-3.22.3.tgz",
|
||||
"integrity": "sha512-inbQSusJad7H0T++L1APg/anfL5d15cNGp2YG3vwo6TQr71nn2c9pepvmz3xuAIt8eygZDRba+4GT/COP1f9QA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^3.22.3",
|
||||
"@tiptap/pm": "^3.22.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-text": {
|
||||
"version": "3.22.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.22.3.tgz",
|
||||
|
|
@ -6425,6 +6528,12 @@
|
|||
"@types/react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/regenerator-runtime": {
|
||||
"version": "0.13.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/regenerator-runtime/-/regenerator-runtime-0.13.8.tgz",
|
||||
"integrity": "sha512-jjKoBekfYDH331060tZhosdJVDnXIXx+T8Iw2h2T4HEds6Ddb2lr0JxD15+XPKlXwRHRNgZoY+4Fb2ykoqzHBg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/tough-cookie": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz",
|
||||
|
|
@ -7614,6 +7723,15 @@
|
|||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/bessel": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/bessel/-/bessel-1.0.2.tgz",
|
||||
"integrity": "sha512-Al3nHGQGqDYqqinXhQzmwmcRToe/3WyBv4N8aZc5Pef8xw2neZlR9VPi84Sa23JtgWcucu18HxVZrnI0fn2etw==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/big-integer": {
|
||||
"version": "1.6.52",
|
||||
"resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz",
|
||||
|
|
@ -11748,6 +11866,11 @@
|
|||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/jstat": {
|
||||
"version": "1.9.6",
|
||||
"resolved": "https://registry.npmjs.org/jstat/-/jstat-1.9.6.tgz",
|
||||
"integrity": "sha512-rPBkJbK2TnA8pzs93QcDDPlKcrtZWuuCo2dVR0TFLOJSxhqfWOVCSp8aV3/oSbn+4uY4yw1URtLpHQedtmXfug=="
|
||||
},
|
||||
"node_modules/jsx-ast-utils": {
|
||||
"version": "3.3.5",
|
||||
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
|
||||
|
|
@ -12173,6 +12296,12 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.defaults": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
|
||||
|
|
@ -12311,6 +12440,15 @@
|
|||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/luckyexcel": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/luckyexcel/-/luckyexcel-1.0.1.tgz",
|
||||
"integrity": "sha512-hvbJmCXNp/vST/huA6sieDn32Ib8bd80L9aIu5ZGxniJvZle7VlpHZrl6weLGaEnX99+t7cPAoYGqrqbfZp/AQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jszip": "^3.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
|
|
@ -13783,6 +13921,15 @@
|
|||
"url": "https://github.com/fb55/nth-check?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/numeral": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/numeral/-/numeral-2.0.6.tgz",
|
||||
"integrity": "sha512-qaKRmtYPZ5qdw4jWJD6bxEf1FJEqllJrwxCLIm0sQU/A7v2/czigzOb+C2uSiFsa9lBUzeH7M1oK+Q+OLxL3kA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
|
|
@ -14426,9 +14573,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/prosemirror-menu": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-menu/-/prosemirror-menu-1.3.1.tgz",
|
||||
"integrity": "sha512-2OSIKBFyLo2iqDpjQHEC7tKt3lluhY7L44pcRai8EpoU9R7cZDj/dklEsOOIubNKWUXab6dL7y4JtAWnrlR4lA==",
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-menu/-/prosemirror-menu-1.3.2.tgz",
|
||||
"integrity": "sha512-6VgUJTYod0nMBlCaYJGhXGLu7Gt4AvcwcOq0YfJCY/6Uh+3S7UsWhpy6rJFCBFOmonq1hD8KyWOtZhkppd4YPg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"crelt": "^1.0.0",
|
||||
|
|
@ -14438,9 +14585,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/prosemirror-model": {
|
||||
"version": "1.25.4",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz",
|
||||
"integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==",
|
||||
"version": "1.25.9",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.9.tgz",
|
||||
"integrity": "sha512-pRTklkDDMMRopyoAcrr9wV/8g/RYgrLHBuJAb5hlEuYZRdm5yqmPjWId83fpBwPpSFqEdja0H7Dfd7z1X/npcA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"orderedmap": "^2.0.0"
|
||||
|
|
@ -14892,6 +15039,12 @@
|
|||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/regenerator-runtime": {
|
||||
"version": "0.14.1",
|
||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
|
||||
"integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/regexp.prototype.flags": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
|
||||
|
|
@ -16275,6 +16428,12 @@
|
|||
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tiny-emitter": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz",
|
||||
"integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tiny-invariant": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"prebuild": "node ../scripts/build-workflows.js",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint",
|
||||
|
|
@ -15,6 +16,8 @@
|
|||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1025.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1025.0",
|
||||
"@fortune-sheet/core": "^1.0.4",
|
||||
"@fortune-sheet/react": "^1.0.4",
|
||||
"@opennextjs/cloudflare": "^1.19.9",
|
||||
"@openrouter/sdk": "^0.3.11",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
|
|
@ -23,6 +26,8 @@
|
|||
"@supabase/auth-helpers-nextjs": "^0.10.0",
|
||||
"@supabase/auth-js": "^2.101.1",
|
||||
"@supabase/supabase-js": "^2.81.1",
|
||||
"@tiptap/core": "^3.22.3",
|
||||
"@tiptap/extension-table": "^3.22.3",
|
||||
"@tiptap/pm": "^3.22.3",
|
||||
"@tiptap/react": "^3.22.3",
|
||||
"@tiptap/starter-kit": "^3.22.3",
|
||||
|
|
@ -36,6 +41,7 @@
|
|||
"exceljs": "^4.4.0",
|
||||
"katex": "^0.16.27",
|
||||
"lucide-react": "^0.553.0",
|
||||
"luckyexcel": "^1.0.1",
|
||||
"mammoth": "^1.11.0",
|
||||
"marked": "^17.0.1",
|
||||
"next": "^16.2.6",
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { useUserProfile } from "@/contexts/UserProfileContext";
|
|||
import {
|
||||
MfaVerificationPopup,
|
||||
needsMfaVerification,
|
||||
} from "@/app/components/shared/MfaVerificationPopup";
|
||||
} from "@/app/components/popups/MfaVerificationPopup";
|
||||
import { isMfaRequiredError } from "@/app/lib/mikeApi";
|
||||
import {
|
||||
accountGlassIconButtonClassName,
|
||||
|
|
|
|||
|
|
@ -3,20 +3,19 @@
|
|||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
ChevronDown,
|
||||
Check,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Loader2,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Modal } from "@/app/components/shared/Modal";
|
||||
import { Modal } from "@/app/components/modals/Modal";
|
||||
import { NewMcpModal } from "@/app/components/account/NewMcpModal";
|
||||
import {
|
||||
MfaVerificationPopup,
|
||||
needsMfaVerification,
|
||||
} from "@/app/components/shared/MfaVerificationPopup";
|
||||
} from "@/app/components/popups/MfaVerificationPopup";
|
||||
import {
|
||||
type McpConnectorSummary,
|
||||
MikeApiError,
|
||||
|
|
@ -31,7 +30,6 @@ import {
|
|||
updateMcpConnector,
|
||||
} from "@/app/lib/mikeApi";
|
||||
import {
|
||||
accountGlassDangerButtonClassName,
|
||||
accountGlassIconButtonClassName,
|
||||
accountGlassInputClassName,
|
||||
accountGlassPrimaryButtonClassName,
|
||||
|
|
@ -634,7 +632,7 @@ export default function ConnectorsPage() {
|
|||
)}
|
||||
</div>
|
||||
|
||||
<AddMcpConnectorModal
|
||||
<NewMcpModal
|
||||
open={addOpen}
|
||||
draft={addDraft}
|
||||
step={addStep}
|
||||
|
|
@ -793,129 +791,6 @@ function ConnectorRow({
|
|||
);
|
||||
}
|
||||
|
||||
function AddMcpConnectorModal({
|
||||
open,
|
||||
draft,
|
||||
step,
|
||||
result,
|
||||
error,
|
||||
authMessage,
|
||||
showToken,
|
||||
showAdvanced,
|
||||
onDraftChange,
|
||||
onShowTokenChange,
|
||||
onShowAdvancedChange,
|
||||
onClose,
|
||||
onSubmit,
|
||||
onOpenConnector,
|
||||
}: {
|
||||
open: boolean;
|
||||
draft: AddDraft;
|
||||
step: AddStep;
|
||||
result: McpConnectorSummary | null;
|
||||
error: string | null;
|
||||
authMessage: string | null;
|
||||
showToken: boolean;
|
||||
showAdvanced: boolean;
|
||||
onDraftChange: (draft: AddDraft) => void;
|
||||
onShowTokenChange: (show: boolean) => void;
|
||||
onShowAdvancedChange: (show: boolean) => void;
|
||||
onClose: () => void;
|
||||
onSubmit: () => Promise<void>;
|
||||
onOpenConnector: (connectorId: string) => void;
|
||||
}) {
|
||||
const canSubmit =
|
||||
draft.name.trim().length > 0 &&
|
||||
draft.serverUrl.trim().length > 0 &&
|
||||
step !== "working" &&
|
||||
step !== "auth";
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
breadcrumbs={[
|
||||
"Connectors",
|
||||
step === "success"
|
||||
? "Connector added"
|
||||
: step === "auth"
|
||||
? "Authenticate connector"
|
||||
: "Add MCP connector",
|
||||
]}
|
||||
size="lg"
|
||||
primaryAction={
|
||||
step === "success" && result
|
||||
? {
|
||||
label: "View connector",
|
||||
onClick: () => onOpenConnector(result.id),
|
||||
}
|
||||
: {
|
||||
label:
|
||||
step === "working"
|
||||
? "Connecting..."
|
||||
: step === "auth"
|
||||
? "Authorizing..."
|
||||
: "Connect",
|
||||
icon:
|
||||
step === "working" || step === "auth" ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : undefined,
|
||||
onClick: () => void onSubmit(),
|
||||
disabled: !canSubmit,
|
||||
}
|
||||
}
|
||||
cancelAction={
|
||||
step === "working" || step === "auth"
|
||||
? false
|
||||
: { label: step === "success" ? "Done" : "Cancel", onClick: onClose }
|
||||
}
|
||||
footerStatus={
|
||||
error ? (
|
||||
<div className="rounded-xl border border-white/70 bg-white/75 px-3 py-2 text-sm text-red-600 shadow-[0_12px_32px_rgba(15,23,42,0.10),inset_0_1px_0_rgba(255,255,255,0.75)] backdrop-blur-xl">
|
||||
{error}
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{step === "success" && result ? (
|
||||
<SuccessToolsList connector={result} />
|
||||
) : step === "auth" ? (
|
||||
<ConnectorAuthScreen
|
||||
message={
|
||||
authMessage ??
|
||||
"Complete authorization in the popup to finish connecting this MCP server."
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-4 pb-4">
|
||||
<p className="text-sm text-gray-500">
|
||||
The assistant will have access to this MCP server and
|
||||
its enabled tools.
|
||||
</p>
|
||||
<ConnectorForm
|
||||
draft={draft}
|
||||
showToken={showToken}
|
||||
showAdvanced={showAdvanced}
|
||||
showTokenNote
|
||||
tokenPlaceholder="Bearer token"
|
||||
disabled={step === "working"}
|
||||
onDraftChange={(next) =>
|
||||
onDraftChange({
|
||||
name: next.name,
|
||||
serverUrl: next.serverUrl,
|
||||
bearerToken: next.bearerToken,
|
||||
customHeaders: next.customHeaders,
|
||||
})
|
||||
}
|
||||
onShowTokenChange={onShowTokenChange}
|
||||
onShowAdvancedChange={onShowAdvancedChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function McpConnectorDetailsModal({
|
||||
connector,
|
||||
draft,
|
||||
|
|
@ -1020,7 +895,7 @@ function McpConnectorDetailsModal({
|
|||
}
|
||||
>
|
||||
{connector && (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-5 pb-4">
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-5 overflow-y-auto pb-4">
|
||||
<ConnectorForm
|
||||
draft={draft}
|
||||
showToken={showToken}
|
||||
|
|
@ -1290,41 +1165,6 @@ function ConnectorForm({
|
|||
);
|
||||
}
|
||||
|
||||
function SuccessToolsList({ connector }: { connector: McpConnectorSummary }) {
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-1 flex-col gap-4 pb-4">
|
||||
<div className="flex items-start gap-3 rounded-xl border border-green-100/80 bg-green-50/80 px-3 py-3 text-green-800 shadow-[0_3px_9px_rgba(15,23,42,0.03),inset_0_1px_0_rgba(255,255,255,0.9),inset_0_-4px_9px_rgba(255,255,255,0.05)] backdrop-blur-xl">
|
||||
<Check className="mt-0.5 h-4 w-4 shrink-0 text-green-600" />
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium">
|
||||
{connector.name} is connected.{" "}
|
||||
<span className="font-normal text-green-700">
|
||||
{connector.tools.length} tools discovered.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ScrollableToolList connector={connector} fill />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConnectorAuthScreen({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-3 pb-4 text-center">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl border border-white/70 bg-white/75 text-gray-700 shadow-[0_3px_9px_rgba(15,23,42,0.03),inset_0_1px_0_rgba(255,255,255,0.9),inset_0_-4px_9px_rgba(255,255,255,0.05)] backdrop-blur-xl">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
<div className="max-w-sm space-y-1">
|
||||
<h3 className="text-sm font-medium text-gray-900">
|
||||
Authentication required
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">{message}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolListSkeleton({
|
||||
count,
|
||||
fill = false,
|
||||
|
|
|
|||
|
|
@ -7,12 +7,12 @@ import { Button } from "@/components/ui/button";
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useUserProfile } from "@/contexts/UserProfileContext";
|
||||
import { ConfirmPopup } from "@/app/components/shared/ConfirmPopup";
|
||||
import { ConfirmPopup } from "@/app/components/popups/ConfirmPopup";
|
||||
import {
|
||||
MfaVerificationPopup,
|
||||
needsMfaVerification,
|
||||
} from "@/app/components/shared/MfaVerificationPopup";
|
||||
import { WarningPopup } from "@/app/components/shared/WarningPopup";
|
||||
} from "@/app/components/popups/MfaVerificationPopup";
|
||||
import { WarningPopup } from "@/app/components/popups/WarningPopup";
|
||||
import { deleteAccount, isMfaRequiredError } from "@/app/lib/mikeApi";
|
||||
import {
|
||||
accountGlassDangerOutlineButtonClassName,
|
||||
|
|
|
|||
|
|
@ -4,11 +4,11 @@ import { useState } from "react";
|
|||
import { Download, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useChatHistoryContext } from "@/app/contexts/ChatHistoryContext";
|
||||
import { ConfirmPopup } from "@/app/components/shared/ConfirmPopup";
|
||||
import { ConfirmPopup } from "@/app/components/popups/ConfirmPopup";
|
||||
import {
|
||||
MfaVerificationPopup,
|
||||
needsMfaVerification,
|
||||
} from "@/app/components/shared/MfaVerificationPopup";
|
||||
} from "@/app/components/popups/MfaVerificationPopup";
|
||||
import {
|
||||
deleteAllChats,
|
||||
deleteAllProjects,
|
||||
|
|
|
|||
|
|
@ -12,11 +12,11 @@ import { supabase } from "@/lib/supabase";
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { useUserProfile } from "@/contexts/UserProfileContext";
|
||||
import { isMfaRequiredError } from "@/app/lib/mikeApi";
|
||||
import { Modal } from "@/app/components/shared/Modal";
|
||||
import { Modal } from "@/app/components/modals/Modal";
|
||||
import {
|
||||
MfaVerificationPopup,
|
||||
needsMfaVerification,
|
||||
} from "@/app/components/shared/MfaVerificationPopup";
|
||||
} from "@/app/components/popups/MfaVerificationPopup";
|
||||
import {
|
||||
accountGlassPrimaryButtonClassName,
|
||||
} from "../accountStyles";
|
||||
|
|
@ -578,7 +578,7 @@ export default function SecurityPage() {
|
|||
<Modal
|
||||
open={setupModalOpen}
|
||||
onClose={() => void closeSetupModal()}
|
||||
title="Set up authenticator app"
|
||||
breadcrumbs={["Security", "Set up authenticator app"]}
|
||||
cancelAction={{
|
||||
label: enrollment ? "Back" : "Cancel",
|
||||
onClick: enrollment
|
||||
|
|
@ -601,7 +601,13 @@ export default function SecurityPage() {
|
|||
}
|
||||
}
|
||||
>
|
||||
<div className={enrollment ? "space-y-3 pt-2" : "space-y-5 pt-3"}>
|
||||
<div
|
||||
className={
|
||||
enrollment
|
||||
? "min-h-0 flex-1 space-y-3 overflow-y-auto pt-2"
|
||||
: "min-h-0 flex-1 space-y-5 overflow-y-auto pt-3"
|
||||
}
|
||||
>
|
||||
{!enrollment ? (
|
||||
<>
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-gray-400">
|
||||
|
|
|
|||
|
|
@ -39,9 +39,10 @@ import { AssistantMessage } from "@/app/components/assistant/AssistantMessage";
|
|||
import { ChatInput } from "@/app/components/assistant/ChatInput";
|
||||
import type { ChatInputHandle } from "@/app/components/assistant/ChatInput";
|
||||
import { ProjectExplorer } from "@/app/components/projects/ProjectExplorer";
|
||||
import { DocView } from "@/app/components/shared/DocView";
|
||||
import { OwnerOnlyModal } from "@/app/components/shared/OwnerOnlyModal";
|
||||
import { DocxView } from "@/app/components/shared/DocxView";
|
||||
import { PdfView } from "@/app/components/shared/views/PdfView";
|
||||
import { SpreadsheetView } from "@/app/components/shared/views/SpreadsheetView";
|
||||
import { OwnerOnlyPopup } from "@/app/components/popups/OwnerOnlyPopup";
|
||||
import { DocxView } from "@/app/components/shared/views/DocxView";
|
||||
import { MikeIcon } from "@/components/chat/mike-icon";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useUserProfile } from "@/contexts/UserProfileContext";
|
||||
|
|
@ -50,13 +51,16 @@ import { PageHeader } from "@/app/components/shared/PageHeader";
|
|||
import { HeaderActionsMenu } from "@/app/components/shared/HeaderActionsMenu";
|
||||
import type {
|
||||
CitationQuote,
|
||||
CitationAnnotation,
|
||||
Citation,
|
||||
Document,
|
||||
EditAnnotation,
|
||||
Message,
|
||||
Project,
|
||||
} from "@/app/components/shared/types";
|
||||
import { expandCitationToEntries } from "@/app/components/shared/types";
|
||||
import {
|
||||
expandCitationToEntries,
|
||||
isSpreadsheetFilename,
|
||||
} from "@/app/components/shared/types";
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ id: string; chatId: string }>;
|
||||
|
|
@ -92,6 +96,7 @@ const EXPLORER_MIN = 160;
|
|||
const EXPLORER_DEFAULT = 280;
|
||||
const CHAT_MIN = 320;
|
||||
const CHAT_DEFAULT = 420;
|
||||
const DEFAULT_ASSISTANT_BOTTOM_PADDING = 116;
|
||||
|
||||
function AssistantGreeting({ username }: { username: string }) {
|
||||
const { profile } = useUserProfile();
|
||||
|
|
@ -260,6 +265,11 @@ export default function ProjectAssistantChatPage({ params }: Props) {
|
|||
const [initialMessages] = useState<Message[]>(newChatMessages ?? []);
|
||||
const { messages, isResponseLoading, handleChat, setMessages, cancel } =
|
||||
useAssistantChat({ initialMessages, chatId, projectId });
|
||||
const pendingInitialUserMessageRef = useRef<Message | null>(
|
||||
initialMessages.length === 1 && initialMessages[0].role === "user"
|
||||
? initialMessages[0]
|
||||
: null,
|
||||
);
|
||||
|
||||
const hasLoaded = useRef(false);
|
||||
const hasAutoSent = useRef(false);
|
||||
|
|
@ -349,19 +359,19 @@ export default function ProjectAssistantChatPage({ params }: Props) {
|
|||
}, [chats, chatId]);
|
||||
|
||||
useEffect(() => {
|
||||
const pendingMessage = pendingInitialUserMessageRef.current;
|
||||
if (
|
||||
newChatMessages &&
|
||||
newChatMessages.length === 1 &&
|
||||
newChatMessages[0].role === "user" &&
|
||||
pendingMessage &&
|
||||
!hasAutoSent.current &&
|
||||
!isResponseLoading &&
|
||||
messages.length === 1
|
||||
) {
|
||||
hasAutoSent.current = true;
|
||||
pendingInitialUserMessageRef.current = null;
|
||||
setNewChatMessages(null);
|
||||
void handleChat(newChatMessages[0]);
|
||||
void handleChat(pendingMessage);
|
||||
}
|
||||
}, [newChatMessages, messages.length, isResponseLoading]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [messages.length, isResponseLoading, handleChat, setNewChatMessages]);
|
||||
|
||||
const scrollLatestUserToTop = useCallback(() => {
|
||||
requestAnimationFrame(() => {
|
||||
|
|
@ -405,10 +415,17 @@ export default function ProjectAssistantChatPage({ params }: Props) {
|
|||
const userEl = latestUserMessageRef.current;
|
||||
const containerEl = messagesContainerRef.current;
|
||||
if (!userEl || !containerEl) return;
|
||||
const messageGap = window.innerWidth < 768 ? 24 : 32;
|
||||
setMinHeight(
|
||||
`${Math.max(0, containerEl.clientHeight - 48 - userEl.offsetHeight - 16)}px`,
|
||||
`${Math.max(
|
||||
0,
|
||||
containerEl.clientHeight -
|
||||
messageGap * 3 -
|
||||
userEl.offsetHeight -
|
||||
DEFAULT_ASSISTANT_BOTTOM_PADDING,
|
||||
)}px`,
|
||||
);
|
||||
}, [messages.length, latestUserMessageRef.current]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [messages.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeTabId) return;
|
||||
|
|
@ -489,7 +506,7 @@ export default function ProjectAssistantChatPage({ params }: Props) {
|
|||
openTab(doc.id, doc.filename);
|
||||
};
|
||||
|
||||
const handleCitationClick = (citation: CitationAnnotation) => {
|
||||
const handleCitationClick = (citation: Citation) => {
|
||||
if (citation.kind === "case") return;
|
||||
openTab(
|
||||
citation.document_id,
|
||||
|
|
@ -876,7 +893,7 @@ export default function ProjectAssistantChatPage({ params }: Props) {
|
|||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".pdf,.docx,.doc"
|
||||
accept=".pdf,.docx,.doc,.xlsx,.xlsm,.xls,.pptx,.ppt"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) =>
|
||||
|
|
@ -1101,8 +1118,16 @@ export default function ProjectAssistantChatPage({ params }: Props) {
|
|||
rounded={false}
|
||||
bordered={false}
|
||||
/>
|
||||
) : isSpreadsheetFilename(activeTab.filename) ? (
|
||||
<SpreadsheetView
|
||||
key={activeTab.documentId}
|
||||
documentId={activeTab.documentId}
|
||||
versionId={activeTab.versionId}
|
||||
rounded={false}
|
||||
bordered={false}
|
||||
/>
|
||||
) : (
|
||||
<DocView
|
||||
<PdfView
|
||||
key={activeTab.documentId}
|
||||
doc={{ document_id: activeTab.documentId }}
|
||||
quotes={activeQuotes ?? undefined}
|
||||
|
|
@ -1132,7 +1157,7 @@ export default function ProjectAssistantChatPage({ params }: Props) {
|
|||
{/* RIGHT: Assistant Panel */}
|
||||
<div
|
||||
style={{ width: chatWidth }}
|
||||
className="shrink-0 flex flex-col"
|
||||
className="relative shrink-0 flex flex-col"
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={handleChatDrop}
|
||||
>
|
||||
|
|
@ -1166,8 +1191,11 @@ export default function ProjectAssistantChatPage({ params }: Props) {
|
|||
) : (
|
||||
<div
|
||||
ref={messagesContainerRef}
|
||||
className="flex-1 overflow-y-auto px-4 py-4 space-y-4 min-h-0"
|
||||
style={{ scrollbarGutter: "stable" }}
|
||||
className="flex-1 overflow-y-auto px-4 pt-6 md:pt-8 space-y-6 md:space-y-8 min-h-0"
|
||||
style={{
|
||||
paddingBottom: DEFAULT_ASSISTANT_BOTTOM_PADDING,
|
||||
scrollbarGutter: "stable",
|
||||
}}
|
||||
>
|
||||
{(() => {
|
||||
const lastUserIdx = messages
|
||||
|
|
@ -1188,20 +1216,20 @@ export default function ProjectAssistantChatPage({ params }: Props) {
|
|||
>
|
||||
<UserMessage
|
||||
content={msg.content ?? ""}
|
||||
files={(msg as any).files}
|
||||
files={msg.files}
|
||||
workflow={msg.workflow}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<AssistantMessage
|
||||
key={i}
|
||||
content={msg.content ?? ""}
|
||||
events={msg.events}
|
||||
isStreaming={
|
||||
i === messages.length - 1 &&
|
||||
isResponseLoading
|
||||
}
|
||||
isError={!!(msg as any).error}
|
||||
annotations={msg.annotations}
|
||||
isError={!!msg.error}
|
||||
citations={msg.citations}
|
||||
citationStatus={
|
||||
msg.citationStatus
|
||||
}
|
||||
|
|
@ -1231,20 +1259,23 @@ export default function ProjectAssistantChatPage({ params }: Props) {
|
|||
)}
|
||||
|
||||
{/* ChatInput */}
|
||||
<div className="shrink-0 px-4 pb-4">
|
||||
<ChatInput
|
||||
ref={chatInputRef}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={cancel}
|
||||
isLoading={isResponseLoading}
|
||||
hideAddDocButton
|
||||
projectName={project?.name}
|
||||
projectCmNumber={project?.cm_number}
|
||||
/>
|
||||
<div className="absolute bottom-2 left-0 right-0 z-30 w-full md:bottom-3">
|
||||
<div className="pointer-events-none absolute -bottom-2 left-4 right-4 z-0 h-7 bg-white/50 backdrop-blur-[1px] md:-bottom-3" />
|
||||
<div className="relative z-20 w-full px-4">
|
||||
<ChatInput
|
||||
ref={chatInputRef}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={cancel}
|
||||
isLoading={isResponseLoading}
|
||||
hideAddDocButton
|
||||
projectName={project?.name}
|
||||
projectCmNumber={project?.cm_number}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<OwnerOnlyModal
|
||||
<OwnerOnlyPopup
|
||||
open={!!ownerOnlyAction}
|
||||
action={ownerOnlyAction ?? undefined}
|
||||
onClose={() => setOwnerOnlyAction(null)}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
updateTabularReview,
|
||||
} from "@/app/lib/mikeApi";
|
||||
import { ProjectReviewsTable } from "@/app/components/projects/ProjectReviewsTable";
|
||||
import { TabularReviewDetailsModal } from "@/app/components/tabular/TabularReviewDetailsModal";
|
||||
import {
|
||||
ProjectSectionToolbar,
|
||||
useProjectWorkspace,
|
||||
|
|
@ -70,10 +71,9 @@ export default function ProjectTabularReviewsPage({ params }: Props) {
|
|||
setProjectReviews,
|
||||
} = workspace;
|
||||
const [selectedReviewIds, setSelectedReviewIds] = useState<string[]>([]);
|
||||
const [renamingReviewId, setRenamingReviewId] = useState<string | null>(
|
||||
const [detailsReview, setDetailsReview] = useState<TabularReview | null>(
|
||||
null,
|
||||
);
|
||||
const [renameReviewValue, setRenameReviewValue] = useState("");
|
||||
const [actionsOpen, setActionsOpen] = useState(false);
|
||||
const docs = project?.documents ?? [];
|
||||
const reviews = useMemo(() => projectReviews ?? [], [projectReviews]);
|
||||
|
|
@ -94,16 +94,35 @@ export default function ProjectTabularReviewsPage({ params }: Props) {
|
|||
!allReviewsSelected &&
|
||||
filteredReviews.some((r) => selectedReviewIds.includes(r.id));
|
||||
|
||||
async function submitReviewRename(reviewId: string) {
|
||||
const trimmed = renameReviewValue.trim();
|
||||
setRenamingReviewId(null);
|
||||
if (!trimmed) return;
|
||||
await updateTabularReview(reviewId, { title: trimmed });
|
||||
function handleOpenDetails(review: TabularReview) {
|
||||
if (user?.id && review.user_id !== user.id) {
|
||||
setOwnerOnlyAction("edit tabular review details");
|
||||
return;
|
||||
}
|
||||
setDetailsReview(review);
|
||||
}
|
||||
|
||||
async function handleDetailsSave(values: {
|
||||
title: string;
|
||||
projectId?: string | null;
|
||||
}) {
|
||||
if (!detailsReview) return;
|
||||
if (user?.id && detailsReview.user_id !== user.id) {
|
||||
setOwnerOnlyAction("edit tabular review details");
|
||||
return;
|
||||
}
|
||||
const updated = await updateTabularReview(detailsReview.id, {
|
||||
title: values.title,
|
||||
project_id: projectId,
|
||||
});
|
||||
setProjectReviews((prev) =>
|
||||
(prev ?? []).map((review) =>
|
||||
review.id === reviewId ? { ...review, title: trimmed } : review,
|
||||
review.id === updated.id ? { ...review, ...updated } : review,
|
||||
),
|
||||
);
|
||||
setDetailsReview((current) =>
|
||||
current?.id === updated.id ? { ...current, ...updated } : current,
|
||||
);
|
||||
}
|
||||
|
||||
async function handleDeleteReviewRow(review: TabularReview) {
|
||||
|
|
@ -164,8 +183,6 @@ export default function ProjectTabularReviewsPage({ params }: Props) {
|
|||
selectedReviewIds={selectedReviewIds}
|
||||
allReviewsSelected={allReviewsSelected}
|
||||
someReviewsSelected={someReviewsSelected}
|
||||
renamingReviewId={renamingReviewId}
|
||||
renameReviewValue={renameReviewValue}
|
||||
creatingReview={workspace.creatingReview}
|
||||
currentUserId={user?.id}
|
||||
loading={loading}
|
||||
|
|
@ -175,12 +192,22 @@ export default function ProjectTabularReviewsPage({ params }: Props) {
|
|||
`/projects/${projectId}/tabular-reviews/${reviewId}`,
|
||||
)
|
||||
}
|
||||
onOpenDetails={handleOpenDetails}
|
||||
onDeleteReview={handleDeleteReviewRow}
|
||||
onOwnerOnlyAction={setOwnerOnlyAction}
|
||||
submitReviewRename={submitReviewRename}
|
||||
setSelectedReviewIds={setSelectedReviewIds}
|
||||
setRenamingReviewId={setRenamingReviewId}
|
||||
setRenameReviewValue={setRenameReviewValue}
|
||||
/>
|
||||
<TabularReviewDetailsModal
|
||||
open={!!detailsReview}
|
||||
review={detailsReview}
|
||||
projects={project ? [project] : []}
|
||||
canEdit={
|
||||
!!detailsReview &&
|
||||
(!user?.id || detailsReview.user_id === user.id)
|
||||
}
|
||||
lockProject
|
||||
onClose={() => setDetailsReview(null)}
|
||||
onSave={handleDetailsSave}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -16,8 +16,9 @@ import {
|
|||
} from "@/app/lib/mikeApi";
|
||||
import type { TabularReview, Project } from "@/app/components/shared/types";
|
||||
import { TableToolbar } from "@/app/components/shared/TableToolbar";
|
||||
import { AddNewTRModal } from "@/app/components/tabular/AddNewTRModal";
|
||||
import { OwnerOnlyModal } from "@/app/components/shared/OwnerOnlyModal";
|
||||
import { NewTRModal } from "@/app/components/tabular/NewTRModal";
|
||||
import { TabularReviewDetailsModal } from "@/app/components/tabular/TabularReviewDetailsModal";
|
||||
import { OwnerOnlyPopup } from "@/app/components/popups/OwnerOnlyPopup";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { PageHeader } from "@/app/components/shared/PageHeader";
|
||||
import {
|
||||
|
|
@ -62,9 +63,10 @@ export default function TabularReviewsPage() {
|
|||
const [loading, setLoading] = useState(true);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [newTROpen, setNewTROpen] = useState(false);
|
||||
const [detailsReview, setDetailsReview] = useState<TabularReview | null>(
|
||||
null,
|
||||
);
|
||||
const [activeScope, setActiveScope] = useState<ReviewScope>("all");
|
||||
const [renamingId, setRenamingId] = useState<string | null>(null);
|
||||
const [renameValue, setRenameValue] = useState("");
|
||||
const [projectFilter, setProjectFilter] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
|
|
@ -156,23 +158,35 @@ export default function TabularReviewsPage() {
|
|||
}
|
||||
};
|
||||
|
||||
async function handleRenameSubmit(reviewId: string) {
|
||||
const trimmed = renameValue.trim();
|
||||
if (!trimmed) {
|
||||
setRenamingId(null);
|
||||
function requestReviewDetails(review: TabularReview) {
|
||||
if (user?.id && review.user_id !== user.id) {
|
||||
setOwnerOnlyAction("edit tabular review details");
|
||||
return;
|
||||
}
|
||||
const review = reviews.find((r) => r.id === reviewId);
|
||||
if (review && user?.id && review.user_id !== user.id) {
|
||||
setRenamingId(null);
|
||||
setOwnerOnlyAction("rename this tabular review");
|
||||
setDetailsReview(review);
|
||||
}
|
||||
|
||||
async function handleDetailsSave(values: {
|
||||
title: string;
|
||||
projectId?: string | null;
|
||||
}) {
|
||||
if (!detailsReview) return;
|
||||
if (user?.id && detailsReview.user_id !== user.id) {
|
||||
setOwnerOnlyAction("edit tabular review details");
|
||||
return;
|
||||
}
|
||||
const updated = await updateTabularReview(detailsReview.id, {
|
||||
title: values.title,
|
||||
project_id: values.projectId ?? null,
|
||||
});
|
||||
setReviews((prev) =>
|
||||
prev.map((r) => (r.id === reviewId ? { ...r, title: trimmed } : r)),
|
||||
prev.map((review) =>
|
||||
review.id === updated.id ? { ...review, ...updated } : review,
|
||||
),
|
||||
);
|
||||
setDetailsReview((current) =>
|
||||
current?.id === updated.id ? { ...current, ...updated } : current,
|
||||
);
|
||||
setRenamingId(null);
|
||||
await updateTabularReview(reviewId, { title: trimmed });
|
||||
}
|
||||
|
||||
async function handleDeleteSelected() {
|
||||
|
|
@ -264,37 +278,40 @@ export default function TabularReviewsPage() {
|
|||
/>
|
||||
|
||||
{/* Table */}
|
||||
<TableScrollArea>
|
||||
<TableHeaderRow>
|
||||
<TableStickyCell header>
|
||||
{loading ? (
|
||||
<SkeletonDot />
|
||||
) : (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allSelected}
|
||||
ref={(el) => {
|
||||
if (el) el.indeterminate = someSelected;
|
||||
}}
|
||||
onChange={toggleAll}
|
||||
className={TABLE_CHECKBOX_CLASS}
|
||||
/>
|
||||
)}
|
||||
<span>Name</span>
|
||||
</TableStickyCell>
|
||||
<TableHeaderCell className="ml-auto w-24">
|
||||
Columns
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell className="w-24">Documents</TableHeaderCell>
|
||||
<TableHeaderCell className="w-40">
|
||||
<div className="flex items-center gap-1">
|
||||
<span>Project</span>
|
||||
{projectFilterButton}
|
||||
</div>
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell className="w-32">Created</TableHeaderCell>
|
||||
<TableHeaderCell className="w-8" />
|
||||
</TableHeaderRow>
|
||||
<TableScrollArea
|
||||
header={
|
||||
<TableHeaderRow>
|
||||
<TableStickyCell header>
|
||||
{loading ? (
|
||||
<SkeletonDot />
|
||||
) : (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allSelected}
|
||||
ref={(el) => {
|
||||
if (el) el.indeterminate = someSelected;
|
||||
}}
|
||||
onChange={toggleAll}
|
||||
className={TABLE_CHECKBOX_CLASS}
|
||||
/>
|
||||
)}
|
||||
<span>Name</span>
|
||||
</TableStickyCell>
|
||||
<TableHeaderCell className="ml-auto w-24">
|
||||
Columns
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell className="w-24">Documents</TableHeaderCell>
|
||||
<TableHeaderCell className="w-40">
|
||||
<div className="flex items-center gap-1">
|
||||
<span>Project</span>
|
||||
{projectFilterButton}
|
||||
</div>
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell className="w-32">Created</TableHeaderCell>
|
||||
<TableHeaderCell className="w-8" />
|
||||
</TableHeaderRow>
|
||||
}
|
||||
>
|
||||
|
||||
{loading ? (
|
||||
<TableBody>
|
||||
|
|
@ -367,21 +384,8 @@ export default function TabularReviewsPage() {
|
|||
rightClickDropdown={(close) => (
|
||||
<RowActionMenuItems
|
||||
onClose={close}
|
||||
onRename={() => {
|
||||
if (
|
||||
user?.id &&
|
||||
review.user_id !== user.id
|
||||
) {
|
||||
setOwnerOnlyAction(
|
||||
"rename this tabular review",
|
||||
);
|
||||
return;
|
||||
}
|
||||
setRenameValue(
|
||||
review.title ??
|
||||
"Untitled Review",
|
||||
);
|
||||
setRenamingId(review.id);
|
||||
onEditDetails={() => {
|
||||
requestReviewDetails(review);
|
||||
}}
|
||||
onDelete={async () => {
|
||||
if (
|
||||
|
|
@ -406,7 +410,6 @@ export default function TabularReviewsPage() {
|
|||
/>
|
||||
)}
|
||||
onClick={() => {
|
||||
if (renamingId === review.id) return;
|
||||
router.push(
|
||||
review.project_id
|
||||
? `/projects/${review.project_id}/tabular-reviews/${review.id}`
|
||||
|
|
@ -425,13 +428,6 @@ export default function TabularReviewsPage() {
|
|||
label={
|
||||
review.title ?? "Untitled Review"
|
||||
}
|
||||
editing={renamingId === review.id}
|
||||
editValue={renameValue}
|
||||
onEditValueChange={setRenameValue}
|
||||
onEditCommit={() =>
|
||||
handleRenameSubmit(review.id)
|
||||
}
|
||||
onEditCancel={() => setRenamingId(null)}
|
||||
/>
|
||||
<TableCell className="ml-auto w-24">
|
||||
{review.columns_config?.length ?? 0}
|
||||
|
|
@ -462,21 +458,8 @@ export default function TabularReviewsPage() {
|
|||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<RowActions
|
||||
onRename={() => {
|
||||
if (
|
||||
user?.id &&
|
||||
review.user_id !== user.id
|
||||
) {
|
||||
setOwnerOnlyAction(
|
||||
"rename this tabular review",
|
||||
);
|
||||
return;
|
||||
}
|
||||
setRenameValue(
|
||||
review.title ??
|
||||
"Untitled Review",
|
||||
);
|
||||
setRenamingId(review.id);
|
||||
onEditDetails={() => {
|
||||
requestReviewDetails(review);
|
||||
}}
|
||||
onDelete={async () => {
|
||||
if (
|
||||
|
|
@ -507,14 +490,26 @@ export default function TabularReviewsPage() {
|
|||
)}
|
||||
</TableScrollArea>
|
||||
|
||||
<AddNewTRModal
|
||||
<NewTRModal
|
||||
open={newTROpen}
|
||||
onClose={() => setNewTROpen(false)}
|
||||
onAdd={handleNewReview}
|
||||
projects={projects}
|
||||
/>
|
||||
|
||||
<OwnerOnlyModal
|
||||
<TabularReviewDetailsModal
|
||||
open={!!detailsReview}
|
||||
review={detailsReview}
|
||||
projects={projects}
|
||||
canEdit={
|
||||
!!detailsReview &&
|
||||
(!user?.id || detailsReview.user_id === user.id)
|
||||
}
|
||||
onClose={() => setDetailsReview(null)}
|
||||
onSave={handleDetailsSave}
|
||||
/>
|
||||
|
||||
<OwnerOnlyPopup
|
||||
open={!!ownerOnlyAction}
|
||||
action={ownerOnlyAction ?? undefined}
|
||||
onClose={() => setOwnerOnlyAction(null)}
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ export function AddDocButton({
|
|||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".pdf,.docx,.doc"
|
||||
accept=".pdf,.docx,.doc,.xlsx,.xlsm,.xls,.pptx,.ppt"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleUpload}
|
||||
|
|
|
|||
730
frontend/src/app/components/assistant/AskInputPopup.tsx
Normal file
730
frontend/src/app/components/assistant/AskInputPopup.tsx
Normal file
|
|
@ -0,0 +1,730 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
AlertCircle,
|
||||
Check,
|
||||
CornerDownLeft,
|
||||
Loader2,
|
||||
Upload,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { AssistantEvent, Document } from "../shared/types";
|
||||
import { FileTypeIcon } from "../shared/FileTypeIcon";
|
||||
import {
|
||||
AddDocumentsModal,
|
||||
invalidateDirectoryCache,
|
||||
} from "../modals/AddDocumentsModal";
|
||||
import { uploadStandaloneDocument } from "@/app/lib/mikeApi";
|
||||
import {
|
||||
SUPPORTED_DOCUMENT_ACCEPT,
|
||||
formatUnsupportedDocumentWarning,
|
||||
partitionSupportedDocumentFiles,
|
||||
} from "@/app/lib/documentUploadValidation";
|
||||
|
||||
type AskInputsEvent = Extract<AssistantEvent, { type: "ask_inputs" }>;
|
||||
type AskInputItem = AskInputsEvent["items"][number];
|
||||
type AskInputsResponse = Extract<
|
||||
AssistantEvent,
|
||||
{ type: "ask_inputs_response" }
|
||||
>;
|
||||
|
||||
export function AskInputPopup({
|
||||
event,
|
||||
onSubmit,
|
||||
onDismiss,
|
||||
}: {
|
||||
event: AskInputsEvent;
|
||||
onSubmit?: (
|
||||
response: AskInputsResponse,
|
||||
content: string,
|
||||
files: { filename: string; document_id: string }[],
|
||||
) => void;
|
||||
onDismiss?: () => void;
|
||||
}) {
|
||||
const [choiceAnswers, setChoiceAnswers] = useState<Record<string, string>>(
|
||||
{},
|
||||
);
|
||||
const [otherOpen, setOtherOpen] = useState<Record<string, boolean>>({});
|
||||
const [otherValues, setOtherValues] = useState<Record<string, string>>({});
|
||||
const [docsByInput, setDocsByInput] = useState<Record<string, Document[]>>(
|
||||
{},
|
||||
);
|
||||
const [skipped, setSkipped] = useState<Set<string>>(() => new Set());
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
const [uploadingInputId, setUploadingInputId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [dragActiveInputId, setDragActiveInputId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [uploadWarning, setUploadWarning] = useState<string | null>(null);
|
||||
const [docSelectorInputId, setDocSelectorInputId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [activeInputId, setActiveInputId] = useState(
|
||||
() => event.items[0]?.id ?? "",
|
||||
);
|
||||
const fileInputsRef = useRef<Record<string, HTMLInputElement | null>>({});
|
||||
|
||||
const itemResolved = useCallback(
|
||||
(item: AskInputItem) => {
|
||||
if (skipped.has(item.id)) return true;
|
||||
if (item.kind === "choice") return !!choiceAnswers[item.id]?.trim();
|
||||
return (docsByInput[item.id] ?? []).length > 0;
|
||||
},
|
||||
[choiceAnswers, docsByInput, skipped],
|
||||
);
|
||||
|
||||
const firstUnresolvedId = useCallback(
|
||||
(resolvedId?: string) =>
|
||||
event.items.find((item) => {
|
||||
if (item.id === resolvedId) return false;
|
||||
return !itemResolved(item);
|
||||
})?.id ?? null,
|
||||
[event.items, itemResolved],
|
||||
);
|
||||
|
||||
const goToNextUnresolved = useCallback(
|
||||
(resolvedId: string) => {
|
||||
const nextId = firstUnresolvedId(resolvedId);
|
||||
if (nextId) setActiveInputId(nextId);
|
||||
},
|
||||
[firstUnresolvedId],
|
||||
);
|
||||
|
||||
const setSkippedFor = (id: string, shouldSkip = true) => {
|
||||
setSkipped((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (shouldSkip) next.add(id);
|
||||
else next.delete(id);
|
||||
return next;
|
||||
});
|
||||
if (shouldSkip) goToNextUnresolved(id);
|
||||
};
|
||||
|
||||
const addDocs = (inputId: string, selected: Document[]) => {
|
||||
if (selected.length === 0) return;
|
||||
setSkippedFor(inputId, false);
|
||||
setDocsByInput((prev) => {
|
||||
const current = prev[inputId] ?? [];
|
||||
const existing = new Set(current.map((doc) => doc.id));
|
||||
return {
|
||||
...prev,
|
||||
[inputId]: [
|
||||
...current,
|
||||
...selected.filter((doc) => !existing.has(doc.id)),
|
||||
],
|
||||
};
|
||||
});
|
||||
goToNextUnresolved(inputId);
|
||||
};
|
||||
|
||||
const removeDoc = (inputId: string, docId: string) => {
|
||||
setDocsByInput((prev) => ({
|
||||
...prev,
|
||||
[inputId]: (prev[inputId] ?? []).filter((doc) => doc.id !== docId),
|
||||
}));
|
||||
};
|
||||
|
||||
const handleFiles = async (inputId: string, incomingFiles: File[]) => {
|
||||
if (!incomingFiles.length || submitted) return;
|
||||
const { supported, unsupported } =
|
||||
partitionSupportedDocumentFiles(incomingFiles);
|
||||
setUploadWarning(formatUnsupportedDocumentWarning(unsupported));
|
||||
if (supported.length === 0) {
|
||||
const input = fileInputsRef.current[inputId];
|
||||
if (input) input.value = "";
|
||||
return;
|
||||
}
|
||||
setUploadingInputId(inputId);
|
||||
try {
|
||||
const uploaded = await Promise.all(
|
||||
supported.map((file) => uploadStandaloneDocument(file)),
|
||||
);
|
||||
invalidateDirectoryCache();
|
||||
addDocs(inputId, uploaded);
|
||||
} catch (err) {
|
||||
console.error("Document upload failed:", err);
|
||||
} finally {
|
||||
setUploadingInputId(null);
|
||||
setDragActiveInputId(null);
|
||||
const input = fileInputsRef.current[inputId];
|
||||
if (input) input.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const chooseAnswer = (
|
||||
item: Extract<AskInputItem, { kind: "choice" }>,
|
||||
answer: string,
|
||||
) => {
|
||||
const trimmed = answer.trim();
|
||||
if (!trimmed || submitted) return;
|
||||
setSkippedFor(item.id, false);
|
||||
setChoiceAnswers((prev) => ({ ...prev, [item.id]: trimmed }));
|
||||
goToNextUnresolved(item.id);
|
||||
};
|
||||
|
||||
const allResolved =
|
||||
event.items.length > 0 && event.items.every(itemResolved);
|
||||
const canSubmit =
|
||||
!submitted &&
|
||||
!uploadingInputId &&
|
||||
allResolved &&
|
||||
!!onSubmit;
|
||||
|
||||
const buildResponse = (): AskInputsResponse => {
|
||||
const responses = event.items.map((item) => {
|
||||
if (skipped.has(item.id)) {
|
||||
return item.kind === "choice"
|
||||
? {
|
||||
id: item.id,
|
||||
kind: "choice" as const,
|
||||
question: item.question,
|
||||
skipped: true,
|
||||
}
|
||||
: {
|
||||
id: item.id,
|
||||
kind: "documents" as const,
|
||||
filenames: [],
|
||||
skipped: true,
|
||||
};
|
||||
}
|
||||
if (item.kind === "choice") {
|
||||
return {
|
||||
id: item.id,
|
||||
kind: "choice" as const,
|
||||
question: item.question,
|
||||
answer: choiceAnswers[item.id]?.trim() ?? "",
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: item.id,
|
||||
kind: "documents" as const,
|
||||
filenames: (docsByInput[item.id] ?? []).map(
|
||||
(doc) => doc.filename,
|
||||
),
|
||||
};
|
||||
});
|
||||
return { type: "ask_inputs_response", responses };
|
||||
};
|
||||
|
||||
const responseFiles = (response: AskInputsResponse) => {
|
||||
const responseById = new Map(response.responses.map((item) => [item.id, item]));
|
||||
const docs = event.items.flatMap((item) => {
|
||||
const responseItem = responseById.get(item.id);
|
||||
if (
|
||||
item.kind !== "documents" ||
|
||||
responseItem?.kind !== "documents" ||
|
||||
responseItem.skipped
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
return docsByInput[item.id] ?? [];
|
||||
});
|
||||
const seen = new Set<string>();
|
||||
return docs.flatMap((doc) => {
|
||||
if (seen.has(doc.id)) return [];
|
||||
seen.add(doc.id);
|
||||
return [{ filename: doc.filename, document_id: doc.id }];
|
||||
});
|
||||
};
|
||||
|
||||
const buildContent = (response: AskInputsResponse) => {
|
||||
const lines = response.responses.map((item, index) => {
|
||||
if (item.kind === "choice") {
|
||||
if (item.skipped)
|
||||
return `${index + 1}. Skipped: ${item.question}`;
|
||||
return `${index + 1}. ${item.question}\n${item.answer ?? ""}`;
|
||||
}
|
||||
if (item.skipped) return `${index + 1}. Skipped document request.`;
|
||||
return `${index + 1}. Documents attached: ${item.filenames.join(", ")}`;
|
||||
});
|
||||
return `Responses to Mike's questions:\n${lines.join("\n\n")}`;
|
||||
};
|
||||
|
||||
const submit = () => {
|
||||
if (!canSubmit) return;
|
||||
const response = buildResponse();
|
||||
setSubmitted(true);
|
||||
onSubmit?.(response, buildContent(response), responseFiles(response));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (canSubmit) submit();
|
||||
});
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
if (submitted || dismissed) return;
|
||||
setDismissed(true);
|
||||
onDismiss?.();
|
||||
}, [dismissed, onDismiss, submitted]);
|
||||
|
||||
useEffect(() => {
|
||||
if (submitted || dismissed) return;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") dismiss();
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [submitted, dismissed, dismiss]);
|
||||
|
||||
if (dismissed) return null;
|
||||
|
||||
const activeItem =
|
||||
event.items.find((item) => item.id === activeInputId) ?? event.items[0];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-full overflow-hidden rounded-[18px] border border-white/65 bg-white/60 pb-3 font-serif shadow-[0_4px_10px_rgba(15,23,42,0.084),inset_0_1px_0_rgba(255,255,255,0.595),inset_0_-6px_14px_rgba(255,255,255,0.126)] backdrop-blur-2xl md:rounded-[22px]">
|
||||
<div className="flex min-w-0 items-center justify-between gap-2 bg-gray-100/70 px-3 py-2">
|
||||
<div className="flex min-w-0 items-center">
|
||||
<div className="text-sm text-gray-500">
|
||||
{submitted ? (
|
||||
"Inputs sent"
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1">
|
||||
{event.items.map((item, index) => {
|
||||
const isActive =
|
||||
item.id === activeItem?.id;
|
||||
const isResolved = itemResolved(item);
|
||||
const label =
|
||||
item.kind === "choice"
|
||||
? `Question ${index + 1}`
|
||||
: "Add Documents";
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
disabled={submitted}
|
||||
onClick={() =>
|
||||
setActiveInputId(item.id)
|
||||
}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full py-0.5 font-sans text-[10px] transition-colors disabled:cursor-default",
|
||||
isActive
|
||||
? "text-gray-900"
|
||||
: "text-gray-400 hover:text-gray-700",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
{isResolved ? (
|
||||
<Check className="h-3 w-3" />
|
||||
) : (
|
||||
<span className="h-2.5 w-2.5 rounded-full border border-current opacity-70" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!submitted && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={dismiss}
|
||||
className="shrink-0 rounded-full py-0.5 font-sans text-[10px] text-gray-500 transition-colors hover:text-gray-700"
|
||||
>
|
||||
Esc (end response)
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-3">
|
||||
{activeItem && (
|
||||
<div className="mt-3 flex min-h-54 flex-col">
|
||||
<div className="mt-auto">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
{activeItem.kind === "choice" ? (
|
||||
<p className="text-sm text-gray-800">
|
||||
{activeItem.question}
|
||||
</p>
|
||||
) : (
|
||||
<DocumentPrompt item={activeItem} />
|
||||
)}
|
||||
</div>
|
||||
{!submitted && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setSkippedFor(
|
||||
activeItem.id,
|
||||
!skipped.has(activeItem.id),
|
||||
)
|
||||
}
|
||||
className="shrink-0 rounded-full py-0.5 font-sans text-[10px] text-gray-500 transition-colors hover:text-gray-800 disabled:cursor-default disabled:opacity-40"
|
||||
>
|
||||
{skipped.has(activeItem.id)
|
||||
? "Unskip"
|
||||
: "Skip"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pt-3">
|
||||
{activeItem.kind === "choice" ? (
|
||||
<OptionInput
|
||||
item={activeItem}
|
||||
disabled={
|
||||
submitted ||
|
||||
skipped.has(activeItem.id)
|
||||
}
|
||||
selectedAnswer={
|
||||
choiceAnswers[activeItem.id] ??
|
||||
null
|
||||
}
|
||||
otherOpen={
|
||||
!!otherOpen[activeItem.id]
|
||||
}
|
||||
otherValue={
|
||||
otherValues[activeItem.id] ?? ""
|
||||
}
|
||||
onAnswer={(answer) =>
|
||||
chooseAnswer(activeItem, answer)
|
||||
}
|
||||
onOtherOpen={() =>
|
||||
setOtherOpen((prev) => ({
|
||||
...prev,
|
||||
[activeItem.id]: true,
|
||||
}))
|
||||
}
|
||||
onOtherValue={(value) =>
|
||||
setOtherValues((prev) => ({
|
||||
...prev,
|
||||
[activeItem.id]: value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<DocumentInput
|
||||
item={activeItem}
|
||||
disabled={
|
||||
submitted ||
|
||||
skipped.has(activeItem.id)
|
||||
}
|
||||
docs={
|
||||
docsByInput[activeItem.id] ?? []
|
||||
}
|
||||
uploading={
|
||||
uploadingInputId ===
|
||||
activeItem.id
|
||||
}
|
||||
dragActive={
|
||||
dragActiveInputId ===
|
||||
activeItem.id
|
||||
}
|
||||
fileInputRef={(node) => {
|
||||
fileInputsRef.current[
|
||||
activeItem.id
|
||||
] = node;
|
||||
}}
|
||||
onFiles={(files) =>
|
||||
void handleFiles(
|
||||
activeItem.id,
|
||||
files,
|
||||
)
|
||||
}
|
||||
onDragActive={(active) =>
|
||||
setDragActiveInputId(
|
||||
active
|
||||
? activeItem.id
|
||||
: null,
|
||||
)
|
||||
}
|
||||
onBrowse={() =>
|
||||
setDocSelectorInputId(
|
||||
activeItem.id,
|
||||
)
|
||||
}
|
||||
onRemoveDoc={(docId) =>
|
||||
removeDoc(activeItem.id, docId)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{uploadWarning && (
|
||||
<div className="mt-3 flex items-center gap-2 rounded-lg border border-red-100 bg-red-50 px-3 py-2 font-sans text-xs text-gray-900">
|
||||
<AlertCircle className="h-3.5 w-3.5 shrink-0 text-red-600" />
|
||||
<span className="min-w-0 flex-1">
|
||||
{uploadWarning}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setUploadWarning(null)}
|
||||
className="shrink-0 rounded p-0.5 text-black hover:bg-gray-100"
|
||||
aria-label="Dismiss warning"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AddDocumentsModal
|
||||
open={!!docSelectorInputId}
|
||||
onClose={() => setDocSelectorInputId(null)}
|
||||
onSelect={(selected) => {
|
||||
if (docSelectorInputId)
|
||||
addDocs(docSelectorInputId, selected);
|
||||
}}
|
||||
breadcrumb={["Assistant", "Add Documents"]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function OptionInput({
|
||||
item,
|
||||
disabled,
|
||||
selectedAnswer,
|
||||
otherOpen,
|
||||
otherValue,
|
||||
onAnswer,
|
||||
onOtherOpen,
|
||||
onOtherValue,
|
||||
}: {
|
||||
item: Extract<AskInputItem, { kind: "choice" }>;
|
||||
disabled?: boolean;
|
||||
selectedAnswer: string | null;
|
||||
otherOpen: boolean;
|
||||
otherValue: string;
|
||||
onAnswer: (answer: string) => void;
|
||||
onOtherOpen: () => void;
|
||||
onOtherValue: (value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="mt-2 grid gap-2">
|
||||
{item.options.map((option, idx) => {
|
||||
const answer = option.value.trim();
|
||||
const isSelected = selectedAnswer === answer.trim();
|
||||
return (
|
||||
<button
|
||||
key={`${item.id}-${option.value}-${idx}`}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onAnswer(answer)}
|
||||
className={`w-full rounded-lg p-2 text-left transition-colors ${
|
||||
isSelected
|
||||
? "bg-gray-200/80 text-gray-900"
|
||||
: "bg-gray-100/70 text-gray-700 hover:bg-gray-200/70 disabled:hover:bg-gray-100/70"
|
||||
} disabled:cursor-default disabled:opacity-60`}
|
||||
>
|
||||
<span className="flex items-start gap-1.5">
|
||||
<span className="mt-0.5 w-4 shrink-0 text-xs text-gray-500">
|
||||
{idx + 1}.
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-sm">{answer}</span>
|
||||
</span>
|
||||
{isSelected && (
|
||||
<Check className="mt-0.5 h-3.5 w-3.5 shrink-0 text-gray-700" />
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{item.allow_other && (
|
||||
<div
|
||||
className={`w-full rounded-lg p-2 transition-colors ${
|
||||
otherOpen
|
||||
? "bg-gray-200/80"
|
||||
: "cursor-pointer bg-gray-100/70 hover:bg-gray-200/70"
|
||||
} ${disabled ? "cursor-default opacity-60" : ""}`}
|
||||
onClick={() => !otherOpen && !disabled && onOtherOpen()}
|
||||
>
|
||||
<span className="flex items-start gap-1.5">
|
||||
<span className="mt-0.5 w-4 shrink-0 text-xs text-gray-500">
|
||||
{item.options.length + 1}.
|
||||
</span>
|
||||
{otherOpen ? (
|
||||
<span className="min-w-0 flex-1 flex items-end gap-2">
|
||||
<textarea
|
||||
name={`other-${item.id}`}
|
||||
rows={1}
|
||||
autoFocus
|
||||
value={otherValue}
|
||||
disabled={disabled}
|
||||
onChange={(e) => {
|
||||
onOtherValue(e.target.value);
|
||||
e.target.style.height = "auto";
|
||||
e.target.style.height = `${e.target.scrollHeight}px`;
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
onAnswer(otherValue);
|
||||
}
|
||||
}}
|
||||
placeholder="Type your answer..."
|
||||
className="flex-1 resize-none overflow-hidden bg-transparent text-sm leading-5 text-gray-600 outline-none placeholder:text-gray-400"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled || !otherValue.trim()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAnswer(otherValue);
|
||||
}}
|
||||
className="shrink-0 flex items-center gap-1 rounded-full bg-blue-600 px-3 py-0.5 font-sans text-[10px] text-white transition-colors hover:bg-blue-700 disabled:cursor-default disabled:opacity-40"
|
||||
>
|
||||
Set
|
||||
<CornerDownLeft className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
) : (
|
||||
<span className="min-w-0 flex-1 text-sm text-gray-700">
|
||||
{item.other_label || "Other"}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DocumentPrompt({
|
||||
item,
|
||||
}: {
|
||||
item: Extract<AskInputItem, { kind: "documents" }>;
|
||||
}) {
|
||||
const documentTypes = item.document_types ?? [];
|
||||
return (
|
||||
<div className="mt-0.5 text-sm text-gray-800">
|
||||
<p>Add the following documents if available:</p>
|
||||
{documentTypes.length > 0 && (
|
||||
<div className="mt-1 space-y-0.5 text-gray-700">
|
||||
{documentTypes.map((documentType, index) => (
|
||||
<p
|
||||
key={`${documentType}-${index}`}
|
||||
className="break-words"
|
||||
>
|
||||
{index + 1}. {documentType}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DocumentInput({
|
||||
item,
|
||||
disabled,
|
||||
docs,
|
||||
uploading,
|
||||
dragActive,
|
||||
fileInputRef,
|
||||
onFiles,
|
||||
onDragActive,
|
||||
onBrowse,
|
||||
onRemoveDoc,
|
||||
}: {
|
||||
item: Extract<AskInputItem, { kind: "documents" }>;
|
||||
disabled?: boolean;
|
||||
docs: Document[];
|
||||
uploading: boolean;
|
||||
dragActive: boolean;
|
||||
fileInputRef: (node: HTMLInputElement | null) => void;
|
||||
onFiles: (files: File[]) => void;
|
||||
onDragActive: (active: boolean) => void;
|
||||
onBrowse: () => void;
|
||||
onRemoveDoc: (docId: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="mt-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={SUPPORTED_DOCUMENT_ACCEPT}
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => onFiles(Array.from(e.target.files || []))}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled || uploading}
|
||||
onClick={onBrowse}
|
||||
onDragEnter={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!disabled && !uploading) onDragActive(true);
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!disabled && !uploading) onDragActive(true);
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onDragActive(false);
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onDragActive(false);
|
||||
onFiles(Array.from(e.dataTransfer.files || []));
|
||||
}}
|
||||
className={`flex h-[168px] w-full flex-col items-center justify-center gap-1.5 rounded-lg px-3 py-4 font-sans text-xs transition-colors disabled:cursor-default disabled:opacity-50 ${
|
||||
dragActive
|
||||
? "bg-gray-300 text-gray-900"
|
||||
: "bg-gray-100/80 text-gray-500 hover:bg-gray-200/80 hover:text-gray-800"
|
||||
}`}
|
||||
>
|
||||
{uploading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Upload className="h-4 w-4" />
|
||||
)}
|
||||
<span className="text-gray-800">
|
||||
{uploading
|
||||
? "Uploading documents..."
|
||||
: "Drop files here or click to choose documents"}
|
||||
</span>
|
||||
<span className="text-[11px] text-gray-400">
|
||||
PDF, Word, Excel, or PowerPoint
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{docs.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{docs.map((doc) => {
|
||||
return (
|
||||
<div
|
||||
key={`${item.id}-${doc.id}`}
|
||||
className="inline-flex items-center gap-1 rounded-[10px] border border-white/70 bg-white py-0.5 pl-2 pr-1 text-xs text-gray-800 shadow-[0_2px_6px_rgba(15,23,42,0.08),inset_0_1px_0_rgba(255,255,255,0.9)] backdrop-blur-xl"
|
||||
>
|
||||
<FileTypeIcon
|
||||
fileType={doc.file_type}
|
||||
className="h-2.5 w-2.5"
|
||||
/>
|
||||
<span className="max-w-[160px] truncate">
|
||||
{doc.filename}
|
||||
</span>
|
||||
{!disabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemoveDoc(doc.id)}
|
||||
className="ml-0.5 rounded-full p-0.5 text-gray-400 transition-colors hover:bg-gray-900/5 hover:text-gray-700"
|
||||
>
|
||||
<X className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -8,9 +8,9 @@ import {
|
|||
type CSSProperties,
|
||||
} from "react";
|
||||
import { X } from "lucide-react";
|
||||
import { DocPanel, type DocPanelMode } from "../shared/DocPanel";
|
||||
import { DocPanel, type DocPanelMode } from "./DocPanel";
|
||||
import type {
|
||||
CitationAnnotation,
|
||||
Citation,
|
||||
EditAnnotation,
|
||||
} from "../shared/types";
|
||||
import {
|
||||
|
|
@ -45,12 +45,13 @@ export type DocumentTab = CommonTab & { kind: "document" };
|
|||
|
||||
export type CitationTab = CommonTab & {
|
||||
kind: "citation";
|
||||
citation: CitationAnnotation;
|
||||
citation: Citation;
|
||||
};
|
||||
|
||||
export type EditTab = CommonTab & {
|
||||
kind: "edit";
|
||||
edit: EditAnnotation;
|
||||
changeNumber?: number;
|
||||
};
|
||||
|
||||
export type AssistantSidePanelTab =
|
||||
|
|
@ -307,6 +308,7 @@ export function AssistantSidePanel({
|
|||
? {
|
||||
kind: "edit",
|
||||
edit: tab.edit,
|
||||
changeNumber: tab.changeNumber,
|
||||
isEditReloading:
|
||||
isEditReloading?.(tab.edit.edit_id) ??
|
||||
false,
|
||||
|
|
|
|||
|
|
@ -18,11 +18,11 @@ import type { CaseCitationQuote } from "../shared/types";
|
|||
import {
|
||||
clearDocxQuoteHighlights,
|
||||
highlightDocxQuote,
|
||||
} from "../shared/highlightDocxQuote";
|
||||
} from "../shared/views/highlightDocxQuote";
|
||||
import {
|
||||
RelevantQuotes,
|
||||
type RelevantQuoteItem,
|
||||
} from "../shared/RelevantQuotes";
|
||||
CitationQuotesHeader,
|
||||
type CitationQuoteHeaderItem,
|
||||
} from "./CitationQuotesHeader";
|
||||
import {
|
||||
getCourtlistenerOpinions,
|
||||
type CaseLawOpinion,
|
||||
|
|
@ -292,7 +292,7 @@ export function CaseLawPanel({
|
|||
Math.max(relevantQuotes.length - 1, 0),
|
||||
)
|
||||
: 0;
|
||||
const relevantQuoteItems: RelevantQuoteItem[] = relevantQuotes.map(
|
||||
const relevantQuoteItems: CitationQuoteHeaderItem[] = relevantQuotes.map(
|
||||
(quote, index) => ({
|
||||
id: relevantQuoteKey(quote, index),
|
||||
quote: quote.quote,
|
||||
|
|
@ -432,7 +432,7 @@ export function CaseLawPanel({
|
|||
</div>
|
||||
</div>
|
||||
{relevantQuoteItems.length > 0 && (
|
||||
<RelevantQuotes
|
||||
<CitationQuotesHeader
|
||||
quotes={relevantQuoteItems}
|
||||
activeQuoteId={activeQuoteKey}
|
||||
currentIndex={currentQuoteIndex}
|
||||
|
|
|
|||
|
|
@ -11,17 +11,16 @@ import {
|
|||
import {
|
||||
ArrowRight,
|
||||
Check,
|
||||
File,
|
||||
FileText,
|
||||
FolderOpen,
|
||||
Library,
|
||||
Square,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { AddDocButton } from "./AddDocButton";
|
||||
import { AddDocumentsModal } from "../shared/AddDocumentsModal";
|
||||
import { FileTypeIcon } from "../shared/FileTypeIcon";
|
||||
import { AddDocumentsModal } from "../modals/AddDocumentsModal";
|
||||
import { AssistantWorkflowModal } from "./AssistantWorkflowModal";
|
||||
import { ApiKeyMissingModal } from "../shared/ApiKeyMissingModal";
|
||||
import { ApiKeyMissingPopup } from "../popups/ApiKeyMissingPopup";
|
||||
import { ModelToggle } from "./ModelToggle";
|
||||
import { useSelectedModel } from "@/app/hooks/useSelectedModel";
|
||||
import { useUserProfile } from "@/contexts/UserProfileContext";
|
||||
|
|
@ -193,18 +192,15 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput(
|
|||
</div>
|
||||
)}
|
||||
{attachedDocs.map((doc) => {
|
||||
const ft = doc.file_type?.toLowerCase();
|
||||
const isPdf = ft === "pdf";
|
||||
return (
|
||||
<div
|
||||
key={doc.id}
|
||||
className="inline-flex items-center gap-1 rounded-[10px] border border-white/70 bg-white py-0.5 pl-2 pr-1 text-xs text-gray-800 shadow-[0_2px_6px_rgba(15,23,42,0.08),inset_0_1px_0_rgba(255,255,255,0.9)] backdrop-blur-xl"
|
||||
>
|
||||
{isPdf ? (
|
||||
<FileText className="h-2.5 w-2.5 shrink-0 text-red-500" />
|
||||
) : (
|
||||
<File className="h-2.5 w-2.5 shrink-0 text-blue-500" />
|
||||
)}
|
||||
<FileTypeIcon
|
||||
fileType={doc.file_type}
|
||||
className="h-2.5 w-2.5"
|
||||
/>
|
||||
<span className="max-w-[140px] truncate">
|
||||
{doc.filename}
|
||||
</span>
|
||||
|
|
@ -348,7 +344,7 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput(
|
|||
projectName={projectName}
|
||||
projectCmNumber={projectCmNumber}
|
||||
/>
|
||||
<ApiKeyMissingModal
|
||||
<ApiKeyMissingPopup
|
||||
open={apiKeyModalProvider !== null}
|
||||
provider={apiKeyModalProvider}
|
||||
onClose={() => setApiKeyModalProvider(null)}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import { ArrowDown } from "lucide-react";
|
|||
import { UserMessage } from "./UserMessage";
|
||||
import { AssistantMessage } from "./AssistantMessage";
|
||||
import { ChatInput } from "./ChatInput";
|
||||
import type { ChatInputHandle } from "./ChatInput";
|
||||
import { AskInputPopup } from "./AskInputPopup";
|
||||
import {
|
||||
AssistantSidePanel,
|
||||
type AssistantSidePanelTab,
|
||||
|
|
@ -13,24 +15,35 @@ import {
|
|||
import { AssistantWorkflowModal } from "./AssistantWorkflowModal";
|
||||
import type {
|
||||
AssistantEvent,
|
||||
CitationAnnotation,
|
||||
Citation,
|
||||
EditAnnotation,
|
||||
Message,
|
||||
} from "../shared/types";
|
||||
import { useSidebar } from "@/app/contexts/SidebarContext";
|
||||
import { invalidateDocxBytes } from "@/app/hooks/useFetchDocxBytes";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface Props {
|
||||
chatId?: string | null;
|
||||
messages: Message[];
|
||||
isResponseLoading: boolean;
|
||||
handleChat: (message: Message) => Promise<string | null>;
|
||||
handleChat: (
|
||||
message: Message,
|
||||
opts?: {
|
||||
displayedDoc?: { filename: string; documentId: string } | null;
|
||||
askInputsResponse?: Extract<
|
||||
AssistantEvent,
|
||||
{ type: "ask_inputs_response" }
|
||||
>;
|
||||
},
|
||||
) => Promise<string | null>;
|
||||
cancel: () => void;
|
||||
}
|
||||
|
||||
const ASSISTANT_PANEL_TRANSITION_MS = 500;
|
||||
const MOBILE_BREAKPOINT_PX = 768;
|
||||
const DEFAULT_ASSISTANT_BOTTOM_PADDING = 116;
|
||||
const SCROLL_BUTTON_INPUT_GAP = 16;
|
||||
const CHAT_INPUT_BOTTOM_OFFSET = 12;
|
||||
|
||||
function isSmallScreen() {
|
||||
return (
|
||||
|
|
@ -54,6 +67,9 @@ export function ChatView({
|
|||
const [workflowModalInitialId, setWorkflowModalInitialId] = useState<
|
||||
string | undefined
|
||||
>();
|
||||
const [hiddenAskInputKeys, setHiddenAskInputKeys] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
const [reloadingDocIds, setReloadingDocIds] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
|
|
@ -66,6 +82,10 @@ export function ChatView({
|
|||
const { setSidebarOpen } = useSidebar();
|
||||
const panelCloseTimerRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setHiddenAskInputKeys(new Set());
|
||||
}, [chatId]);
|
||||
|
||||
const showPanel = useCallback(() => {
|
||||
if (panelCloseTimerRef.current !== null) {
|
||||
window.clearTimeout(panelCloseTimerRef.current);
|
||||
|
|
@ -199,7 +219,7 @@ export function ChatView({
|
|||
* AssistantMessage when the user clicks a numbered citation pill.
|
||||
*/
|
||||
const openCitation = useCallback(
|
||||
(citation: CitationAnnotation, options?: { showQuotes?: boolean }) => {
|
||||
(citation: Citation, options?: { showQuotes?: boolean }) => {
|
||||
const showQuotes = options?.showQuotes ?? true;
|
||||
if (citation.kind === "case") {
|
||||
if (!chatId) return;
|
||||
|
|
@ -270,7 +290,7 @@ export function ChatView({
|
|||
* AssistantMessage when the user clicks an EditCard's View button.
|
||||
*/
|
||||
const openEditor = useCallback(
|
||||
(ann: EditAnnotation, filename: string) => {
|
||||
(ann: EditAnnotation, filename: string, changeNumber?: number) => {
|
||||
upsertTab({
|
||||
kind: "edit",
|
||||
id: ann.document_id,
|
||||
|
|
@ -279,6 +299,7 @@ export function ChatView({
|
|||
versionId: ann.version_id ?? null,
|
||||
versionNumber: ann.version_number ?? null,
|
||||
edit: ann,
|
||||
changeNumber,
|
||||
});
|
||||
},
|
||||
[upsertTab],
|
||||
|
|
@ -450,7 +471,8 @@ export function ChatView({
|
|||
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const latestUserMessageRef = useRef<HTMLDivElement>(null);
|
||||
const chatInputRef = useRef<HTMLDivElement>(null);
|
||||
const chatInputRef = useRef<ChatInputHandle | null>(null);
|
||||
const measuredInputRef = useRef<HTMLDivElement>(null);
|
||||
// Seed "already in place" when messages exist at mount (a freshly created
|
||||
// chat arrives with its first message in hand). Otherwise the skeleton +
|
||||
// opacity-0 gate would flash the message out and fade it back in on every
|
||||
|
|
@ -465,28 +487,26 @@ export function ChatView({
|
|||
const [minHeight, setMinHeight] = useState("0px");
|
||||
|
||||
useEffect(() => {
|
||||
const el = chatInputRef.current;
|
||||
const el = measuredInputRef.current;
|
||||
if (!el) return;
|
||||
const observer = new ResizeObserver(() =>
|
||||
setInputHeight(el.offsetHeight),
|
||||
);
|
||||
const update = () => setInputHeight(el.offsetHeight);
|
||||
const observer = new ResizeObserver(update);
|
||||
observer.observe(el);
|
||||
setInputHeight(el.offsetHeight);
|
||||
update();
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (latestUserMessageRef.current) {
|
||||
const headerHeight = window.innerWidth < 768 ? 56 : 0;
|
||||
const gap = window.innerWidth < 768 ? 16 : 24;
|
||||
const paddingBottom = 128;
|
||||
const marginBottom = 48;
|
||||
const messageGap = window.innerWidth < 768 ? 24 : 32;
|
||||
const paddingBottom = DEFAULT_ASSISTANT_BOTTOM_PADDING;
|
||||
const userMessageHeight = latestUserMessageRef.current.offsetHeight;
|
||||
setMinHeight(
|
||||
`calc(100dvh - ${headerHeight + gap + userMessageHeight + paddingBottom + marginBottom}px)`,
|
||||
`calc(100dvh - ${headerHeight + messageGap * 3 + userMessageHeight + paddingBottom}px)`,
|
||||
);
|
||||
}
|
||||
}, [messages.length, latestUserMessageRef.current]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [messages.length]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const updateScrollButton = useCallback(() => {
|
||||
const c = messagesContainerRef.current;
|
||||
|
|
@ -573,6 +593,41 @@ export function ChatView({
|
|||
};
|
||||
}, [panelMounted]);
|
||||
|
||||
const rawActiveInput = (() => {
|
||||
for (
|
||||
let messageIndex = messages.length - 1;
|
||||
messageIndex >= 0;
|
||||
messageIndex--
|
||||
) {
|
||||
const message = messages[messageIndex];
|
||||
if (message.role === "user") return null;
|
||||
if (message.role !== "assistant" || !message.events) continue;
|
||||
for (
|
||||
let eventIndex = message.events.length - 1;
|
||||
eventIndex >= 0;
|
||||
eventIndex--
|
||||
) {
|
||||
const event = message.events[eventIndex];
|
||||
if (event.type === "ask_inputs_response") {
|
||||
return null;
|
||||
}
|
||||
if (event.type === "ask_inputs") {
|
||||
return {
|
||||
key: `${messageIndex}-${eventIndex}`,
|
||||
event,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
const activeInput =
|
||||
rawActiveInput && !hiddenAskInputKeys.has(rawActiveInput.key)
|
||||
? rawActiveInput
|
||||
: null;
|
||||
|
||||
const messagesBottomPadding = DEFAULT_ASSISTANT_BOTTOM_PADDING;
|
||||
|
||||
return (
|
||||
<div className="h-full w-full flex relative">
|
||||
{/* Chat column */}
|
||||
|
|
@ -583,9 +638,12 @@ export function ChatView({
|
|||
className="flex-1 w-full overflow-y-auto"
|
||||
style={{ scrollbarGutter: "stable both-edges" }}
|
||||
>
|
||||
<div className="w-full max-w-4xl mx-auto pb-32 px-6 md:px-8 pt-4 md:pt-6 min-h-full flex flex-col relative">
|
||||
<div
|
||||
className="w-full max-w-4xl mx-auto px-6 pt-6 md:px-8 md:pt-8 min-h-full flex flex-col relative"
|
||||
style={{ paddingBottom: messagesBottomPadding }}
|
||||
>
|
||||
{!messagesVisible && (
|
||||
<div className="space-y-6 w-full">
|
||||
<div className="space-y-6 md:space-y-8 w-full">
|
||||
<div className="flex justify-end">
|
||||
<div className="bg-gray-100 rounded-2xl p-4 w-2/5">
|
||||
<div className="h-4 bg-gradient-to-r from-gray-200 via-gray-300 to-gray-200 bg-[length:200%_100%] animate-[shimmer_2s_ease-in-out_infinite] rounded w-full" />
|
||||
|
|
@ -602,7 +660,7 @@ export function ChatView({
|
|||
</div>
|
||||
)}
|
||||
<div
|
||||
className="space-y-6 transition-opacity duration-150"
|
||||
className="space-y-6 md:space-y-8 transition-opacity duration-150"
|
||||
style={{ opacity: messagesVisible ? 1 : 0 }}
|
||||
>
|
||||
{(() => {
|
||||
|
|
@ -629,7 +687,6 @@ export function ChatView({
|
|||
/>
|
||||
) : (
|
||||
<AssistantMessage
|
||||
content={msg.content ?? ""}
|
||||
events={msg.events}
|
||||
isStreaming={
|
||||
i === messages.length - 1 &&
|
||||
|
|
@ -642,7 +699,7 @@ export function ChatView({
|
|||
? (msg as any).error
|
||||
: undefined
|
||||
}
|
||||
annotations={msg.annotations}
|
||||
citations={msg.citations}
|
||||
citationStatus={
|
||||
msg.citationStatus
|
||||
}
|
||||
|
|
@ -702,14 +759,16 @@ export function ChatView({
|
|||
{showScrollButton && (
|
||||
<div
|
||||
className="absolute left-1/2 -translate-x-1/2 z-19"
|
||||
style={{ bottom: inputHeight + 12 }}
|
||||
style={{
|
||||
bottom:
|
||||
inputHeight +
|
||||
CHAT_INPUT_BOTTOM_OFFSET +
|
||||
SCROLL_BUTTON_INPUT_GAP,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={scrollToBottom}
|
||||
className={cn(
|
||||
"rounded-full p-2 cursor-pointer transition-all",
|
||||
"bg-white/30 shadow-[0_5px_16px_rgba(15,23,42,0.13),inset_0_1px_0_rgba(255,255,255,0.75),inset_0_-8px_18px_rgba(255,255,255,0.26)] backdrop-blur-xl hover:bg-white/45 hover:shadow-[0_7px_20px_rgba(15,23,42,0.16),inset_0_1px_0_rgba(255,255,255,0.85),inset_0_-8px_18px_rgba(255,255,255,0.32)]",
|
||||
)}
|
||||
className="rounded-full p-2 cursor-pointer transition-all bg-white/30 shadow-[0_5px_16px_rgba(15,23,42,0.13),inset_0_1px_0_rgba(255,255,255,0.75),inset_0_-8px_18px_rgba(255,255,255,0.26)] backdrop-blur-xl hover:bg-white/45 hover:shadow-[0_7px_20px_rgba(15,23,42,0.16),inset_0_1px_0_rgba(255,255,255,0.85),inset_0_-8px_18px_rgba(255,255,255,0.32)]"
|
||||
>
|
||||
<ArrowDown className="h-6 w-6 text-gray-500" />
|
||||
</button>
|
||||
|
|
@ -717,34 +776,51 @@ export function ChatView({
|
|||
)}
|
||||
|
||||
{/* Chat input */}
|
||||
<div
|
||||
ref={chatInputRef}
|
||||
className="absolute bottom-0 left-0 right-0 w-full z-30"
|
||||
>
|
||||
<div className="absolute bottom-3 left-0 right-0 w-full z-30">
|
||||
<div className="pointer-events-none absolute -bottom-3 left-0 right-0 z-0">
|
||||
<div className="mx-auto h-7 w-full max-w-4xl px-4 md:px-6">
|
||||
<div className="h-full rounded-t-[20px] bg-white/50 backdrop-blur-[1px]" />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute bottom-0 left-0 z-0",
|
||||
"right-4 h-28 bg-gradient-to-t from-white/50 via-white/25 to-transparent backdrop-blur-[1px]",
|
||||
)}
|
||||
/>
|
||||
<div className="relative z-20 w-full max-w-4xl mx-auto px-4 md:px-6">
|
||||
<div
|
||||
className={cn(
|
||||
"w-full rounded-t-[20px]",
|
||||
"bg-transparent",
|
||||
ref={measuredInputRef}
|
||||
className="relative z-20 w-full max-w-4xl mx-auto px-4 md:px-6"
|
||||
>
|
||||
<div className="w-full rounded-t-[20px] bg-transparent">
|
||||
{activeInput ? (
|
||||
<AskInputPopup
|
||||
key={activeInput.key}
|
||||
event={activeInput.event}
|
||||
onSubmit={(response, content, files) => {
|
||||
setHiddenAskInputKeys((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(activeInput.key);
|
||||
return next;
|
||||
});
|
||||
void handleChat(
|
||||
{ role: "user", content, files },
|
||||
{
|
||||
askInputsResponse: response,
|
||||
},
|
||||
);
|
||||
}}
|
||||
onDismiss={() => {
|
||||
setHiddenAskInputKeys((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(activeInput.key);
|
||||
return next;
|
||||
});
|
||||
cancel();
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<ChatInput
|
||||
ref={chatInputRef}
|
||||
onSubmit={handleChat}
|
||||
onCancel={cancel}
|
||||
isLoading={isResponseLoading}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<ChatInput
|
||||
onSubmit={handleChat}
|
||||
onCancel={cancel}
|
||||
isLoading={isResponseLoading}
|
||||
/>
|
||||
<div className="py-3 text-center">
|
||||
<p className="text-xs text-gray-500">
|
||||
AI can make mistakes. Answers are not legal
|
||||
advice.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { useState } from "react";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { PillButton } from "@/components/ui/pill-button";
|
||||
import type { EditAnnotation } from "../shared/types";
|
||||
|
||||
function normalizeText(s: string) {
|
||||
|
|
@ -131,6 +132,7 @@ export function applyOptimisticResolution(
|
|||
|
||||
interface Props {
|
||||
annotation: EditAnnotation;
|
||||
changeNumber?: number;
|
||||
/**
|
||||
* External override for this edit's status. When set, takes
|
||||
* precedence over the annotation's DB status and the card's own
|
||||
|
|
@ -183,6 +185,7 @@ interface Props {
|
|||
*/
|
||||
export function EditCard({
|
||||
annotation,
|
||||
changeNumber,
|
||||
resolvedStatus,
|
||||
isReloading,
|
||||
onViewClick,
|
||||
|
|
@ -275,13 +278,16 @@ export function EditCard({
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="border border-gray-200 rounded-lg p-3 bg-gray-50">
|
||||
<div className="rounded-xl bg-white shadow-[0_3px_9px_rgba(15,23,42,0.1),inset_0_1px_0_rgba(255,255,255,0.9),inset_0_-4px_9px_rgba(255,255,255,0.05)] backdrop-blur-2xl p-3">
|
||||
{changeNumber !== undefined && (
|
||||
<p className="text-xs text-gray-400 mb-1.5">{changeNumber}</p>
|
||||
)}
|
||||
{annotation.reason && (
|
||||
<p className="text-xs text-gray-500 mb-2">
|
||||
{annotation.reason}
|
||||
</p>
|
||||
)}
|
||||
<div className="text-sm leading-relaxed font-serif bg-white border border-gray-200 rounded-md px-2 py-2">
|
||||
<div className="text-sm leading-relaxed font-serif bg-gray-100/70 rounded-lg px-2 py-2">
|
||||
{annotation.inserted_text && (
|
||||
<span className="text-green-700">
|
||||
{annotation.inserted_text}
|
||||
|
|
@ -294,22 +300,26 @@ export function EditCard({
|
|||
)}
|
||||
</div>
|
||||
<div className="flex gap-2 mt-3">
|
||||
<button
|
||||
<PillButton
|
||||
tone="black"
|
||||
size="sm"
|
||||
onClick={() => handle("accept")}
|
||||
disabled={inFlight || resolved}
|
||||
className="px-2 py-1 text-xs rounded border border-gray-900 bg-gray-900 text-white hover:bg-gray-800 disabled:opacity-50"
|
||||
>
|
||||
{status === "accepted" ? "Accepted" : "Accept"}
|
||||
</button>
|
||||
<button
|
||||
</PillButton>
|
||||
<PillButton
|
||||
tone="white"
|
||||
size="sm"
|
||||
onClick={() => handle("reject")}
|
||||
disabled={inFlight || resolved}
|
||||
className="px-2 py-1 text-xs rounded border border-gray-200 bg-white text-gray-700 hover:bg-gray-100 disabled:opacity-50"
|
||||
>
|
||||
{status === "rejected" ? "Rejected" : "Reject"}
|
||||
</button>
|
||||
</PillButton>
|
||||
{onViewClick && (
|
||||
<button
|
||||
<PillButton
|
||||
tone="blue"
|
||||
size="sm"
|
||||
onClick={() => onViewClick(annotation)}
|
||||
disabled={resolved}
|
||||
title={
|
||||
|
|
@ -317,10 +327,10 @@ export function EditCard({
|
|||
? "This change has been resolved and is no longer in the document."
|
||||
: undefined
|
||||
}
|
||||
className="ml-auto px-2 py-1 text-xs rounded border border-gray-200 bg-white text-gray-700 hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-white"
|
||||
className="ml-auto"
|
||||
>
|
||||
View
|
||||
</button>
|
||||
</PillButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -4,8 +4,7 @@ import { useEffect, useState } from "react";
|
|||
import { useRouter } from "next/navigation";
|
||||
import { useChatHistoryContext } from "@/app/contexts/ChatHistoryContext";
|
||||
import { useDirectoryData } from "../shared/useDirectoryData";
|
||||
import { ProjectPicker } from "../shared/ProjectPicker";
|
||||
import { Modal } from "../shared/Modal";
|
||||
import { ProjectPickerModal } from "../modals/ProjectPickerModal";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
|
|
@ -40,22 +39,19 @@ export function SelectAssistantProjectModal({ open, onClose }: Props) {
|
|||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
<ProjectPickerModal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
breadcrumbs={["Assistant", "Start Chat in a Project"]}
|
||||
projects={projects}
|
||||
loading={loading}
|
||||
selectedId={selectedId}
|
||||
onSelect={setSelectedId}
|
||||
primaryAction={{
|
||||
label: creating ? "Creating…" : "Continue",
|
||||
onClick: handleContinue,
|
||||
disabled: !selectedId || creating,
|
||||
}}
|
||||
>
|
||||
<ProjectPicker
|
||||
projects={projects}
|
||||
loading={loading}
|
||||
selectedId={selectedId}
|
||||
onSelect={setSelectedId}
|
||||
/>
|
||||
</Modal>
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { File, FileText, Library } from "lucide-react";
|
||||
import { Library } from "lucide-react";
|
||||
import { FileTypeIcon } from "../shared/FileTypeIcon";
|
||||
|
||||
interface Props {
|
||||
content: string;
|
||||
|
|
@ -23,22 +24,15 @@ export function UserMessage({ content, files, workflow }: Props) {
|
|||
<span className="max-w-[140px] truncate">{workflow.title}</span>
|
||||
</div>
|
||||
)}
|
||||
{hasFiles && files.map((f, i) => {
|
||||
const ext = f.filename.split(".").pop()?.toLowerCase();
|
||||
const isPdf = ext === "pdf";
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="inline-flex items-center gap-1 rounded-[10px] border border-white/70 bg-white py-0.5 pl-2 pr-2.5 text-xs text-gray-800 shadow-[0_2px_6px_rgba(15,23,42,0.08),inset_0_1px_0_rgba(255,255,255,0.9)] backdrop-blur-xl"
|
||||
>
|
||||
{isPdf
|
||||
? <FileText className="h-2.5 w-2.5 shrink-0 text-red-500" />
|
||||
: <File className="h-2.5 w-2.5 shrink-0 text-blue-500" />
|
||||
}
|
||||
<span className="max-w-[140px] truncate">{f.filename}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{hasFiles && files.map((f, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="inline-flex items-center gap-1 rounded-[10px] border border-white/70 bg-white py-0.5 pl-2 pr-2.5 text-xs text-gray-800 shadow-[0_2px_6px_rgba(15,23,42,0.08),inset_0_1px_0_rgba(255,255,255,0.9)] backdrop-blur-xl"
|
||||
>
|
||||
<FileTypeIcon fileType={f.filename} className="h-2.5 w-2.5" />
|
||||
<span className="max-w-[140px] truncate">{f.filename}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,81 +0,0 @@
|
|||
import { X } from "lucide-react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
interface CreditsExhaustedModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
resetDate: string;
|
||||
}
|
||||
|
||||
export function CreditsExhaustedModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
resetDate,
|
||||
}: CreditsExhaustedModalProps) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
// Format the reset date
|
||||
const formatResetDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString("en-US", {
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-[200]"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-[201] w-full max-w-md px-4">
|
||||
<div className="relative bg-white rounded-2xl shadow-2xl p-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<h2 className="text-3xl font-light font-eb-garamond text-gray-900">
|
||||
Message Limit Reached
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="space-y-4">
|
||||
<p className="text-gray-600">
|
||||
You've reached your monthly message limit of 100
|
||||
messages.
|
||||
</p>
|
||||
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<p className="text-sm text-blue-900 font-medium mb-1">
|
||||
Your credits will reset on:
|
||||
</p>
|
||||
<p className="text-lg font-semibold text-blue-700">
|
||||
{formatResetDate(resetDate)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-500">
|
||||
Your message credits automatically reset on the
|
||||
first day of each month.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="mt-6 flex gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-1 px-4 py-2.5 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg font-medium transition-colors"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
|
@ -1,97 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { X, Check } from "lucide-react";
|
||||
|
||||
interface DeleteChatsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
chatCount: number;
|
||||
isDeleting: boolean;
|
||||
isSuccess?: boolean;
|
||||
}
|
||||
|
||||
export function DeleteChatsModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
chatCount,
|
||||
isDeleting,
|
||||
isSuccess = false,
|
||||
}: DeleteChatsModalProps) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-199"
|
||||
onClick={isDeleting || isSuccess ? undefined : onClose}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-200 w-full max-w-md">
|
||||
<div className="bg-white rounded-2xl shadow-2xl p-8">
|
||||
{isSuccess ? (
|
||||
<>
|
||||
{/* Success State */}
|
||||
<div className="text-center">
|
||||
<div className="mx-auto w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mb-4">
|
||||
<Check className="h-8 w-8 text-green-600" />
|
||||
</div>
|
||||
<h2 className="text-3xl font-light font-eb-garamond text-gray-900 mb-2">
|
||||
All Chats Deleted
|
||||
</h2>
|
||||
<p className="text-gray-600 text-sm">
|
||||
Your chat history has been successfully
|
||||
deleted.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-4xl font-light font-eb-garamond text-red-700">
|
||||
Delete All Chats
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="space-y-4">
|
||||
<p className="text-gray-600 text-sm leading-relaxed">
|
||||
Are you sure you want to delete all{" "}
|
||||
{chatCount} chat
|
||||
{chatCount !== 1 ? "s" : ""}? This action is
|
||||
permanent and cannot be undone.
|
||||
</p>
|
||||
|
||||
<div className="space-y-3 pt-4">
|
||||
<Button
|
||||
onClick={onConfirm}
|
||||
disabled={isDeleting}
|
||||
variant="destructive"
|
||||
className="w-full bg-red-600 hover:bg-red-700 text-white"
|
||||
>
|
||||
{isDeleting
|
||||
? "Deleting..."
|
||||
: "Delete All Chats"}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onClose}
|
||||
variant="outline"
|
||||
disabled={isDeleting}
|
||||
className="w-full border-gray-300 text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,90 +0,0 @@
|
|||
import { X, Link2, Check } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
interface SimpleLinkDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
shareUrl: string | null;
|
||||
}
|
||||
|
||||
export function SimpleLinkDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
shareUrl,
|
||||
}: SimpleLinkDialogProps) {
|
||||
const [linkCopied, setLinkCopied] = useState(false);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleCopyLink = async () => {
|
||||
if (!shareUrl) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(shareUrl);
|
||||
setLinkCopied(true);
|
||||
setTimeout(() => setLinkCopied(false), 2000);
|
||||
} catch (err) {}
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-[199]"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Dialog */}
|
||||
<div className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-[200] w-full max-w-md px-4">
|
||||
<div className="relative bg-white rounded-2xl shadow-2xl p-6">
|
||||
{/* Close Button */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute right-4 top-4 text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-3xl font-light font-eb-garamond text-gray-900">
|
||||
Share Chat
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="space-y-4">
|
||||
{/* Link display */}
|
||||
<div className="bg-gray-50 rounded-lg p-3 border border-gray-200">
|
||||
<p className="text-sm text-gray-600 mb-2 font-medium">
|
||||
Share Link
|
||||
</p>
|
||||
<p className="text-sm text-gray-800 break-all font-mono">
|
||||
{shareUrl}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Copy button */}
|
||||
<button
|
||||
onClick={handleCopyLink}
|
||||
className="w-full flex items-center justify-center gap-2 bg-blue-600 hover:bg-blue-700 text-white py-2.5 px-4 rounded-lg transition-colors font-medium"
|
||||
>
|
||||
{linkCopied ? (
|
||||
<>
|
||||
<Check className="h-5 w-5" />
|
||||
Copied!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link2 className="h-5 w-5" />
|
||||
Copy Link
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
|
@ -12,10 +12,12 @@ import {
|
|||
Upload,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { ConfirmPopup } from "@/app/components/shared/ConfirmPopup";
|
||||
import { DocView } from "@/app/components/shared/DocView";
|
||||
import { WarningPopup } from "@/app/components/shared/WarningPopup";
|
||||
import { ConfirmPopup } from "@/app/components/popups/ConfirmPopup";
|
||||
import { PdfView } from "@/app/components/shared/views/PdfView";
|
||||
import { SpreadsheetView } from "@/app/components/shared/views/SpreadsheetView";
|
||||
import { WarningPopup } from "@/app/components/popups/WarningPopup";
|
||||
import type { Document } from "@/app/components/shared/types";
|
||||
import { isSpreadsheetFilename } from "@/app/components/shared/types";
|
||||
import type { DocumentVersion } from "@/app/lib/mikeApi";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatBytes } from "./ProjectPageParts";
|
||||
|
|
@ -163,7 +165,7 @@ export function DocumentSidePanel({
|
|||
|
||||
const activeDoc = doc;
|
||||
const documentId = activeDoc.id;
|
||||
const newVersionAccept = ".pdf,.docx,.doc";
|
||||
const newVersionAccept = ".pdf,.docx,.doc,.xlsx,.xlsm,.xls,.pptx,.ppt";
|
||||
const orderedVersions = [...versions].reverse();
|
||||
const activeVersionCount = versions.filter(
|
||||
(version) => version.deleted_at == null,
|
||||
|
|
@ -481,15 +483,25 @@ export function DocumentSidePanel({
|
|||
"rounded-xl border border-gray-200 bg-white/55 shadow-[inset_0_1px_0_rgba(255,255,255,0.8)] backdrop-blur-xl",
|
||||
)}
|
||||
>
|
||||
<DocView
|
||||
key={`${selectedVersionId ?? "current"}:${selectedUploadedAt ?? ""}:${selectedSizeBytes ?? ""}`}
|
||||
doc={{
|
||||
document_id: doc.id,
|
||||
version_id: selectedVersionId,
|
||||
}}
|
||||
rounded={false}
|
||||
bordered={false}
|
||||
/>
|
||||
{isSpreadsheetFilename(selectedFilename) ? (
|
||||
<SpreadsheetView
|
||||
key={`${selectedVersionId ?? "current"}:${selectedUploadedAt ?? ""}:${selectedSizeBytes ?? ""}`}
|
||||
documentId={doc.id}
|
||||
versionId={selectedVersionId}
|
||||
rounded={false}
|
||||
bordered={false}
|
||||
/>
|
||||
) : (
|
||||
<PdfView
|
||||
key={`${selectedVersionId ?? "current"}:${selectedUploadedAt ?? ""}:${selectedSizeBytes ?? ""}`}
|
||||
doc={{
|
||||
document_id: doc.id,
|
||||
version_id: selectedVersionId,
|
||||
}}
|
||||
rounded={false}
|
||||
bordered={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { Users, Upload } from "lucide-react";
|
||||
import { Upload, User, X } from "lucide-react";
|
||||
import {
|
||||
addDocumentToProject,
|
||||
createProject,
|
||||
|
|
@ -9,10 +9,14 @@ import {
|
|||
} from "@/app/lib/mikeApi";
|
||||
import { useDirectoryData } from "../shared/useDirectoryData";
|
||||
import { FileDirectory } from "../shared/FileDirectory";
|
||||
import { EmailPillInput } from "../shared/EmailPillInput";
|
||||
import { AddUserInput } from "../shared/AddUserInput";
|
||||
import type { Project } from "../shared/types";
|
||||
import type { UserLookupResult } from "@/app/lib/mikeApi";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { Modal } from "../shared/Modal";
|
||||
import { Modal } from "../modals/Modal";
|
||||
import { ModalFieldLabel } from "../modals/ModalFieldLabel";
|
||||
import { ModalTextInput } from "../modals/ModalTextInput";
|
||||
import { ProjectPracticeField } from "./ProjectPracticeField";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
|
|
@ -21,10 +25,11 @@ interface Props {
|
|||
}
|
||||
|
||||
export function NewProjectModal({ open, onClose, onCreated }: Props) {
|
||||
const [step, setStep] = useState<"details" | "documents">("details");
|
||||
const [name, setName] = useState("");
|
||||
const [cmNumber, setCmNumber] = useState("");
|
||||
const [sharedEmails, setSharedEmails] = useState<string[]>([]);
|
||||
const [showMembers, setShowMembers] = useState(false);
|
||||
const [practice, setPractice] = useState("");
|
||||
const [sharedUsers, setSharedUsers] = useState<UserLookupResult[]>([]);
|
||||
const [selectedDocIds, setSelectedDocIds] = useState<Set<string>>(new Set());
|
||||
const [pendingFiles, setPendingFiles] = useState<File[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
|
@ -38,6 +43,14 @@ export function NewProjectModal({ open, onClose, onCreated }: Props) {
|
|||
|
||||
if (!open) return null;
|
||||
|
||||
function submitterValue(e: React.FormEvent<HTMLFormElement>) {
|
||||
return (
|
||||
(e.nativeEvent as SubmitEvent).submitter as
|
||||
| HTMLButtonElement
|
||||
| null
|
||||
)?.value;
|
||||
}
|
||||
|
||||
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const files = Array.from(e.target.files ?? []);
|
||||
e.target.value = "";
|
||||
|
|
@ -45,18 +58,27 @@ export function NewProjectModal({ open, onClose, onCreated }: Props) {
|
|||
setPendingFiles((prev) => [...prev, ...files.filter((f) => !prev.some((p) => p.name === f.name))]);
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
if (!name.trim()) return;
|
||||
if (step === "details" || submitterValue(e) !== "create-project") {
|
||||
setStep("documents");
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const project = await createProject(
|
||||
name.trim(),
|
||||
cmNumber.trim() || undefined,
|
||||
practice.trim() && practice.trim() !== "Other"
|
||||
? practice.trim()
|
||||
: undefined,
|
||||
ownEmail
|
||||
? sharedEmails.filter((email) => email !== ownEmail)
|
||||
: sharedEmails,
|
||||
? sharedUsers
|
||||
.map((user) => user.email)
|
||||
.filter((email) => email !== ownEmail)
|
||||
: sharedUsers.map((user) => user.email),
|
||||
);
|
||||
await Promise.all([
|
||||
...[...selectedDocIds].map((id) => addDocumentToProject(project.id, id).catch(() => {})),
|
||||
|
|
@ -73,10 +95,11 @@ export function NewProjectModal({ open, onClose, onCreated }: Props) {
|
|||
}
|
||||
|
||||
function resetForm() {
|
||||
setStep("details");
|
||||
setName("");
|
||||
setCmNumber("");
|
||||
setSharedEmails([]);
|
||||
setShowMembers(false);
|
||||
setPractice("");
|
||||
setSharedUsers([]);
|
||||
setSelectedDocIds(new Set());
|
||||
setPendingFiles([]);
|
||||
setError("");
|
||||
|
|
@ -87,22 +110,88 @@ export function NewProjectModal({ open, onClose, onCreated }: Props) {
|
|||
onClose();
|
||||
}
|
||||
|
||||
function validateShareUser(email: string) {
|
||||
if (ownEmail && email === ownEmail) {
|
||||
return "You cannot share a project with yourself.";
|
||||
}
|
||||
if (
|
||||
sharedUsers.some(
|
||||
(user) => user.email.trim().toLowerCase() === email,
|
||||
)
|
||||
) {
|
||||
return `${email} already has access.`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function handleAddShareUser(user: UserLookupResult) {
|
||||
setSharedUsers((prev) => [
|
||||
...prev,
|
||||
{
|
||||
...user,
|
||||
email: user.email.trim().toLowerCase(),
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
function handleRemoveShareUser(email: string) {
|
||||
setSharedUsers((prev) =>
|
||||
prev.filter(
|
||||
(user) =>
|
||||
user.email.trim().toLowerCase() !==
|
||||
email.trim().toLowerCase(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
breadcrumbs={["Projects", "New project"]}
|
||||
secondaryAction={{
|
||||
label: `Upload files${pendingFiles.length > 0 ? ` (${pendingFiles.length})` : ""}`,
|
||||
icon: <Upload className="h-3.5 w-3.5" />,
|
||||
onClick: () => fileInputRef.current?.click(),
|
||||
}}
|
||||
primaryAction={{
|
||||
label: loading ? "Creating…" : "Create project",
|
||||
type: "submit",
|
||||
form: formId,
|
||||
disabled: !name.trim() || loading,
|
||||
}}
|
||||
breadcrumbs={[
|
||||
"Projects",
|
||||
"New project",
|
||||
step === "details" ? "Details" : "Documents",
|
||||
]}
|
||||
secondaryAction={
|
||||
step === "documents"
|
||||
? {
|
||||
label: `Upload documents${pendingFiles.length > 0 ? ` (${pendingFiles.length})` : ""}`,
|
||||
icon: <Upload className="h-3.5 w-3.5" />,
|
||||
onClick: () => fileInputRef.current?.click(),
|
||||
disabled: loading,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
cancelAction={
|
||||
step === "documents"
|
||||
? {
|
||||
label: "Back",
|
||||
onClick: () => setStep("details"),
|
||||
disabled: loading,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
primaryAction={
|
||||
step === "details"
|
||||
? {
|
||||
label: "Next",
|
||||
type: "button",
|
||||
onClick: (event) => {
|
||||
event.preventDefault();
|
||||
setStep("documents");
|
||||
},
|
||||
disabled: !name.trim() || loading,
|
||||
}
|
||||
: {
|
||||
label: loading ? "Creating…" : "Create project",
|
||||
type: "submit",
|
||||
form: formId,
|
||||
name: "modalAction",
|
||||
value: "create-project",
|
||||
disabled: !name.trim() || loading,
|
||||
}
|
||||
}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
|
|
@ -116,61 +205,124 @@ export function NewProjectModal({ open, onClose, onCreated }: Props) {
|
|||
onSubmit={handleSubmit}
|
||||
className="flex flex-col flex-1 min-h-0"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Project name"
|
||||
className="w-full text-2xl font-serif text-gray-800 placeholder-gray-300 focus:outline-none bg-transparent"
|
||||
autoFocus
|
||||
/>
|
||||
{step === "details" ? (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<ModalFieldLabel htmlFor="new-project-name">
|
||||
Project name
|
||||
</ModalFieldLabel>
|
||||
<ModalTextInput
|
||||
id="new-project-name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Add project name"
|
||||
variant="minimal"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={cmNumber}
|
||||
onChange={(e) => setCmNumber(e.target.value)}
|
||||
placeholder="Add a CM number..."
|
||||
className="mt-1.5 w-full text-sm text-gray-500 placeholder-gray-300 focus:outline-none bg-transparent"
|
||||
/>
|
||||
<div>
|
||||
<ModalFieldLabel htmlFor="new-project-cm-number">
|
||||
CM number
|
||||
</ModalFieldLabel>
|
||||
<ModalTextInput
|
||||
id="new-project-cm-number"
|
||||
type="text"
|
||||
value={cmNumber}
|
||||
onChange={(e) => setCmNumber(e.target.value)}
|
||||
placeholder="Add a CM number..."
|
||||
variant="minimal"
|
||||
className="text-xl text-gray-600"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowMembers((v) => !v)}
|
||||
className="flex items-center gap-1.5 rounded-full border border-gray-200 px-3 py-1 text-xs text-gray-600 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<Users className="h-3 w-3 text-gray-400" />
|
||||
Members{sharedEmails.length > 0 ? ` (${sharedEmails.length})` : ""}
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<ModalFieldLabel htmlFor="new-project-practice">
|
||||
Practice
|
||||
</ModalFieldLabel>
|
||||
<ProjectPracticeField
|
||||
id="new-project-practice"
|
||||
value={practice}
|
||||
onChange={setPractice}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showMembers && (
|
||||
<div className="mt-3">
|
||||
<EmailPillInput
|
||||
emails={sharedEmails}
|
||||
onChange={setSharedEmails}
|
||||
validate={async (email) =>
|
||||
ownEmail && email === ownEmail
|
||||
? "You cannot share a project with yourself."
|
||||
: null
|
||||
}
|
||||
placeholder="Add colleagues by email…"
|
||||
<div className="space-y-2">
|
||||
<ModalFieldLabel as="p">
|
||||
Share with
|
||||
</ModalFieldLabel>
|
||||
<AddUserInput
|
||||
onAdd={handleAddShareUser}
|
||||
validateEmail={validateShareUser}
|
||||
placeholder="Add colleagues by email..."
|
||||
/>
|
||||
{sharedUsers.length > 0 && (
|
||||
<ul className="space-y-1 pt-1">
|
||||
{sharedUsers.map((entry) => {
|
||||
const displayName =
|
||||
entry.display_name?.trim();
|
||||
const primary = displayName || "User";
|
||||
const initial = displayName
|
||||
?.charAt(0)
|
||||
.toUpperCase();
|
||||
return (
|
||||
<li
|
||||
key={entry.email}
|
||||
className="flex items-center gap-2.5 rounded-lg px-2 py-1.5 transition-colors hover:bg-gray-100/70"
|
||||
>
|
||||
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full border border-white/80 bg-white text-gray-700 shadow-[0_4px_12px_rgba(15,23,42,0.10),inset_0_1px_0_rgba(255,255,255,0.92),inset_0_-1px_0_rgba(255,255,255,0.64)]">
|
||||
{initial ? (
|
||||
<span className="font-serif text-[11px] leading-none">
|
||||
{initial}
|
||||
</span>
|
||||
) : (
|
||||
<User className="h-2.5 w-2.5" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-xs text-gray-800">
|
||||
{primary}
|
||||
<span className="text-gray-400">
|
||||
{" "}
|
||||
· {entry.email}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
handleRemoveShareUser(
|
||||
entry.email,
|
||||
)
|
||||
}
|
||||
className="self-center inline-flex items-center rounded-full px-2 py-1 text-xs text-gray-500 transition-colors hover:text-red-600"
|
||||
aria-label={`Remove ${entry.email}`}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="min-h-0 flex-1">
|
||||
<FileDirectory
|
||||
standaloneDocs={standaloneDocuments}
|
||||
directoryProjects={dirProjects}
|
||||
loading={dirLoading}
|
||||
selectedIds={selectedDocIds}
|
||||
onChange={setSelectedDocIds}
|
||||
emptyMessage="No existing documents"
|
||||
searchable
|
||||
searchAutoFocus
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 space-y-2">
|
||||
<p className="text-xs font-medium text-gray-700">Select documents</p>
|
||||
<FileDirectory
|
||||
standaloneDocs={standaloneDocuments}
|
||||
directoryProjects={dirProjects}
|
||||
loading={dirLoading}
|
||||
selectedIds={selectedDocIds}
|
||||
onChange={setSelectedDocIds}
|
||||
emptyMessage="No existing documents"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="mt-3 text-sm text-red-500">{error}</p>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -67,34 +67,37 @@ export function ProjectAssistantTable({
|
|||
loading?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<TableScrollArea>
|
||||
<TableHeaderRow className="pr-8 md:pr-8">
|
||||
<TableStickyCell header>
|
||||
{loading ? (
|
||||
<SkeletonDot />
|
||||
) : (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allChatsSelected}
|
||||
ref={(el) => {
|
||||
if (el) el.indeterminate = someChatsSelected;
|
||||
}}
|
||||
onChange={() => {
|
||||
if (allChatsSelected) setSelectedChatIds([]);
|
||||
else
|
||||
setSelectedChatIds(
|
||||
filteredChats.map((c) => c.id),
|
||||
);
|
||||
}}
|
||||
className={TABLE_CHECKBOX_CLASS}
|
||||
/>
|
||||
)}
|
||||
<span>Chats</span>
|
||||
</TableStickyCell>
|
||||
<TableHeaderCell className="ml-auto w-32">Creator</TableHeaderCell>
|
||||
<TableHeaderCell className="w-32">Created</TableHeaderCell>
|
||||
<TableHeaderCell className="w-8" />
|
||||
</TableHeaderRow>
|
||||
<TableScrollArea
|
||||
header={
|
||||
<TableHeaderRow className="pr-8 md:pr-8">
|
||||
<TableStickyCell header>
|
||||
{loading ? (
|
||||
<SkeletonDot />
|
||||
) : (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allChatsSelected}
|
||||
ref={(el) => {
|
||||
if (el) el.indeterminate = someChatsSelected;
|
||||
}}
|
||||
onChange={() => {
|
||||
if (allChatsSelected) setSelectedChatIds([]);
|
||||
else
|
||||
setSelectedChatIds(
|
||||
filteredChats.map((c) => c.id),
|
||||
);
|
||||
}}
|
||||
className={TABLE_CHECKBOX_CLASS}
|
||||
/>
|
||||
)}
|
||||
<span>Chats</span>
|
||||
</TableStickyCell>
|
||||
<TableHeaderCell className="ml-auto w-32">Creator</TableHeaderCell>
|
||||
<TableHeaderCell className="w-32">Created</TableHeaderCell>
|
||||
<TableHeaderCell className="w-8" />
|
||||
</TableHeaderRow>
|
||||
}
|
||||
>
|
||||
{loading ? (
|
||||
<ProjectAssistantLoadingRows />
|
||||
) : chats.length === 0 ? (
|
||||
|
|
|
|||
|
|
@ -1,38 +1,33 @@
|
|||
"use client";
|
||||
|
||||
import { type ReactNode, useEffect, useMemo, useState } from "react";
|
||||
import { Loader2, Users } from "lucide-react";
|
||||
import { Modal } from "@/app/components/shared/Modal";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Users } from "lucide-react";
|
||||
import { Modal } from "@/app/components/modals/Modal";
|
||||
import { ModalFieldLabel } from "@/app/components/modals/ModalFieldLabel";
|
||||
import { ModalTextInput } from "@/app/components/modals/ModalTextInput";
|
||||
import type { Project } from "@/app/components/shared/types";
|
||||
import type { ProjectPeople } from "@/app/lib/mikeApi";
|
||||
import { ProjectPracticeField } from "./ProjectPracticeField";
|
||||
|
||||
interface ProjectDetailsModalProps {
|
||||
open: boolean;
|
||||
project: Project | null;
|
||||
canEdit: boolean;
|
||||
currentUserDisplayName?: string | null;
|
||||
currentUserEmail?: string | null;
|
||||
fetchPeople: (projectId: string) => Promise<ProjectPeople>;
|
||||
onClose: () => void;
|
||||
onSave: (values: { name: string; cmNumber: string }) => Promise<void>;
|
||||
onShareProject: () => void;
|
||||
onSave: (values: { name: string; cmNumber: string; practice: string }) => Promise<void>;
|
||||
onShareProject?: () => void;
|
||||
}
|
||||
|
||||
export function ProjectDetailsModal({
|
||||
open,
|
||||
project,
|
||||
canEdit,
|
||||
currentUserDisplayName,
|
||||
currentUserEmail,
|
||||
fetchPeople,
|
||||
onClose,
|
||||
onSave,
|
||||
onShareProject,
|
||||
}: ProjectDetailsModalProps) {
|
||||
const [nameDraft, setNameDraft] = useState("");
|
||||
const [cmDraft, setCmDraft] = useState("");
|
||||
const [people, setPeople] = useState<ProjectPeople | null>(null);
|
||||
const [peopleLoading, setPeopleLoading] = useState(false);
|
||||
const [practiceDraft, setPracticeDraft] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
|
@ -41,70 +36,39 @@ export function ProjectDetailsModal({
|
|||
if (!open || !project) return;
|
||||
setNameDraft(project.name);
|
||||
setCmDraft(project.cm_number ?? "");
|
||||
setPracticeDraft(project.practice ?? "");
|
||||
setSaved(false);
|
||||
setError(null);
|
||||
}, [open, project]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !project) return;
|
||||
const isPrivateOwnedProject =
|
||||
project.is_owner !== false &&
|
||||
(!Array.isArray(project.shared_with) ||
|
||||
project.shared_with.length === 0);
|
||||
if (isPrivateOwnedProject) {
|
||||
setPeople(null);
|
||||
setPeopleLoading(false);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setPeopleLoading(true);
|
||||
fetchPeople(project.id)
|
||||
.then((data) => {
|
||||
if (!cancelled) setPeople(data);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setPeople(null);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setPeopleLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, project, fetchPeople]);
|
||||
|
||||
const trimmedName = nameDraft.trim();
|
||||
const trimmedCm = cmDraft.trim();
|
||||
const trimmedPractice = practiceDraft.trim();
|
||||
const hasChanges = useMemo(() => {
|
||||
if (!project) return false;
|
||||
return (
|
||||
trimmedName !== project.name ||
|
||||
trimmedCm !== (project.cm_number ?? "")
|
||||
trimmedCm !== (project.cm_number ?? "") ||
|
||||
trimmedPractice !== (project.practice ?? "")
|
||||
);
|
||||
}, [project, trimmedCm, trimmedName]);
|
||||
}, [project, trimmedCm, trimmedName, trimmedPractice]);
|
||||
|
||||
if (!project) return null;
|
||||
|
||||
const accessLabel =
|
||||
Array.isArray(project.shared_with) && project.shared_with.length > 0
|
||||
? "Shared"
|
||||
: "Private";
|
||||
const isPrivateOwnedProject =
|
||||
project.is_owner !== false && accessLabel === "Private";
|
||||
const ownerLabel =
|
||||
people?.owner.display_name?.trim() ||
|
||||
people?.owner.email?.trim() ||
|
||||
(isPrivateOwnedProject ? currentUserDisplayName?.trim() : "") ||
|
||||
(isPrivateOwnedProject ? currentUserEmail?.trim() : "") ||
|
||||
"Unknown";
|
||||
|
||||
async function handleSave() {
|
||||
if (!canEdit || saving || !hasChanges || !trimmedName) return;
|
||||
setSaving(true);
|
||||
setSaved(false);
|
||||
setError(null);
|
||||
try {
|
||||
await onSave({ name: trimmedName, cmNumber: trimmedCm });
|
||||
await onSave({
|
||||
name: trimmedName,
|
||||
cmNumber: trimmedCm,
|
||||
practice:
|
||||
trimmedPractice && trimmedPractice !== "Other"
|
||||
? trimmedPractice
|
||||
: "",
|
||||
});
|
||||
setSaved(true);
|
||||
} catch {
|
||||
setError("Could not update project details.");
|
||||
|
|
@ -118,11 +82,15 @@ export function ProjectDetailsModal({
|
|||
open={open}
|
||||
onClose={onClose}
|
||||
breadcrumbs={["Projects", project.name, "Details"]}
|
||||
secondaryAction={{
|
||||
label: "Share Project",
|
||||
icon: <Users className="h-4 w-4" />,
|
||||
onClick: onShareProject,
|
||||
}}
|
||||
secondaryAction={
|
||||
onShareProject
|
||||
? {
|
||||
label: "Share Project",
|
||||
icon: <Users className="h-4 w-4" />,
|
||||
onClick: onShareProject,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
footerStatus={
|
||||
error ? (
|
||||
<span className="text-sm text-red-600">{error}</span>
|
||||
|
|
@ -141,15 +109,12 @@ export function ProjectDetailsModal({
|
|||
}
|
||||
cancelAction={canEdit ? undefined : false}
|
||||
>
|
||||
<div className="flex flex-col gap-5 py-1">
|
||||
<div className="flex flex-col gap-3">
|
||||
<label
|
||||
htmlFor="project-details-name"
|
||||
className="text-xs font-medium text-gray-700"
|
||||
>
|
||||
Project Name
|
||||
</label>
|
||||
<input
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-6 py-1">
|
||||
<div>
|
||||
<ModalFieldLabel htmlFor="project-details-name">
|
||||
Project name
|
||||
</ModalFieldLabel>
|
||||
<ModalTextInput
|
||||
id="project-details-name"
|
||||
value={nameDraft}
|
||||
onChange={(e) => {
|
||||
|
|
@ -158,18 +123,16 @@ export function ProjectDetailsModal({
|
|||
setError(null);
|
||||
}}
|
||||
disabled={!canEdit || saving}
|
||||
className="h-9 w-full rounded-md border border-gray-200 bg-gray-50 px-3 text-sm text-gray-900 outline-none transition-colors focus:border-gray-300 disabled:cursor-not-allowed disabled:text-gray-400"
|
||||
placeholder="Add project name"
|
||||
variant="minimal"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<label
|
||||
htmlFor="project-details-cm"
|
||||
className="text-xs font-medium text-gray-700"
|
||||
>
|
||||
CM
|
||||
</label>
|
||||
<input
|
||||
<div>
|
||||
<ModalFieldLabel htmlFor="project-details-cm">
|
||||
CM number
|
||||
</ModalFieldLabel>
|
||||
<ModalTextInput
|
||||
id="project-details-cm"
|
||||
value={cmDraft}
|
||||
onChange={(e) => {
|
||||
|
|
@ -178,39 +141,29 @@ export function ProjectDetailsModal({
|
|||
setError(null);
|
||||
}}
|
||||
disabled={!canEdit || saving}
|
||||
placeholder="No CM"
|
||||
className="h-9 w-full rounded-md border border-gray-200 bg-gray-50 px-3 text-sm text-gray-900 outline-none transition-colors focus:border-gray-300 disabled:cursor-not-allowed disabled:text-gray-400"
|
||||
placeholder="Add a CM number..."
|
||||
variant="minimal"
|
||||
className="text-xl text-gray-600"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-gray-100 text-sm">
|
||||
<DetailRow label="Ownership" value={accessLabel} />
|
||||
<DetailRow
|
||||
label="Owner"
|
||||
value={
|
||||
peopleLoading && !isPrivateOwnedProject ? (
|
||||
<span className="inline-flex items-center gap-1.5 text-gray-400">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
Loading
|
||||
</span>
|
||||
) : (
|
||||
ownerLabel
|
||||
)
|
||||
}
|
||||
<div>
|
||||
<ModalFieldLabel htmlFor="project-details-practice">
|
||||
Practice
|
||||
</ModalFieldLabel>
|
||||
<ProjectPracticeField
|
||||
id="project-details-practice"
|
||||
value={practiceDraft}
|
||||
onChange={(value) => {
|
||||
setPracticeDraft(value);
|
||||
setSaved(false);
|
||||
setError(null);
|
||||
}}
|
||||
disabled={!canEdit || saving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailRow({ label, value }: { label: string; value: ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4 py-3">
|
||||
<span className="text-gray-500">{label}</span>
|
||||
<span className="min-w-0 truncate text-right text-gray-900">
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,10 +43,10 @@ import {
|
|||
import {
|
||||
AddDocumentsModal,
|
||||
invalidateDirectoryCache,
|
||||
} from "@/app/components/shared/AddDocumentsModal";
|
||||
} from "@/app/components/modals/AddDocumentsModal";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { WarningPopup } from "@/app/components/shared/WarningPopup";
|
||||
import { ConfirmPopup } from "@/app/components/shared/ConfirmPopup";
|
||||
import { WarningPopup } from "@/app/components/popups/WarningPopup";
|
||||
import { ConfirmPopup } from "@/app/components/popups/ConfirmPopup";
|
||||
import {
|
||||
formatUnsupportedDocumentWarning,
|
||||
partitionSupportedDocumentFiles,
|
||||
|
|
@ -92,9 +92,9 @@ function ProjectTableLoading({
|
|||
}) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<div className="flex items-center h-8 pr-8 border-b border-gray-200 text-xs text-gray-500 font-medium select-none shrink-0">
|
||||
<div className={`sticky top-0 z-[70] ${stickyCellBg} flex items-center h-8 pr-8 border-b border-gray-200 text-xs text-gray-500 font-medium select-none shrink-0`}>
|
||||
<div
|
||||
className={`sticky left-0 z-[60] ${DOC_NAME_COL_W} ${stickyCellBg} flex items-center gap-4 self-stretch pl-4 pr-2 text-left`}
|
||||
className={`sticky left-0 z-[80] ${DOC_NAME_COL_W} ${stickyCellBg} flex items-center gap-4 self-stretch pl-4 pr-2 text-left`}
|
||||
>
|
||||
<div className="h-2.5 w-2.5 rounded bg-gray-100 animate-pulse" />
|
||||
<span>Name</span>
|
||||
|
|
@ -1893,7 +1893,7 @@ export function ProjectDocumentsView({ projectId }: Props) {
|
|||
const sidePanelDoc = viewingDoc
|
||||
? (docs.find((doc) => doc.id === viewingDoc.id) ?? viewingDoc)
|
||||
: null;
|
||||
const versionUploadAccept = ".pdf,.docx,.doc";
|
||||
const versionUploadAccept = ".pdf,.docx,.doc,.xlsx,.xlsm,.xls,.pptx,.ppt";
|
||||
const q = search.toLowerCase();
|
||||
const filteredDocs = q
|
||||
? docs.filter((d) => d.filename.toLowerCase().includes(q))
|
||||
|
|
@ -2140,9 +2140,9 @@ export function ProjectDocumentsView({ projectId }: Props) {
|
|||
) : (
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
{/* Table header */}
|
||||
<div className="flex items-center h-8 pr-8 border-b border-gray-200 text-xs text-gray-500 font-medium select-none shrink-0">
|
||||
<div className={`sticky top-0 z-[70] ${stickyCellBg} flex items-center h-8 pr-8 border-b border-gray-200 text-xs text-gray-500 font-medium select-none shrink-0`}>
|
||||
<div
|
||||
className={`sticky left-0 z-[60] ${DOC_NAME_COL_W} ${stickyCellBg} flex items-center gap-4 self-stretch pl-4 pr-2 text-left`}
|
||||
className={`sticky left-0 z-[80] ${DOC_NAME_COL_W} ${stickyCellBg} flex items-center gap-4 self-stretch pl-4 pr-2 text-left`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
|
|
@ -2233,7 +2233,7 @@ export function ProjectDocumentsView({ projectId }: Props) {
|
|||
>
|
||||
<Upload className="h-8 w-8 text-gray-200 mb-3" />
|
||||
<p className="text-sm text-gray-400">
|
||||
Drop PDF, DOCX, or DOC files here
|
||||
Drop PDF, Word, Excel, or PowerPoint files here
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -2,8 +2,6 @@
|
|||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
FileText,
|
||||
File,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
ChevronRight,
|
||||
|
|
@ -16,6 +14,7 @@ import type {
|
|||
Folder as ProjectFolder,
|
||||
} from "@/app/components/shared/types";
|
||||
import { VersionChip } from "@/app/components/shared/VersionChip";
|
||||
import { FileTypeIcon } from "@/app/components/shared/FileTypeIcon";
|
||||
|
||||
interface Props {
|
||||
projectName?: string | null;
|
||||
|
|
@ -32,11 +31,7 @@ interface Props {
|
|||
}
|
||||
|
||||
function DocIcon({ fileType }: { fileType: string | null }) {
|
||||
if (fileType === "pdf")
|
||||
return <FileText className="h-3.5 w-3.5 text-red-500 shrink-0" />;
|
||||
if (fileType === "docx" || fileType === "doc")
|
||||
return <File className="h-3.5 w-3.5 text-blue-500 shrink-0" />;
|
||||
return <File className="h-3.5 w-3.5 text-gray-400 shrink-0" />;
|
||||
return <FileTypeIcon fileType={fileType} className="h-3.5 w-3.5" />;
|
||||
}
|
||||
|
||||
type ContextMenuState = {
|
||||
|
|
|
|||
|
|
@ -3,9 +3,6 @@
|
|||
import { type CSSProperties, useRef, useState } from "react";
|
||||
import {
|
||||
CornerDownRight,
|
||||
File,
|
||||
FileText,
|
||||
Info,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
Pencil,
|
||||
|
|
@ -14,6 +11,7 @@ import {
|
|||
Users,
|
||||
} from "lucide-react";
|
||||
import { PageHeader } from "@/app/components/shared/PageHeader";
|
||||
import { FileTypeIcon } from "@/app/components/shared/FileTypeIcon";
|
||||
import type { Project } from "@/app/components/shared/types";
|
||||
import type { DocumentVersion } from "@/app/lib/mikeApi";
|
||||
import { RowActions } from "@/app/components/shared/RowActions";
|
||||
|
|
@ -65,12 +63,7 @@ export function DocIcon({
|
|||
fileType: string | null;
|
||||
muted?: boolean;
|
||||
}) {
|
||||
if (muted) return <File className="h-4 w-4 text-gray-300 shrink-0" />;
|
||||
if (fileType === "pdf")
|
||||
return <FileText className="h-4 w-4 text-red-600 shrink-0" />;
|
||||
if (fileType === "docx" || fileType === "doc")
|
||||
return <File className="h-4 w-4 text-blue-600 shrink-0" />;
|
||||
return <File className="h-4 w-4 text-gray-500 shrink-0" />;
|
||||
return <FileTypeIcon fileType={fileType} className="h-4 w-4" muted={muted} />;
|
||||
}
|
||||
|
||||
export function DocVersionHistory({
|
||||
|
|
@ -361,7 +354,6 @@ export function ProjectPageHeader({
|
|||
docsCount,
|
||||
isOwner,
|
||||
onBackToProjects,
|
||||
onOwnerOnly,
|
||||
onOpenDetails,
|
||||
onDeleteProject,
|
||||
onSearchChange,
|
||||
|
|
@ -376,7 +368,6 @@ export function ProjectPageHeader({
|
|||
docsCount: number;
|
||||
isOwner: boolean;
|
||||
onBackToProjects: () => void;
|
||||
onOwnerOnly: (action: string) => void;
|
||||
onOpenDetails: () => void;
|
||||
onDeleteProject: () => void;
|
||||
onSearchChange: (search: string) => void;
|
||||
|
|
@ -384,25 +375,6 @@ export function ProjectPageHeader({
|
|||
onNewChat: () => void;
|
||||
onNewReview: () => void;
|
||||
}) {
|
||||
const requestRename = () => {
|
||||
if (!project) return;
|
||||
if (!isOwner) {
|
||||
onOwnerOnly("rename this project");
|
||||
return;
|
||||
}
|
||||
onOpenDetails();
|
||||
};
|
||||
|
||||
const titleLabel = !project ? undefined : (
|
||||
<span
|
||||
onClick={requestRename}
|
||||
className="inline-block cursor-text"
|
||||
title="Rename"
|
||||
>
|
||||
{project.name}
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<PageHeader
|
||||
breadcrumbs={[
|
||||
|
|
@ -414,8 +386,7 @@ export function ProjectPageHeader({
|
|||
{
|
||||
...(project
|
||||
? {
|
||||
label: titleLabel,
|
||||
cursor: "text",
|
||||
label: project.name,
|
||||
}
|
||||
: {
|
||||
loading: true,
|
||||
|
|
@ -443,13 +414,10 @@ export function ProjectPageHeader({
|
|||
<HeaderActionsMenu
|
||||
items={[
|
||||
{
|
||||
label: "Rename",
|
||||
label: isOwner
|
||||
? "Edit details"
|
||||
: "View details",
|
||||
icon: Pencil,
|
||||
onSelect: requestRename,
|
||||
},
|
||||
{
|
||||
label: "Project Details",
|
||||
icon: Info,
|
||||
onSelect: onOpenDetails,
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -31,18 +31,14 @@ export function ProjectReviewsTable({
|
|||
selectedReviewIds,
|
||||
allReviewsSelected,
|
||||
someReviewsSelected,
|
||||
renamingReviewId,
|
||||
renameReviewValue,
|
||||
creatingReview,
|
||||
currentUserId,
|
||||
onCreateReview,
|
||||
onOpenReview,
|
||||
onOpenDetails,
|
||||
onDeleteReview,
|
||||
onOwnerOnlyAction,
|
||||
submitReviewRename,
|
||||
setSelectedReviewIds,
|
||||
setRenamingReviewId,
|
||||
setRenameReviewValue,
|
||||
loading = false,
|
||||
}: {
|
||||
docs: Document[];
|
||||
|
|
@ -51,50 +47,49 @@ export function ProjectReviewsTable({
|
|||
selectedReviewIds: string[];
|
||||
allReviewsSelected: boolean;
|
||||
someReviewsSelected: boolean;
|
||||
renamingReviewId: string | null;
|
||||
renameReviewValue: string;
|
||||
creatingReview: boolean;
|
||||
currentUserId?: string | null;
|
||||
onCreateReview: () => void;
|
||||
onOpenReview: (reviewId: string) => void;
|
||||
onOpenDetails: (review: TabularReview) => void;
|
||||
onDeleteReview: (review: TabularReview) => Promise<void> | void;
|
||||
onOwnerOnlyAction: (action: string) => void;
|
||||
submitReviewRename: (reviewId: string) => Promise<void> | void;
|
||||
setSelectedReviewIds: Dispatch<SetStateAction<string[]>>;
|
||||
setRenamingReviewId: Dispatch<SetStateAction<string | null>>;
|
||||
setRenameReviewValue: Dispatch<SetStateAction<string>>;
|
||||
loading?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<TableScrollArea>
|
||||
<TableHeaderRow className="pr-8 md:pr-8">
|
||||
<TableStickyCell header>
|
||||
{loading ? (
|
||||
<SkeletonDot />
|
||||
) : (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allReviewsSelected}
|
||||
ref={(el) => {
|
||||
if (el) el.indeterminate = someReviewsSelected;
|
||||
}}
|
||||
onChange={() => {
|
||||
if (allReviewsSelected) setSelectedReviewIds([]);
|
||||
else
|
||||
setSelectedReviewIds(
|
||||
filteredReviews.map((r) => r.id),
|
||||
);
|
||||
}}
|
||||
className={TABLE_CHECKBOX_CLASS}
|
||||
/>
|
||||
)}
|
||||
<span>Name</span>
|
||||
</TableStickyCell>
|
||||
<TableHeaderCell className="ml-auto w-24">Columns</TableHeaderCell>
|
||||
<TableHeaderCell className="w-24">Documents</TableHeaderCell>
|
||||
<TableHeaderCell className="w-32">Created</TableHeaderCell>
|
||||
<TableHeaderCell className="w-8" />
|
||||
</TableHeaderRow>
|
||||
<TableScrollArea
|
||||
header={
|
||||
<TableHeaderRow className="pr-8 md:pr-8">
|
||||
<TableStickyCell header>
|
||||
{loading ? (
|
||||
<SkeletonDot />
|
||||
) : (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allReviewsSelected}
|
||||
ref={(el) => {
|
||||
if (el) el.indeterminate = someReviewsSelected;
|
||||
}}
|
||||
onChange={() => {
|
||||
if (allReviewsSelected) setSelectedReviewIds([]);
|
||||
else
|
||||
setSelectedReviewIds(
|
||||
filteredReviews.map((r) => r.id),
|
||||
);
|
||||
}}
|
||||
className={TABLE_CHECKBOX_CLASS}
|
||||
/>
|
||||
)}
|
||||
<span>Name</span>
|
||||
</TableStickyCell>
|
||||
<TableHeaderCell className="ml-auto w-24">Columns</TableHeaderCell>
|
||||
<TableHeaderCell className="w-24">Documents</TableHeaderCell>
|
||||
<TableHeaderCell className="w-32">Created</TableHeaderCell>
|
||||
<TableHeaderCell className="w-8" />
|
||||
</TableHeaderRow>
|
||||
}
|
||||
>
|
||||
{loading ? (
|
||||
<ProjectReviewsLoadingRows />
|
||||
) : reviews.length === 0 ? (
|
||||
|
|
@ -122,28 +117,22 @@ export function ProjectReviewsTable({
|
|||
rightClickDropdown={(close) => (
|
||||
<RowActionMenuItems
|
||||
onClose={close}
|
||||
onRename={() => {
|
||||
onEditDetails={() => {
|
||||
if (
|
||||
currentUserId &&
|
||||
review.user_id !== currentUserId
|
||||
) {
|
||||
onOwnerOnlyAction(
|
||||
"rename this tabular review",
|
||||
"edit tabular review details",
|
||||
);
|
||||
return;
|
||||
}
|
||||
setRenameReviewValue(
|
||||
review.title ?? "Untitled Review",
|
||||
);
|
||||
setRenamingReviewId(review.id);
|
||||
onOpenDetails(review);
|
||||
}}
|
||||
onDelete={() => onDeleteReview(review)}
|
||||
/>
|
||||
)}
|
||||
onClick={() => {
|
||||
if (renamingReviewId === review.id) return;
|
||||
onOpenReview(review.id);
|
||||
}}
|
||||
onClick={() => onOpenReview(review.id)}
|
||||
className="pr-8 md:pr-8"
|
||||
>
|
||||
<TablePrimaryCell
|
||||
|
|
@ -163,13 +152,6 @@ export function ProjectReviewsTable({
|
|||
)
|
||||
}
|
||||
label={review.title ?? "Untitled Review"}
|
||||
editing={renamingReviewId === review.id}
|
||||
editValue={renameReviewValue}
|
||||
onEditValueChange={setRenameReviewValue}
|
||||
onEditCommit={() =>
|
||||
void submitReviewRename(review.id)
|
||||
}
|
||||
onEditCancel={() => setRenamingReviewId(null)}
|
||||
/>
|
||||
<TableCell className="ml-auto w-24">
|
||||
{review.columns_config?.length ?? 0}
|
||||
|
|
@ -189,20 +171,17 @@ export function ProjectReviewsTable({
|
|||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<RowActions
|
||||
onRename={() => {
|
||||
onEditDetails={() => {
|
||||
if (
|
||||
currentUserId &&
|
||||
review.user_id !== currentUserId
|
||||
) {
|
||||
onOwnerOnlyAction(
|
||||
"rename this tabular review",
|
||||
"edit tabular review details",
|
||||
);
|
||||
return;
|
||||
}
|
||||
setRenameReviewValue(
|
||||
review.title ?? "Untitled Review",
|
||||
);
|
||||
setRenamingReviewId(review.id);
|
||||
onOpenDetails(review);
|
||||
}}
|
||||
onDelete={() => onDeleteReview(review)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -29,10 +29,10 @@ import type {
|
|||
TabularReview,
|
||||
} from "@/app/components/shared/types";
|
||||
import { TableToolbar } from "@/app/components/shared/TableToolbar";
|
||||
import { AddNewTRModal } from "@/app/components/tabular/AddNewTRModal";
|
||||
import { ConfirmPopup } from "@/app/components/shared/ConfirmPopup";
|
||||
import { OwnerOnlyModal } from "@/app/components/shared/OwnerOnlyModal";
|
||||
import { PeopleModal } from "@/app/components/shared/PeopleModal";
|
||||
import { NewTRModal } from "@/app/components/tabular/NewTRModal";
|
||||
import { ConfirmPopup } from "@/app/components/popups/ConfirmPopup";
|
||||
import { OwnerOnlyPopup } from "@/app/components/popups/OwnerOnlyPopup";
|
||||
import { PeopleModal } from "@/app/components/modals/PeopleModal";
|
||||
import { useChatHistoryContext } from "@/app/contexts/ChatHistoryContext";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useUserProfile } from "@/contexts/UserProfileContext";
|
||||
|
|
@ -305,6 +305,7 @@ export function ProjectWorkspaceProvider({
|
|||
async function handleProjectDetailsSave(values: {
|
||||
name: string;
|
||||
cmNumber: string;
|
||||
practice: string;
|
||||
}) {
|
||||
if (project && project.is_owner === false) {
|
||||
setOwnerOnlyAction("edit project details");
|
||||
|
|
@ -312,10 +313,12 @@ export function ProjectWorkspaceProvider({
|
|||
}
|
||||
const name = values.name.trim();
|
||||
const cmNumber = values.cmNumber.trim();
|
||||
const practice = values.practice.trim();
|
||||
if (!name) return;
|
||||
const updated = await updateProject(projectId, {
|
||||
name,
|
||||
cm_number: cmNumber,
|
||||
practice: practice || null,
|
||||
});
|
||||
setProject((prev) =>
|
||||
prev
|
||||
|
|
@ -323,6 +326,7 @@ export function ProjectWorkspaceProvider({
|
|||
...prev,
|
||||
name: updated.name,
|
||||
cm_number: updated.cm_number,
|
||||
practice: updated.practice,
|
||||
}
|
||||
: updated,
|
||||
);
|
||||
|
|
@ -417,7 +421,6 @@ export function ProjectWorkspaceProvider({
|
|||
docsCount={project?.documents?.length ?? 0}
|
||||
isOwner={project?.is_owner !== false}
|
||||
onBackToProjects={() => router.push("/projects")}
|
||||
onOwnerOnly={setOwnerOnlyAction}
|
||||
onOpenDetails={() => setProjectDetailsOpen(true)}
|
||||
onDeleteProject={requestProjectDelete}
|
||||
onSearchChange={setSearch}
|
||||
|
|
@ -428,7 +431,7 @@ export function ProjectWorkspaceProvider({
|
|||
|
||||
{children}
|
||||
|
||||
<AddNewTRModal
|
||||
<NewTRModal
|
||||
open={newTRModalOpen}
|
||||
onClose={() => setNewTRModalOpen(false)}
|
||||
onAdd={handleCreateReview}
|
||||
|
|
@ -439,7 +442,7 @@ export function ProjectWorkspaceProvider({
|
|||
projectCmNumber={project?.cm_number}
|
||||
/>
|
||||
|
||||
<OwnerOnlyModal
|
||||
<OwnerOnlyPopup
|
||||
open={!!ownerOnlyAction}
|
||||
action={ownerOnlyAction ?? undefined}
|
||||
onClose={() => setOwnerOnlyAction(null)}
|
||||
|
|
@ -449,9 +452,6 @@ export function ProjectWorkspaceProvider({
|
|||
open={projectDetailsOpen}
|
||||
project={project}
|
||||
canEdit={project?.is_owner !== false}
|
||||
currentUserDisplayName={profile?.displayName ?? null}
|
||||
currentUserEmail={user?.email ?? null}
|
||||
fetchPeople={getProjectPeople}
|
||||
onClose={() => setProjectDetailsOpen(false)}
|
||||
onSave={handleProjectDetailsSave}
|
||||
onShareProject={() => {
|
||||
|
|
|
|||
|
|
@ -3,11 +3,16 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { FolderOpen, ChevronDown } from "lucide-react";
|
||||
import { listProjects, updateProject, deleteProject } from "@/app/lib/mikeApi";
|
||||
import { OwnerOnlyModal } from "@/app/components/shared/OwnerOnlyModal";
|
||||
import {
|
||||
listProjects,
|
||||
updateProject,
|
||||
deleteProject,
|
||||
} from "@/app/lib/mikeApi";
|
||||
import { OwnerOnlyPopup } from "@/app/components/popups/OwnerOnlyPopup";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import type { Project } from "@/app/components/shared/types";
|
||||
import { NewProjectModal } from "./NewProjectModal";
|
||||
import { ProjectDetailsModal } from "./ProjectDetailsModal";
|
||||
import { TableToolbar } from "@/app/components/shared/TableToolbar";
|
||||
import {
|
||||
RowActionMenuItems,
|
||||
|
|
@ -54,11 +59,8 @@ export function ProjectsOverview() {
|
|||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [detailsProject, setDetailsProject] = useState<Project | null>(null);
|
||||
const [activeFilter, setActiveFilter] = useState<ProjectFilter>("all");
|
||||
const [renamingId, setRenamingId] = useState<string | null>(null);
|
||||
const [renameValue, setRenameValue] = useState("");
|
||||
const [cmEditingId, setCmEditingId] = useState<string | null>(null);
|
||||
const [cmValue, setCmValue] = useState("");
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
const [actionsOpen, setActionsOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
|
|
@ -68,44 +70,45 @@ export function ProjectsOverview() {
|
|||
const { user, isAuthenticated, authLoading } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
if (authLoading) {
|
||||
setLoading(true);
|
||||
return;
|
||||
}
|
||||
if (!isAuthenticated) {
|
||||
setProjects([]);
|
||||
setLoadError(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setLoadError(null);
|
||||
listProjects()
|
||||
.then((loaded) => {
|
||||
|
||||
async function loadProjects() {
|
||||
await Promise.resolve();
|
||||
if (cancelled) return;
|
||||
if (authLoading) {
|
||||
setLoading(true);
|
||||
return;
|
||||
}
|
||||
if (!isAuthenticated) {
|
||||
setProjects([]);
|
||||
setLoadError(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setLoadError(null);
|
||||
try {
|
||||
const loaded = await listProjects();
|
||||
if (!cancelled) setProjects(loaded);
|
||||
})
|
||||
.catch((err) => {
|
||||
} catch (err) {
|
||||
console.error("[projects] failed to load projects", err);
|
||||
if (!cancelled) {
|
||||
setProjects([]);
|
||||
setLoadError("Could not load projects.");
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void loadProjects();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [authLoading, isAuthenticated, user?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIds([]);
|
||||
}, [activeFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (
|
||||
|
|
@ -129,7 +132,8 @@ export function ProjectsOverview() {
|
|||
(p) =>
|
||||
!q ||
|
||||
p.name.toLowerCase().includes(q) ||
|
||||
(p.cm_number ?? "").toLowerCase().includes(q),
|
||||
(p.cm_number ?? "").toLowerCase().includes(q) ||
|
||||
(p.practice ?? "").toLowerCase().includes(q),
|
||||
);
|
||||
|
||||
const allSelected =
|
||||
|
|
@ -158,25 +162,36 @@ export function ProjectsOverview() {
|
|||
{ id: "shared-with-me", label: "Shared with me" },
|
||||
];
|
||||
|
||||
async function handleRenameSubmit(projectId: string) {
|
||||
const trimmed = renameValue.trim();
|
||||
setRenamingId(null);
|
||||
if (!trimmed) return;
|
||||
async function handleProjectDetailsSave(values: {
|
||||
name: string;
|
||||
cmNumber: string;
|
||||
practice: string;
|
||||
}) {
|
||||
if (!detailsProject) return;
|
||||
if (
|
||||
detailsProject.is_owner === false ||
|
||||
(user?.id && detailsProject.user_id !== user.id)
|
||||
) {
|
||||
setOwnerOnlyAction("edit project details");
|
||||
return;
|
||||
}
|
||||
const name = values.name.trim();
|
||||
const cmNumber = values.cmNumber.trim();
|
||||
const practice = values.practice.trim();
|
||||
if (!name) return;
|
||||
const updated = await updateProject(detailsProject.id, {
|
||||
name,
|
||||
cm_number: cmNumber,
|
||||
practice: practice || null,
|
||||
});
|
||||
setProjects((prev) =>
|
||||
prev.map((p) => (p.id === projectId ? { ...p, name: trimmed } : p)),
|
||||
);
|
||||
await updateProject(projectId, { name: trimmed });
|
||||
}
|
||||
|
||||
async function handleCmSubmit(projectId: string) {
|
||||
const trimmed = cmValue.trim();
|
||||
setCmEditingId(null);
|
||||
setProjects((prev) =>
|
||||
prev.map((p) =>
|
||||
p.id === projectId ? { ...p, cm_number: trimmed || null } : p,
|
||||
prev.map((project) =>
|
||||
project.id === updated.id ? { ...project, ...updated } : project,
|
||||
),
|
||||
);
|
||||
await updateProject(projectId, { cm_number: trimmed || undefined });
|
||||
setDetailsProject((current) =>
|
||||
current?.id === updated.id ? { ...current, ...updated } : current,
|
||||
);
|
||||
}
|
||||
|
||||
async function handleDeleteSelected() {
|
||||
|
|
@ -253,40 +268,46 @@ export function ProjectsOverview() {
|
|||
<TableToolbar
|
||||
items={filters}
|
||||
active={activeFilter}
|
||||
onChange={setActiveFilter}
|
||||
onChange={(nextFilter) => {
|
||||
setActiveFilter(nextFilter);
|
||||
setSelectedIds([]);
|
||||
}}
|
||||
actions={toolbarActions}
|
||||
/>
|
||||
|
||||
{/* Table */}
|
||||
<TableScrollArea>
|
||||
{/* Column headers */}
|
||||
<TableHeaderRow>
|
||||
<TableStickyCell header>
|
||||
{loading ? (
|
||||
<SkeletonDot />
|
||||
) : (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allSelected}
|
||||
ref={(el) => {
|
||||
if (el) el.indeterminate = someSelected;
|
||||
}}
|
||||
onChange={toggleAll}
|
||||
className={TABLE_CHECKBOX_CLASS}
|
||||
/>
|
||||
)}
|
||||
<span>Name</span>
|
||||
</TableStickyCell>
|
||||
<TableHeaderCell className="ml-auto w-32">CM</TableHeaderCell>
|
||||
<TableHeaderCell className="w-32">Owner</TableHeaderCell>
|
||||
<TableHeaderCell className="w-24">Files</TableHeaderCell>
|
||||
<TableHeaderCell className="w-24">Chats</TableHeaderCell>
|
||||
<TableHeaderCell className="w-36">
|
||||
Tabular Reviews
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell className="w-32">Created</TableHeaderCell>
|
||||
<TableHeaderCell className="w-8" />
|
||||
</TableHeaderRow>
|
||||
<TableScrollArea
|
||||
header={
|
||||
<TableHeaderRow>
|
||||
<TableStickyCell header>
|
||||
{loading ? (
|
||||
<SkeletonDot />
|
||||
) : (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allSelected}
|
||||
ref={(el) => {
|
||||
if (el) el.indeterminate = someSelected;
|
||||
}}
|
||||
onChange={toggleAll}
|
||||
className={TABLE_CHECKBOX_CLASS}
|
||||
/>
|
||||
)}
|
||||
<span>Name</span>
|
||||
</TableStickyCell>
|
||||
<TableHeaderCell className="ml-auto w-32">CM</TableHeaderCell>
|
||||
<TableHeaderCell className="w-36">Practice</TableHeaderCell>
|
||||
<TableHeaderCell className="w-32">Owner</TableHeaderCell>
|
||||
<TableHeaderCell className="w-24">Files</TableHeaderCell>
|
||||
<TableHeaderCell className="w-24">Chats</TableHeaderCell>
|
||||
<TableHeaderCell className="w-36">
|
||||
Tabular Reviews
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell className="w-32">Created</TableHeaderCell>
|
||||
<TableHeaderCell className="w-8" />
|
||||
</TableHeaderRow>
|
||||
}
|
||||
>
|
||||
|
||||
{loading ? (
|
||||
<TableBody>
|
||||
|
|
@ -305,6 +326,9 @@ export function ProjectsOverview() {
|
|||
<TableCell className="ml-auto w-32">
|
||||
<SkeletonLine className="w-20" />
|
||||
</TableCell>
|
||||
<TableCell className="w-36">
|
||||
<SkeletonLine className="w-20" />
|
||||
</TableCell>
|
||||
<TableCell className="w-32">
|
||||
<SkeletonLine className="w-16" />
|
||||
</TableCell>
|
||||
|
|
@ -375,20 +399,8 @@ export function ProjectsOverview() {
|
|||
? (close) => (
|
||||
<RowActionMenuItems
|
||||
onClose={close}
|
||||
onRename={() => {
|
||||
setRenameValue(
|
||||
project.name,
|
||||
);
|
||||
setRenamingId(project.id);
|
||||
}}
|
||||
onUpdateCmNumber={() => {
|
||||
setCmValue(
|
||||
project.cm_number ??
|
||||
"",
|
||||
);
|
||||
setCmEditingId(
|
||||
project.id,
|
||||
);
|
||||
onEditDetails={() => {
|
||||
setDetailsProject(project);
|
||||
}}
|
||||
onDelete={async () => {
|
||||
await deleteProject(
|
||||
|
|
@ -407,7 +419,6 @@ export function ProjectsOverview() {
|
|||
: undefined
|
||||
}
|
||||
onClick={() => {
|
||||
if (renamingId === project.id) return;
|
||||
router.push(`/projects/${project.id}`);
|
||||
}}
|
||||
>
|
||||
|
|
@ -419,44 +430,20 @@ export function ProjectsOverview() {
|
|||
toggleOne(project.id)
|
||||
}
|
||||
label={project.name}
|
||||
editing={renamingId === project.id}
|
||||
editValue={renameValue}
|
||||
onEditValueChange={setRenameValue}
|
||||
onEditCommit={() =>
|
||||
handleRenameSubmit(project.id)
|
||||
}
|
||||
onEditCancel={() => setRenamingId(null)}
|
||||
/>
|
||||
|
||||
<TableCell
|
||||
className="ml-auto w-32"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{cmEditingId === project.id ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={cmValue}
|
||||
onChange={(e) =>
|
||||
setCmValue(e.target.value)
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter")
|
||||
handleCmSubmit(project.id);
|
||||
if (e.key === "Escape")
|
||||
setCmEditingId(null);
|
||||
}}
|
||||
onBlur={() =>
|
||||
handleCmSubmit(project.id)
|
||||
}
|
||||
placeholder="CM #"
|
||||
className="w-full text-sm text-gray-800 bg-transparent outline-none"
|
||||
/>
|
||||
) : (
|
||||
(project.cm_number ?? (
|
||||
<span className="text-gray-300">
|
||||
—
|
||||
</span>
|
||||
))
|
||||
<TableCell className="ml-auto w-32">
|
||||
{project.cm_number ?? (
|
||||
<span className="text-gray-300">
|
||||
—
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="w-36">
|
||||
{project.practice ?? (
|
||||
<span className="text-gray-300">
|
||||
—
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="w-32">
|
||||
|
|
@ -482,15 +469,8 @@ export function ProjectsOverview() {
|
|||
{(project.is_owner ??
|
||||
project.user_id === user?.id) && (
|
||||
<RowActions
|
||||
onRename={() => {
|
||||
setRenameValue(project.name);
|
||||
setRenamingId(project.id);
|
||||
}}
|
||||
onUpdateCmNumber={() => {
|
||||
setCmValue(
|
||||
project.cm_number ?? "",
|
||||
);
|
||||
setCmEditingId(project.id);
|
||||
onEditDetails={() => {
|
||||
setDetailsProject(project);
|
||||
}}
|
||||
onDelete={async () => {
|
||||
await deleteProject(project.id);
|
||||
|
|
@ -520,7 +500,19 @@ export function ProjectsOverview() {
|
|||
}}
|
||||
/>
|
||||
|
||||
<OwnerOnlyModal
|
||||
<ProjectDetailsModal
|
||||
open={!!detailsProject}
|
||||
project={detailsProject}
|
||||
canEdit={
|
||||
!!detailsProject &&
|
||||
detailsProject.is_owner !== false &&
|
||||
(!user?.id || detailsProject.user_id === user.id)
|
||||
}
|
||||
onClose={() => setDetailsProject(null)}
|
||||
onSave={handleProjectDetailsSave}
|
||||
/>
|
||||
|
||||
<OwnerOnlyPopup
|
||||
open={!!ownerOnlyAction}
|
||||
action={ownerOnlyAction ?? undefined}
|
||||
onClose={() => setOwnerOnlyAction(null)}
|
||||
|
|
|
|||
|
|
@ -1,316 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { AlertCircle, Upload, Search, Loader2, X } from "lucide-react";
|
||||
import {
|
||||
uploadStandaloneDocument,
|
||||
uploadProjectDocument,
|
||||
addDocumentToProject,
|
||||
deleteDocument,
|
||||
} from "@/app/lib/mikeApi";
|
||||
import type { Document } from "./types";
|
||||
import { FileDirectory } from "./FileDirectory";
|
||||
import { useDirectoryData, invalidateDirectoryCache } from "./useDirectoryData";
|
||||
import { OwnerOnlyModal } from "./OwnerOnlyModal";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { Modal } from "./Modal";
|
||||
import {
|
||||
SUPPORTED_DOCUMENT_ACCEPT,
|
||||
formatUnsupportedDocumentWarning,
|
||||
partitionSupportedDocumentFiles,
|
||||
} from "@/app/lib/documentUploadValidation";
|
||||
|
||||
export { invalidateDirectoryCache };
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSelect: (documents: Document[], projectId?: string) => void;
|
||||
breadcrumb: string[];
|
||||
allowMultiple?: boolean;
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
export function AddDocumentsModal({
|
||||
open,
|
||||
onClose,
|
||||
onSelect,
|
||||
breadcrumb,
|
||||
allowMultiple = true,
|
||||
projectId,
|
||||
}: Props) {
|
||||
const { loading, standaloneDocuments, projects } = useDirectoryData(open);
|
||||
const { user } = useAuth();
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadingFilenames, setUploadingFilenames] = useState<string[]>([]);
|
||||
const [uploadWarning, setUploadWarning] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const [extraUploadedDocs, setExtraUploadedDocs] = useState<Document[]>([]);
|
||||
// IDs deleted in this session — hidden locally since `useDirectoryData`'s
|
||||
// cached state won't re-fetch until the modal reopens.
|
||||
const [deletedIds, setDeletedIds] = useState<Set<string>>(new Set());
|
||||
const [ownerOnlyAction, setOwnerOnlyAction] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setSearch("");
|
||||
setSelectedIds(new Set());
|
||||
setExtraUploadedDocs([]);
|
||||
setDeletedIds(new Set());
|
||||
setUploadingFilenames([]);
|
||||
setUploadWarning(null);
|
||||
}, [open]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const q = search.toLowerCase().trim();
|
||||
|
||||
const allStandalone = [
|
||||
...extraUploadedDocs.filter(
|
||||
(u) => !standaloneDocuments.some((d) => d.id === u.id),
|
||||
),
|
||||
...standaloneDocuments,
|
||||
].filter((d) => !deletedIds.has(d.id));
|
||||
|
||||
const filteredStandalone = q
|
||||
? allStandalone.filter((d) =>
|
||||
d.filename.toLowerCase().includes(q),
|
||||
)
|
||||
: allStandalone;
|
||||
|
||||
const filteredProjects = projects
|
||||
.filter((p) => p.id !== projectId)
|
||||
.map((p) => ({
|
||||
...p,
|
||||
documents: (p.documents || []).filter(
|
||||
(d) =>
|
||||
!deletedIds.has(d.id) &&
|
||||
(!q ||
|
||||
d.filename.toLowerCase().includes(q)),
|
||||
),
|
||||
}))
|
||||
.filter(
|
||||
(p) =>
|
||||
!q ||
|
||||
p.name.toLowerCase().includes(q) ||
|
||||
p.documents.length > 0,
|
||||
);
|
||||
|
||||
const allDocs = [
|
||||
...allStandalone,
|
||||
...projects.flatMap((p) => p.documents || []),
|
||||
];
|
||||
|
||||
async function handleConfirm() {
|
||||
const selected = allDocs.filter((d) => selectedIds.has(d.id));
|
||||
|
||||
if (projectId) {
|
||||
const toAssign = selected.filter((d) => d.project_id !== projectId);
|
||||
const alreadyHere = selected.filter(
|
||||
(d) => d.project_id === projectId,
|
||||
);
|
||||
if (toAssign.length > 0) {
|
||||
setUploading(true);
|
||||
try {
|
||||
const assigned = await Promise.all(
|
||||
toAssign.map((d) =>
|
||||
addDocumentToProject(projectId, d.id),
|
||||
),
|
||||
);
|
||||
onSelect([...alreadyHere, ...assigned], projectId);
|
||||
} catch (err) {
|
||||
console.error("Failed to assign documents:", err);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
} else {
|
||||
onSelect(alreadyHere, projectId);
|
||||
}
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
const projectIds = new Set(
|
||||
selected.map((d) => d.project_id).filter(Boolean),
|
||||
);
|
||||
const singleProjectId =
|
||||
projectIds.size === 1 ? [...projectIds][0]! : undefined;
|
||||
onSelect(selected, singleProjectId);
|
||||
onClose();
|
||||
}
|
||||
|
||||
async function handleDelete(ids: string[]) {
|
||||
// Server only allows the doc creator to delete. Filter to owned
|
||||
// and warn for the rest.
|
||||
const docsById = new Map<string, Document>();
|
||||
for (const d of [
|
||||
...standaloneDocuments,
|
||||
...extraUploadedDocs,
|
||||
...projects.flatMap((p) => p.documents ?? []),
|
||||
]) {
|
||||
docsById.set(d.id, d);
|
||||
}
|
||||
const owned = ids.filter((id) => {
|
||||
const d = docsById.get(id);
|
||||
return !d || !d.user_id || !user?.id || d.user_id === user.id;
|
||||
});
|
||||
const blocked = ids.length - owned.length;
|
||||
if (owned.length === 0 && blocked > 0) {
|
||||
setOwnerOnlyAction(
|
||||
"delete these documents — only the document creator can delete a document",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const idSet = new Set(owned);
|
||||
try {
|
||||
await Promise.all(owned.map((id) => deleteDocument(id)));
|
||||
} catch (err) {
|
||||
console.error("Delete failed:", err);
|
||||
return;
|
||||
}
|
||||
invalidateDirectoryCache();
|
||||
setExtraUploadedDocs((prev) => prev.filter((d) => !idSet.has(d.id)));
|
||||
setDeletedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
owned.forEach((id) => next.add(id));
|
||||
return next;
|
||||
});
|
||||
if (blocked > 0) {
|
||||
setOwnerOnlyAction(
|
||||
`delete ${blocked} of the selected documents — only the document creator can delete a document`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const files = Array.from(e.target.files || []);
|
||||
if (!files.length) return;
|
||||
const { supported, unsupported } = partitionSupportedDocumentFiles(files);
|
||||
setUploadWarning(formatUnsupportedDocumentWarning(unsupported));
|
||||
if (supported.length === 0) {
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
return;
|
||||
}
|
||||
setUploadingFilenames(supported.map((file) => file.name));
|
||||
setUploading(true);
|
||||
try {
|
||||
const uploaded = await Promise.all(
|
||||
supported.map((f) =>
|
||||
projectId
|
||||
? uploadProjectDocument(projectId, f)
|
||||
: uploadStandaloneDocument(f),
|
||||
),
|
||||
);
|
||||
invalidateDirectoryCache();
|
||||
setExtraUploadedDocs((prev) => [...uploaded, ...prev]);
|
||||
uploaded.forEach((d) =>
|
||||
setSelectedIds((prev) => new Set([...prev, d.id])),
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("Upload failed:", err);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
setUploadingFilenames([]);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
breadcrumbs={breadcrumb}
|
||||
secondaryAction={{
|
||||
label: uploading ? "Uploading…" : "Upload",
|
||||
icon: uploading ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
),
|
||||
onClick: () => fileInputRef.current?.click(),
|
||||
disabled: uploading,
|
||||
}}
|
||||
footerStatus={
|
||||
selectedIds.size > 0 ? (
|
||||
<span className="text-xs text-gray-400">
|
||||
{selectedIds.size} selected
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
primaryAction={{
|
||||
label: uploading ? "Saving…" : "Confirm",
|
||||
onClick: handleConfirm,
|
||||
disabled: selectedIds.size === 0 || uploading,
|
||||
}}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={SUPPORTED_DOCUMENT_ACCEPT}
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleUpload}
|
||||
/>
|
||||
{/* Search bar */}
|
||||
<div className="pt-1 pb-2">
|
||||
<div className="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-3 py-2">
|
||||
<Search className="h-3.5 w-3.5 text-gray-400 shrink-0" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="flex-1 bg-transparent text-sm text-gray-700 placeholder:text-gray-400 outline-none"
|
||||
autoFocus
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
onClick={() => setSearch("")}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{uploadWarning && (
|
||||
<div className="mb-2 flex items-center gap-2 rounded-lg border border-red-100 bg-red-50 px-3 py-2 text-xs text-gray-900">
|
||||
<AlertCircle className="h-3.5 w-3.5 shrink-0 text-red-600" />
|
||||
<span className="min-w-0 flex-1">{uploadWarning}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setUploadWarning(null)}
|
||||
className="shrink-0 rounded p-0.5 text-black hover:bg-gray-100"
|
||||
aria-label="Dismiss warning"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File browser */}
|
||||
<FileDirectory
|
||||
standaloneDocs={filteredStandalone}
|
||||
directoryProjects={filteredProjects}
|
||||
loading={loading}
|
||||
selectedIds={selectedIds}
|
||||
onChange={setSelectedIds}
|
||||
allowMultiple={allowMultiple}
|
||||
forceExpanded={!!q}
|
||||
emptyMessage={q ? "No matches found" : "No documents yet"}
|
||||
onDelete={handleDelete}
|
||||
uploadingFilenames={uploadingFilenames}
|
||||
/>
|
||||
</Modal>
|
||||
<OwnerOnlyModal
|
||||
open={!!ownerOnlyAction}
|
||||
action={ownerOnlyAction ?? undefined}
|
||||
onClose={() => setOwnerOnlyAction(null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,261 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Check, Loader2, Search, Upload, X } from "lucide-react";
|
||||
import { getProject, uploadProjectDocument } from "@/app/lib/mikeApi";
|
||||
import type { Document } from "./types";
|
||||
import { DocFileIcon } from "./FileDirectory";
|
||||
import { VersionChip } from "./VersionChip";
|
||||
import { Modal } from "./Modal";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSelect: (documents: Document[]) => void;
|
||||
breadcrumb: string[];
|
||||
projectId: string;
|
||||
/** Docs already in the target list — rendered checked + disabled. */
|
||||
excludeDocIds?: Set<string>;
|
||||
allowMultiple?: boolean;
|
||||
}
|
||||
|
||||
function formatDate(iso: string | null) {
|
||||
if (!iso) return null;
|
||||
return new Date(iso).toLocaleDateString(undefined, {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
export function AddProjectDocsModal({
|
||||
open,
|
||||
onClose,
|
||||
onSelect,
|
||||
breadcrumb,
|
||||
projectId,
|
||||
excludeDocIds,
|
||||
allowMultiple = true,
|
||||
}: Props) {
|
||||
const [docs, setDocs] = useState<Document[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setSearch("");
|
||||
setSelectedIds(new Set());
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
getProject(projectId)
|
||||
.then((p) => {
|
||||
if (!cancelled) setDocs(p.documents ?? []);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setDocs([]);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, projectId]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const q = search.toLowerCase().trim();
|
||||
const filtered = q
|
||||
? docs.filter((d) => d.filename.toLowerCase().includes(q))
|
||||
: docs;
|
||||
|
||||
const isExcluded = (id: string) => !!excludeDocIds?.has(id);
|
||||
|
||||
function toggle(id: string) {
|
||||
if (isExcluded(id)) return;
|
||||
if (!allowMultiple) {
|
||||
setSelectedIds(new Set([id]));
|
||||
return;
|
||||
}
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function handleConfirm() {
|
||||
const selected = docs.filter((d) => selectedIds.has(d.id));
|
||||
onSelect(selected);
|
||||
onClose();
|
||||
}
|
||||
|
||||
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const files = Array.from(e.target.files || []);
|
||||
if (!files.length) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
const uploaded = await Promise.all(
|
||||
files.map((f) => uploadProjectDocument(projectId, f)),
|
||||
);
|
||||
setDocs((prev) => [...uploaded, ...prev]);
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
uploaded.forEach((d) => next.add(d.id));
|
||||
return next;
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Upload failed:", err);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
breadcrumbs={breadcrumb}
|
||||
secondaryAction={{
|
||||
label: uploading ? "Uploading…" : "Upload",
|
||||
icon: uploading ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
),
|
||||
onClick: () => fileInputRef.current?.click(),
|
||||
disabled: uploading,
|
||||
}}
|
||||
footerStatus={
|
||||
selectedIds.size > 0 ? (
|
||||
<span className="text-xs text-gray-400">
|
||||
{selectedIds.size} selected
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
primaryAction={{
|
||||
label: "Confirm",
|
||||
onClick: handleConfirm,
|
||||
disabled: selectedIds.size === 0 || uploading,
|
||||
}}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".pdf,.docx,.doc"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleUpload}
|
||||
/>
|
||||
{/* Search */}
|
||||
<div className="pt-1 pb-2">
|
||||
<div className="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-3 py-2">
|
||||
<Search className="h-3.5 w-3.5 text-gray-400 shrink-0" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="flex-1 bg-transparent text-sm text-gray-700 placeholder:text-gray-400 outline-none"
|
||||
autoFocus
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
onClick={() => setSearch("")}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* File list */}
|
||||
{loading ? (
|
||||
<div className="rounded-sm border border-gray-100 overflow-hidden">
|
||||
{[60, 45, 75, 55, 40].map((w, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-2 px-2 py-2"
|
||||
>
|
||||
<div className="h-3.5 w-3.5 rounded border border-gray-200 shrink-0" />
|
||||
<div className="h-3.5 w-3.5 rounded bg-gray-200 animate-pulse shrink-0" />
|
||||
<div
|
||||
className="h-3 rounded bg-gray-200 animate-pulse"
|
||||
style={{ width: `${w}%` }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<p className="text-center text-sm text-gray-400 py-8">
|
||||
{q ? "No matches found" : "No documents in this project"}
|
||||
</p>
|
||||
) : (
|
||||
<div className="rounded-sm border border-gray-100 overflow-hidden">
|
||||
{filtered.map((doc) => {
|
||||
const excluded = isExcluded(doc.id);
|
||||
const checked = excluded || selectedIds.has(doc.id);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={doc.id}
|
||||
disabled={excluded}
|
||||
onClick={() => toggle(doc.id)}
|
||||
className={`w-full flex items-center gap-2 px-2 py-2 text-xs text-left transition-colors ${
|
||||
excluded
|
||||
? "opacity-50 cursor-not-allowed"
|
||||
: checked
|
||||
? "bg-gray-100"
|
||||
: "hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`shrink-0 h-3.5 w-3.5 rounded border flex items-center justify-center ${
|
||||
checked
|
||||
? "bg-gray-900 border-gray-900"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
>
|
||||
{checked && (
|
||||
<Check className="h-2.5 w-2.5 text-white" />
|
||||
)}
|
||||
</span>
|
||||
<DocFileIcon fileType={doc.file_type} />
|
||||
<span
|
||||
className={`flex-1 truncate ${
|
||||
checked
|
||||
? "text-gray-900"
|
||||
: "text-gray-700"
|
||||
}`}
|
||||
>
|
||||
{doc.filename}
|
||||
</span>
|
||||
{excluded && (
|
||||
<span className="text-[10px] text-gray-400 shrink-0">
|
||||
Already added
|
||||
</span>
|
||||
)}
|
||||
<VersionChip
|
||||
n={
|
||||
doc.active_version_number ??
|
||||
doc.latest_version_number
|
||||
}
|
||||
/>
|
||||
{doc.created_at && (
|
||||
<span className="shrink-0 text-gray-300">
|
||||
{formatDate(doc.created_at)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { providerLabel, type ModelProvider } from "@/app/lib/modelAvailability";
|
||||
import { WarningPopup } from "./WarningPopup";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
provider: ModelProvider | null;
|
||||
/** Optional override for the body sentence. */
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export function ApiKeyMissingModal({ open, onClose, provider, message }: Props) {
|
||||
const router = useRouter();
|
||||
if (!open) return null;
|
||||
|
||||
const providerName = provider ? providerLabel(provider) : "this provider";
|
||||
const body =
|
||||
message ??
|
||||
`You haven't added a ${providerName} API key yet. Add one in your account settings to use this model.`;
|
||||
|
||||
const handleGoToAccount = () => {
|
||||
onClose();
|
||||
router.push("/account/models");
|
||||
};
|
||||
|
||||
return (
|
||||
<WarningPopup
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title="API key required"
|
||||
message={body}
|
||||
icon={
|
||||
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-red-600" />
|
||||
}
|
||||
primaryAction={{
|
||||
label: "Go to account settings",
|
||||
onClick: handleGoToAccount,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -126,7 +126,7 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) {
|
|||
isOpen
|
||||
? "w-64 h-[calc(100dvh-1rem)] md:h-[calc(100dvh-1.5rem)] bg-white/65"
|
||||
: "max-md:hidden w-14 md:h-[calc(100dvh-1.5rem)] md:bg-white/65 h-auto bg-transparent pointer-events-none md:pointer-events-auto",
|
||||
"my-2 ml-2 mr-0 md:my-3 md:ml-3 md:mr-0 rounded-2xl border border-white/70 shadow-[0_-2px_7px_rgba(15,23,42,0.044),0_5px_12px_rgba(15,23,42,0.095),inset_0_1px_0_rgba(255,255,255,0.85)] backdrop-blur-2xl overflow-visible",
|
||||
"my-2 ml-2 mr-0 md:my-3 md:ml-3 md:mr-0 rounded-2xl border border-white/70 shadow-[0_-1px_6px_rgba(15,23,42,0.034),0_4px_9px_rgba(15,23,42,0.074),inset_0_1px_0_rgba(255,255,255,0.85)] backdrop-blur-2xl overflow-visible",
|
||||
"flex flex-col transition-all duration-300 absolute md:relative z-[99]",
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,117 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { createPortal } from "react-dom";
|
||||
import type { ReactNode } from "react";
|
||||
import { Loader2, Trash2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ConfirmStatus = "idle" | "loading" | "complete";
|
||||
|
||||
interface ConfirmPopupProps {
|
||||
open: boolean;
|
||||
title?: ReactNode;
|
||||
message?: ReactNode;
|
||||
confirmLabel?: ReactNode;
|
||||
confirmStatus?: ConfirmStatus;
|
||||
cancelLabel?: ReactNode;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
confirmDisabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ConfirmPopup({
|
||||
open,
|
||||
title,
|
||||
message,
|
||||
confirmLabel = "Confirm",
|
||||
confirmStatus = "idle",
|
||||
cancelLabel = "Cancel",
|
||||
onConfirm,
|
||||
onCancel,
|
||||
confirmDisabled = false,
|
||||
className,
|
||||
}: ConfirmPopupProps) {
|
||||
if (!open) return null;
|
||||
const confirmBusy = confirmStatus === "loading";
|
||||
const resolvedConfirmDisabled = confirmDisabled || confirmStatus !== "idle";
|
||||
const normalizedConfirmLabel =
|
||||
typeof confirmLabel === "string" ? confirmLabel : "Confirm";
|
||||
const isDeleteAction = normalizedConfirmLabel.toLowerCase() === "delete";
|
||||
const resolvedConfirmLabel =
|
||||
confirmStatus === "loading" ? (
|
||||
<span className="inline-flex h-full items-center gap-1.5">
|
||||
<Loader2 className="h-3 w-3 shrink-0 animate-spin" />
|
||||
{progressiveLabel(normalizedConfirmLabel)}
|
||||
</span>
|
||||
) : confirmStatus === "complete" ? (
|
||||
completedLabel(normalizedConfirmLabel)
|
||||
) : isDeleteAction ? (
|
||||
<span className="inline-flex h-full items-center gap-1.5">
|
||||
<Trash2 className="h-3 w-3 shrink-0" />
|
||||
{confirmLabel}
|
||||
</span>
|
||||
) : (
|
||||
confirmLabel
|
||||
);
|
||||
|
||||
return createPortal(
|
||||
<div className="pointer-events-none fixed inset-x-0 bottom-5 z-[230] flex justify-center px-4">
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-auto w-[min(92vw,520px)] rounded-2xl border border-white/70 bg-white px-4 py-3 text-sm shadow-[0_4px_14px_rgba(15,23,42,0.08),inset_0_1px_0_rgba(255,255,255,0.92)] backdrop-blur-2xl",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{title && (
|
||||
<div className="text-sm font-medium text-gray-950 mb-3">
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
{message && (
|
||||
<div
|
||||
className={cn("text-xs text-gray-700", title && "mt-1")}
|
||||
>
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-3 flex items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-3 py-1.5 text-xs font-medium text-gray-700 transition-colors hover:text-gray-950"
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
disabled={resolvedConfirmDisabled}
|
||||
className={cn(
|
||||
"inline-flex h-7 items-center justify-center rounded-full px-3.5 text-xs font-medium leading-none text-white backdrop-blur-xl transition-all active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100",
|
||||
isDeleteAction
|
||||
? "border border-red-700/35 bg-red-600/90 shadow-[0_3px_9px_rgba(127,29,29,0.16),inset_0_1px_0_rgba(255,255,255,0.22),inset_0_-4px_9px_rgba(127,29,29,0.18)] hover:bg-red-600"
|
||||
: "border border-gray-700/40 bg-gray-950/88 shadow-[0_3px_9px_rgba(15,23,42,0.16),inset_0_1px_0_rgba(255,255,255,0.22),inset_0_-4px_9px_rgba(15,23,42,0.2)] hover:bg-gray-900/90",
|
||||
)}
|
||||
aria-busy={confirmBusy}
|
||||
>
|
||||
{resolvedConfirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
function progressiveLabel(label: string) {
|
||||
const lower = label.toLowerCase();
|
||||
if (lower.endsWith("e")) return `${label.slice(0, -1)}ing...`;
|
||||
return `${label}ing...`;
|
||||
}
|
||||
|
||||
function completedLabel(label: string) {
|
||||
const lower = label.toLowerCase();
|
||||
if (lower.endsWith("e")) return `${label}d`;
|
||||
return `${label}ed`;
|
||||
}
|
||||
|
|
@ -1,559 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Download, Loader2 } from "lucide-react";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { applyOptimisticResolution } from "../assistant/EditCard";
|
||||
import { DocView } from "./DocView";
|
||||
import { DocxView } from "./DocxView";
|
||||
import {
|
||||
RelevantQuotes,
|
||||
type RelevantQuoteItem,
|
||||
} from "./RelevantQuotes";
|
||||
import {
|
||||
expandCitationToEntries,
|
||||
formatCitationPage,
|
||||
getDocumentCitationQuotes,
|
||||
} from "./types";
|
||||
import type {
|
||||
CitationQuote,
|
||||
CitationAnnotation,
|
||||
DocumentCitationAnnotation,
|
||||
EditAnnotation,
|
||||
} from "./types";
|
||||
|
||||
function isDocxFilename(name: string): boolean {
|
||||
const ext = name.split(".").pop()?.toLowerCase();
|
||||
return ext === "docx" || ext === "doc";
|
||||
}
|
||||
|
||||
/**
|
||||
* Discriminated-union describing what the panel is showing above the viewer.
|
||||
* - "document": title row + viewer.
|
||||
* - "citation": title row + relevant quote + viewer.
|
||||
* - "edit": title row + tracked change + viewer.
|
||||
*/
|
||||
export type DocPanelMode =
|
||||
| { kind: "document" }
|
||||
| { kind: "citation"; citation: CitationAnnotation }
|
||||
| {
|
||||
kind: "edit";
|
||||
edit: EditAnnotation;
|
||||
/**
|
||||
* True while an accept/reject request for this exact edit is in
|
||||
* flight. Scoped per-edit (not per-document) so sibling edits on
|
||||
* the same doc stay clickable.
|
||||
*/
|
||||
isEditReloading?: boolean;
|
||||
onResolveStart?: (args: {
|
||||
editId: string;
|
||||
documentId: string;
|
||||
verb: "accept" | "reject";
|
||||
}) => void;
|
||||
onResolved?: (args: {
|
||||
editId: string;
|
||||
documentId: string;
|
||||
status: "accepted" | "rejected";
|
||||
versionId: string | null;
|
||||
downloadUrl: string | null;
|
||||
}) => void;
|
||||
onError?: (args: {
|
||||
editId: string;
|
||||
documentId: string;
|
||||
versionId: string | null;
|
||||
message: string;
|
||||
}) => void;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
documentId: string;
|
||||
filename: string;
|
||||
versionId: string | null;
|
||||
versionNumber: number | null;
|
||||
mode: DocPanelMode;
|
||||
/** Spinner on the Download button while an accept/reject is in flight. */
|
||||
isReloading?: boolean;
|
||||
warning?: string | null;
|
||||
onWarningDismiss?: () => void;
|
||||
initialScrollTop?: number | null;
|
||||
onScrollChange?: (scrollTop: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified side-panel body for the assistant. Renders a single document
|
||||
* with optionally a citation quote OR a tracked change highlighted above
|
||||
* the viewer. No selector UI — caller picks the one thing to show; if the
|
||||
* user wants a different citation/edit, the panel gets a new tab.
|
||||
*/
|
||||
export function DocPanel({
|
||||
documentId,
|
||||
filename,
|
||||
versionId,
|
||||
versionNumber,
|
||||
mode,
|
||||
isReloading = false,
|
||||
warning,
|
||||
onWarningDismiss,
|
||||
initialScrollTop,
|
||||
onScrollChange,
|
||||
}: Props) {
|
||||
// Pick the viewer from the filename only, not from mode. Switching
|
||||
// headers (citation ↔ edit ↔ document) for the same document must
|
||||
// not unmount and remount the body — otherwise the user sees a full
|
||||
// re-fetch every time they toggle. Tracked-change rendering still
|
||||
// only lives in DocxView, which is fine because edits are DOCX-only.
|
||||
const useDocxView = isDocxFilename(filename);
|
||||
const citationQuoteId =
|
||||
mode.kind === "citation" ? `document:${mode.citation.ref}:0` : null;
|
||||
const [activeCitationQuoteId, setActiveCitationQuoteId] = useState<
|
||||
string | null
|
||||
>(citationQuoteId);
|
||||
const [quoteFocusKey, setQuoteFocusKey] = useState(0);
|
||||
|
||||
const quotes: CitationQuote[] | undefined = useMemo(() => {
|
||||
if (mode.kind !== "citation") return undefined;
|
||||
if (!activeCitationQuoteId) return [];
|
||||
const selectedIndex = Number(activeCitationQuoteId.split(":").at(-1));
|
||||
if (!Number.isFinite(selectedIndex)) return [];
|
||||
const selectedQuote =
|
||||
getDocumentCitationQuotes(mode.citation)[selectedIndex];
|
||||
if (!selectedQuote) return [];
|
||||
const documentCitation = mode.citation as DocumentCitationAnnotation;
|
||||
return expandCitationToEntries({
|
||||
...documentCitation,
|
||||
page: selectedQuote.page,
|
||||
quote: selectedQuote.quote,
|
||||
quotes: [selectedQuote],
|
||||
});
|
||||
}, [activeCitationQuoteId, citationQuoteId, mode]);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveCitationQuoteId(citationQuoteId);
|
||||
}, [citationQuoteId]);
|
||||
|
||||
const handleCitationQuoteSelect = useCallback(
|
||||
(quoteId: string) => {
|
||||
const shouldSelect = activeCitationQuoteId !== quoteId;
|
||||
setActiveCitationQuoteId(shouldSelect ? quoteId : null);
|
||||
if (shouldSelect) setQuoteFocusKey((current) => current + 1);
|
||||
},
|
||||
[activeCitationQuoteId],
|
||||
);
|
||||
|
||||
const highlightEdit = useMemo(() => {
|
||||
if (mode.kind !== "edit") return null;
|
||||
return {
|
||||
key: `${mode.edit.edit_id}`,
|
||||
inserted_text: mode.edit.inserted_text,
|
||||
deleted_text: mode.edit.deleted_text,
|
||||
ins_w_id: mode.edit.ins_w_id ?? null,
|
||||
del_w_id: mode.edit.del_w_id ?? null,
|
||||
};
|
||||
}, [mode]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<DocumentTitleRow
|
||||
documentId={documentId}
|
||||
filename={filename}
|
||||
versionId={versionId}
|
||||
versionNumber={versionNumber}
|
||||
isReloading={isReloading}
|
||||
/>
|
||||
|
||||
{mode.kind === "citation" && (
|
||||
<RelevantQuoteSection
|
||||
citation={mode.citation}
|
||||
filename={filename}
|
||||
activeQuoteId={activeCitationQuoteId}
|
||||
onQuoteSelect={handleCitationQuoteSelect}
|
||||
/>
|
||||
)}
|
||||
|
||||
{mode.kind === "edit" && <TrackedChangeHeader mode={mode} />}
|
||||
|
||||
<div className="flex flex-1 min-h-0 flex-col px-3 py-3">
|
||||
{useDocxView ? (
|
||||
<DocxView
|
||||
documentId={documentId}
|
||||
versionId={versionId ?? undefined}
|
||||
quotes={quotes}
|
||||
quoteFocusKey={quoteFocusKey}
|
||||
highlightEdit={highlightEdit}
|
||||
warning={warning ?? null}
|
||||
onWarningDismiss={onWarningDismiss}
|
||||
initialScrollTop={initialScrollTop ?? null}
|
||||
onScrollChange={onScrollChange}
|
||||
/>
|
||||
) : (
|
||||
<DocView
|
||||
doc={{
|
||||
document_id: documentId,
|
||||
version_id: versionId,
|
||||
}}
|
||||
quotes={quotes}
|
||||
quoteFocusKey={quoteFocusKey}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Header variants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function DocumentTitleRow({
|
||||
documentId,
|
||||
filename,
|
||||
versionId,
|
||||
versionNumber,
|
||||
isReloading,
|
||||
}: {
|
||||
documentId: string;
|
||||
filename: string;
|
||||
versionId: string | null;
|
||||
versionNumber: number | null;
|
||||
isReloading: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start gap-3 px-3 pt-4 pb-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<h2
|
||||
className="min-w-0 break-words font-serif text-xl text-gray-900"
|
||||
title={filename}
|
||||
>
|
||||
{filename}
|
||||
</h2>
|
||||
{versionNumber && versionNumber > 0 && (
|
||||
<span className="shrink-0 inline-flex items-center rounded-md border border-gray-200 bg-white px-1.5 py-0.5 text-[10px] font-medium text-gray-600">
|
||||
V{versionNumber}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<DownloadButton
|
||||
documentId={documentId}
|
||||
versionId={versionId}
|
||||
filename={filename}
|
||||
isReloading={isReloading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionLabel({ children }: { children: React.ReactNode }) {
|
||||
return <p className="text-xs font-medium text-gray-700">{children}</p>;
|
||||
}
|
||||
|
||||
function RelevantQuoteSection({
|
||||
citation,
|
||||
filename,
|
||||
activeQuoteId,
|
||||
onQuoteSelect,
|
||||
}: {
|
||||
citation: CitationAnnotation;
|
||||
filename: string;
|
||||
activeQuoteId: string | null;
|
||||
onQuoteSelect: (quoteId: string) => void;
|
||||
}) {
|
||||
const citationQuotes = getDocumentCitationQuotes(citation);
|
||||
const pagesLabel = formatCitationPage(citation);
|
||||
const citationText = [filename, pagesLabel].filter(Boolean).join(", ");
|
||||
const relevantQuotes: RelevantQuoteItem[] = citationQuotes.map(
|
||||
(quote, index) => {
|
||||
const pageLabel = `Page ${quote.page}`;
|
||||
return {
|
||||
id: `document:${citation.ref}:${index}`,
|
||||
quote: quote.quote.replaceAll("[[PAGE_BREAK]]", "..."),
|
||||
inlineDetail: pageLabel,
|
||||
citationText: [filename, pageLabel].filter(Boolean).join(", "),
|
||||
};
|
||||
},
|
||||
);
|
||||
const currentIndex = Math.max(
|
||||
0,
|
||||
relevantQuotes.findIndex((quote) => quote.id === activeQuoteId),
|
||||
);
|
||||
|
||||
return (
|
||||
<RelevantQuotes
|
||||
quotes={relevantQuotes}
|
||||
activeQuoteId={activeQuoteId}
|
||||
currentIndex={currentIndex}
|
||||
citationRef={citation.ref}
|
||||
citationText={citationText}
|
||||
onSelect={(quote) => onQuoteSelect(quote.id)}
|
||||
onIndexChange={(index) => {
|
||||
const quote = relevantQuotes[index];
|
||||
if (quote) onQuoteSelect(quote.id);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TrackedChangeHeader({
|
||||
mode,
|
||||
}: {
|
||||
mode: Extract<DocPanelMode, { kind: "edit" }>;
|
||||
}) {
|
||||
const { edit, isEditReloading, onResolveStart, onResolved, onError } = mode;
|
||||
return (
|
||||
<div className="px-3 pb-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<SectionLabel>Tracked Change</SectionLabel>
|
||||
<div className="ml-auto flex items-center gap-2 shrink-0">
|
||||
<EditResolveButtons
|
||||
edit={edit}
|
||||
isReloading={isEditReloading}
|
||||
onResolveStart={onResolveStart}
|
||||
onResolved={onResolved}
|
||||
onError={onError}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{edit.reason && (
|
||||
<p className="mb-2 text-xs text-gray-500">{edit.reason}</p>
|
||||
)}
|
||||
<div className="w-full rounded-md bg-gray-50 border border-gray-200 px-2 py-2">
|
||||
<div className="text-sm leading-relaxed font-serif">
|
||||
{edit.inserted_text && (
|
||||
<span className="text-green-700">
|
||||
{edit.inserted_text}
|
||||
</span>
|
||||
)}
|
||||
{edit.deleted_text && (
|
||||
<span className="text-red-600 line-through">
|
||||
{edit.deleted_text}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Accept / Reject controls
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function EditResolveButtons({
|
||||
edit,
|
||||
isReloading,
|
||||
onResolveStart,
|
||||
onResolved,
|
||||
onError,
|
||||
}: {
|
||||
edit: EditAnnotation;
|
||||
/**
|
||||
* True while an accept/reject for any edit on this document is in
|
||||
* flight (triggered from here, the inline EditCard, the bulk bar, or
|
||||
* elsewhere). Disables both buttons so the user can't double-submit
|
||||
* while a resolution is racing to change the status.
|
||||
*/
|
||||
isReloading?: boolean;
|
||||
onResolveStart?: (args: {
|
||||
editId: string;
|
||||
documentId: string;
|
||||
verb: "accept" | "reject";
|
||||
}) => void;
|
||||
onResolved?: (args: {
|
||||
editId: string;
|
||||
documentId: string;
|
||||
status: "accepted" | "rejected";
|
||||
versionId: string | null;
|
||||
downloadUrl: string | null;
|
||||
}) => void;
|
||||
onError?: (args: {
|
||||
editId: string;
|
||||
documentId: string;
|
||||
versionId: string | null;
|
||||
message: string;
|
||||
}) => void;
|
||||
}) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [status, setStatus] = useState<"pending" | "accepted" | "rejected">(
|
||||
edit.status,
|
||||
);
|
||||
// Sync with the prop when this edit is resolved elsewhere (bulk
|
||||
// accept/reject, inline per-edit card, another open panel for the same
|
||||
// edit). Skips while our own request is in flight so we don't flicker.
|
||||
useEffect(() => {
|
||||
if (busy) return;
|
||||
setStatus(edit.status);
|
||||
}, [edit.status, edit.edit_id, busy]);
|
||||
const resolved = status !== "pending";
|
||||
|
||||
const handle = useCallback(
|
||||
async (verb: "accept" | "reject") => {
|
||||
if (busy || resolved) return;
|
||||
setBusy(true);
|
||||
onResolveStart?.({
|
||||
editId: edit.edit_id,
|
||||
documentId: edit.document_id,
|
||||
verb,
|
||||
});
|
||||
// Optimistically mutate the DOM in the open viewer so the
|
||||
// change reflects immediately. Revert if the backend errors.
|
||||
let revert: (() => void) | null = null;
|
||||
try {
|
||||
revert = applyOptimisticResolution(edit, verb);
|
||||
} catch (e) {
|
||||
console.error(
|
||||
"[DocPanel] optimistic update threw",
|
||||
e,
|
||||
);
|
||||
}
|
||||
try {
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
const token = session?.access_token;
|
||||
const apiBase =
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL ??
|
||||
"http://localhost:3001";
|
||||
const resp = await fetch(
|
||||
`${apiBase}/single-documents/${edit.document_id}/edits/${edit.edit_id}/${verb}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: token
|
||||
? { Authorization: `Bearer ${token}` }
|
||||
: undefined,
|
||||
},
|
||||
);
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const data = (await resp.json()) as {
|
||||
ok: boolean;
|
||||
status?: "accepted" | "rejected";
|
||||
version_id: string | null;
|
||||
download_url: string | null;
|
||||
};
|
||||
const nextStatus =
|
||||
data.status ??
|
||||
(verb === "accept" ? "accepted" : "rejected");
|
||||
setStatus(nextStatus);
|
||||
onResolved?.({
|
||||
editId: edit.edit_id,
|
||||
documentId: edit.document_id,
|
||||
status: nextStatus,
|
||||
versionId: data.version_id,
|
||||
downloadUrl: data.download_url,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("[DocPanel] resolve failed", e);
|
||||
try {
|
||||
revert?.();
|
||||
} catch (revertErr) {
|
||||
console.error(
|
||||
"[DocPanel] revert threw",
|
||||
revertErr,
|
||||
);
|
||||
}
|
||||
onError?.({
|
||||
editId: edit.edit_id,
|
||||
documentId: edit.document_id,
|
||||
versionId: edit.version_id ?? null,
|
||||
message:
|
||||
verb === "accept"
|
||||
? "Couldn't save accept — please retry."
|
||||
: "Couldn't save reject — please retry.",
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[busy, resolved, edit, onResolveStart, onResolved, onError],
|
||||
);
|
||||
|
||||
const inFlight = busy || !!isReloading;
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handle("accept")}
|
||||
disabled={inFlight || resolved}
|
||||
className="inline-flex items-center gap-1 rounded-lg border border-gray-900 bg-gray-900 px-2 py-1.5 text-xs font-medium text-white hover:bg-gray-800 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{status === "accepted" ? "Accepted" : "Accept"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handle("reject")}
|
||||
disabled={inFlight || resolved}
|
||||
className="inline-flex items-center gap-1 rounded-lg border border-gray-200 bg-white px-2 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{status === "rejected" ? "Rejected" : "Reject"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Download button
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function DownloadButton({
|
||||
documentId,
|
||||
versionId,
|
||||
filename,
|
||||
isReloading,
|
||||
}: {
|
||||
documentId: string;
|
||||
versionId: string | null;
|
||||
filename: string;
|
||||
isReloading?: boolean;
|
||||
}) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const handleClick = async () => {
|
||||
if (busy || isReloading) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
const token = session?.access_token;
|
||||
const apiBase =
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:3001";
|
||||
const qs = versionId
|
||||
? `?version_id=${encodeURIComponent(versionId)}`
|
||||
: "";
|
||||
const resp = await fetch(
|
||||
`${apiBase}/single-documents/${documentId}/docx${qs}`,
|
||||
{
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
},
|
||||
);
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const blob = await resp.blob();
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = blobUrl;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(blobUrl), 1000);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const spinning = busy || isReloading;
|
||||
return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
disabled={spinning}
|
||||
className="inline-flex items-center gap-1 rounded-lg border border-gray-200 px-2 py-1.5 text-xs font-medium text-gray-600 hover:bg-gray-100 hover:text-gray-800 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{spinning ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
)}
|
||||
Download
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,601 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Loader2, ZoomIn, ZoomOut } from "lucide-react";
|
||||
import { useFetchSingleDoc } from "@/app/hooks/useFetchSingleDoc";
|
||||
import { DocxView } from "./DocxView";
|
||||
import type { CitationQuote } from "./types";
|
||||
import {
|
||||
clearHighlights,
|
||||
getPdfJs,
|
||||
highlightQuote,
|
||||
STANDARD_FONT_DATA_URL,
|
||||
} from "./highlightQuote";
|
||||
|
||||
interface Props {
|
||||
doc: { document_id: string; version_id?: string | null } | null;
|
||||
/** Preferred: one or more (page, quote) pairs to highlight. */
|
||||
quotes?: CitationQuote[];
|
||||
/** Changes when the parent wants the current quote re-focused. */
|
||||
quoteFocusKey?: string | number;
|
||||
/** Back-compat single-quote API. Ignored if `quotes` is provided. */
|
||||
quote?: string;
|
||||
fallbackPage?: number;
|
||||
rounded?: boolean;
|
||||
bordered?: boolean;
|
||||
}
|
||||
|
||||
type QuoteEntry = { page?: number; quote: string };
|
||||
|
||||
const SIDE_PADDING = 20;
|
||||
const ZOOM_MIN = 0.5;
|
||||
const ZOOM_MAX = 3.0;
|
||||
const ZOOM_STEP = 0.25;
|
||||
|
||||
type RenderedPage = {
|
||||
page: import("pdfjs-dist").PDFPageProxy;
|
||||
viewport: import("pdfjs-dist").PageViewport;
|
||||
wrapper: HTMLDivElement;
|
||||
canvas: HTMLCanvasElement;
|
||||
textDivs: HTMLElement[];
|
||||
};
|
||||
|
||||
export function DocView({
|
||||
doc,
|
||||
quotes,
|
||||
quoteFocusKey,
|
||||
quote,
|
||||
fallbackPage,
|
||||
rounded = true,
|
||||
bordered = true,
|
||||
}: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const pdfDocRef = useRef<import("pdfjs-dist").PDFDocumentProxy | null>(
|
||||
null,
|
||||
);
|
||||
const renderedPagesRef = useRef<RenderedPage[]>([]);
|
||||
const quoteListRef = useRef<QuoteEntry[]>([]);
|
||||
const zoomRef = useRef(1.0);
|
||||
const currentPageRef = useRef(1);
|
||||
|
||||
const quoteList: QuoteEntry[] = useMemo(() => {
|
||||
if (quotes?.length)
|
||||
return quotes.map((q) => ({ page: q.page, quote: q.quote }));
|
||||
if (quote) return [{ page: fallbackPage, quote }];
|
||||
return [];
|
||||
}, [quotes, quote, fallbackPage]);
|
||||
|
||||
// Stable string key so effects can depend on quote-list identity
|
||||
const quoteKey = quoteList
|
||||
.map((q) => `${q.page ?? ""}:${q.quote}`)
|
||||
.join("|");
|
||||
|
||||
const [containerWidth, setContainerWidth] = useState(0);
|
||||
const [zoom, setZoom] = useState(1.0);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [numPages, setNumPages] = useState(0);
|
||||
|
||||
const { result, loading, error } = useFetchSingleDoc(
|
||||
doc?.document_id ?? null,
|
||||
doc?.version_id ?? null,
|
||||
);
|
||||
|
||||
// /display returned DOCX bytes — the active version has no PDF
|
||||
// rendition, so fall back to docx-preview (still applies citation
|
||||
// highlighting via the same `quotes` API).
|
||||
const fallbackToDocx = result?.type === "docx";
|
||||
|
||||
// Track container width via ResizeObserver so re-renders fire on resize
|
||||
useEffect(() => {
|
||||
const el = scrollContainerRef.current;
|
||||
if (!el) return;
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
setContainerWidth(entries[0]?.contentRect.width ?? 0);
|
||||
});
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
// Track current page via scroll position
|
||||
useEffect(() => {
|
||||
const scrollEl = scrollContainerRef.current;
|
||||
if (!scrollEl) return;
|
||||
|
||||
const handleScroll = () => {
|
||||
const pages = renderedPagesRef.current;
|
||||
if (!pages.length) return;
|
||||
const scrollCenter = scrollEl.scrollTop + scrollEl.clientHeight / 2;
|
||||
let closest = 0;
|
||||
let closestDist = Infinity;
|
||||
pages.forEach((p, i) => {
|
||||
const pageCenter =
|
||||
p.wrapper.offsetTop + p.wrapper.clientHeight / 2;
|
||||
const dist = Math.abs(pageCenter - scrollCenter);
|
||||
if (dist < closestDist) {
|
||||
closestDist = dist;
|
||||
closest = i;
|
||||
}
|
||||
});
|
||||
currentPageRef.current = closest + 1;
|
||||
setCurrentPage(closest + 1);
|
||||
};
|
||||
|
||||
scrollEl.addEventListener("scroll", handleScroll, { passive: true });
|
||||
return () => scrollEl.removeEventListener("scroll", handleScroll);
|
||||
}, []);
|
||||
|
||||
// Highlights all entries in `list` across the already-rendered pages.
|
||||
// Returns the 1-based page number of the first successfully highlighted entry, or null.
|
||||
const applyHighlights = useCallback(
|
||||
async (list: QuoteEntry[]): Promise<number | null> => {
|
||||
// Clear any prior highlights across all pages
|
||||
for (const p of renderedPagesRef.current)
|
||||
clearHighlights(p.textDivs);
|
||||
|
||||
let firstHitPage: number | null = null;
|
||||
for (const entry of list) {
|
||||
let hitPage: number | null = null;
|
||||
|
||||
if (entry.page) {
|
||||
const target = renderedPagesRef.current[entry.page - 1];
|
||||
if (target) {
|
||||
const found = await highlightQuote(
|
||||
target.textDivs,
|
||||
entry.quote,
|
||||
);
|
||||
if (found) hitPage = entry.page;
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to scanning all pages for this quote
|
||||
if (hitPage === null) {
|
||||
console.warn(
|
||||
`Quote not found on hinted page, scanning all pages: "${entry.quote.slice(0, 60)}..."`,
|
||||
);
|
||||
for (let i = 0; i < renderedPagesRef.current.length; i++) {
|
||||
const p = renderedPagesRef.current[i];
|
||||
const found = await highlightQuote(
|
||||
p.textDivs,
|
||||
entry.quote,
|
||||
);
|
||||
if (found) {
|
||||
hitPage = i + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hitPage !== null && firstHitPage === null) {
|
||||
firstHitPage = hitPage;
|
||||
}
|
||||
}
|
||||
return firstHitPage;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const renderPDF = useCallback(
|
||||
async (
|
||||
doc: import("pdfjs-dist").PDFDocumentProxy,
|
||||
list: QuoteEntry[],
|
||||
scrollToPage?: number,
|
||||
) => {
|
||||
if (!containerRef.current) return;
|
||||
containerRef.current.innerHTML = "";
|
||||
renderedPagesRef.current = [];
|
||||
const lib = await getPdfJs();
|
||||
lib.TextLayer.cleanup();
|
||||
|
||||
setNumPages(doc.numPages);
|
||||
setCurrentPage(1);
|
||||
currentPageRef.current = 1;
|
||||
|
||||
const hasCitation = list.length > 0;
|
||||
if (hasCitation && scrollContainerRef.current) {
|
||||
scrollContainerRef.current.style.opacity = "0";
|
||||
}
|
||||
|
||||
const reveal = () => {
|
||||
if (scrollContainerRef.current)
|
||||
scrollContainerRef.current.style.opacity = "1";
|
||||
};
|
||||
|
||||
const panelW = containerRef.current.clientWidth;
|
||||
const firstPage = await doc.getPage(1);
|
||||
const naturalWidth = firstPage.getViewport({ scale: 1 }).width;
|
||||
const baseScale = Math.max(
|
||||
0.5,
|
||||
(panelW - SIDE_PADDING) / naturalWidth,
|
||||
);
|
||||
const scale = baseScale * zoomRef.current;
|
||||
|
||||
for (let pageNum = 1; pageNum <= doc.numPages; pageNum++) {
|
||||
const page = await doc.getPage(pageNum);
|
||||
const viewport = page.getViewport({ scale });
|
||||
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.style.position = "relative";
|
||||
wrapper.style.margin = "0 auto 8px";
|
||||
wrapper.style.width = "fit-content";
|
||||
wrapper.className = "shadow-md";
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
canvas.style.display = "block";
|
||||
wrapper.appendChild(canvas);
|
||||
containerRef.current?.appendChild(wrapper);
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) continue;
|
||||
|
||||
const task = page.render({ canvasContext: ctx, viewport });
|
||||
try {
|
||||
await task.promise;
|
||||
} catch (e: unknown) {
|
||||
if (
|
||||
(e as { name?: string })?.name !==
|
||||
"RenderingCancelledException"
|
||||
) {
|
||||
console.error("PDF render error", e);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const textLayerDiv = document.createElement("div");
|
||||
textLayerDiv.className = "pdf-text-layer";
|
||||
textLayerDiv.style.position = "absolute";
|
||||
textLayerDiv.style.left = "0";
|
||||
textLayerDiv.style.top = "0";
|
||||
textLayerDiv.style.width = `${viewport.width}px`;
|
||||
textLayerDiv.style.height = `${viewport.height}px`;
|
||||
textLayerDiv.style.setProperty("--scale-factor", String(scale));
|
||||
wrapper.appendChild(textLayerDiv);
|
||||
|
||||
const textLayer = new lib.TextLayer({
|
||||
textContentSource: page.streamTextContent(),
|
||||
container: textLayerDiv,
|
||||
viewport,
|
||||
});
|
||||
await textLayer.render();
|
||||
const textDivs = textLayer.textDivs;
|
||||
|
||||
renderedPagesRef.current.push({
|
||||
page,
|
||||
viewport,
|
||||
wrapper,
|
||||
canvas,
|
||||
textDivs,
|
||||
});
|
||||
}
|
||||
|
||||
// Apply highlights across all entries, then scroll to the first hit.
|
||||
let targetPage: number | null = null;
|
||||
if (list.length) {
|
||||
targetPage = await applyHighlights(list);
|
||||
if (targetPage === null) {
|
||||
// Fallback: scroll to the first entry's page hint, even without a highlight
|
||||
const hint = list.find((e) => e.page)?.page ?? null;
|
||||
targetPage = hint;
|
||||
}
|
||||
}
|
||||
if (targetPage && targetPage >= 1) {
|
||||
scrollToHighlightOnPage(targetPage);
|
||||
} else if (!hasCitation && scrollToPage && scrollToPage > 1) {
|
||||
// Restore scroll position after zoom re-render
|
||||
const pageEntry = renderedPagesRef.current[scrollToPage - 1];
|
||||
if (pageEntry)
|
||||
pageEntry.wrapper.scrollIntoView({
|
||||
behavior: "instant" as ScrollBehavior,
|
||||
block: "start",
|
||||
});
|
||||
}
|
||||
|
||||
reveal();
|
||||
},
|
||||
[applyHighlights],
|
||||
);
|
||||
|
||||
// Scroll so the first highlight on `pageNum` lands at the vertical center
|
||||
// of the viewer. We compute the scroll position explicitly on the scroll
|
||||
// container — calling `scrollIntoView` on a child of the absolutely-
|
||||
// positioned text layer can scroll just the overlay while leaving the
|
||||
// canvas untouched, which is why we don't use it here.
|
||||
function scrollToHighlightOnPage(pageNum: number) {
|
||||
const pageEntry = renderedPagesRef.current[pageNum - 1];
|
||||
const scrollEl = scrollContainerRef.current;
|
||||
if (!pageEntry || !scrollEl) return;
|
||||
|
||||
const highlightEl = pageEntry.wrapper.querySelector<HTMLElement>(
|
||||
".pdf-text-highlight",
|
||||
);
|
||||
if (highlightEl) {
|
||||
const containerRect = scrollEl.getBoundingClientRect();
|
||||
const highlightRect = highlightEl.getBoundingClientRect();
|
||||
const offsetWithinContainer = highlightRect.top - containerRect.top;
|
||||
const targetTop =
|
||||
scrollEl.scrollTop +
|
||||
offsetWithinContainer -
|
||||
scrollEl.clientHeight / 2 +
|
||||
highlightRect.height / 2;
|
||||
scrollEl.scrollTo({
|
||||
top: Math.max(0, targetTop),
|
||||
behavior: "instant" as ScrollBehavior,
|
||||
});
|
||||
} else {
|
||||
const wrapperRect = pageEntry.wrapper.getBoundingClientRect();
|
||||
const containerRect = scrollEl.getBoundingClientRect();
|
||||
const targetTop =
|
||||
scrollEl.scrollTop + (wrapperRect.top - containerRect.top);
|
||||
scrollEl.scrollTo({
|
||||
top: Math.max(0, targetTop),
|
||||
behavior: "instant" as ScrollBehavior,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const rehighlightQuotes = useCallback(
|
||||
async (list: QuoteEntry[]) => {
|
||||
const targetPage = await applyHighlights(list);
|
||||
const scrollPage =
|
||||
targetPage ?? list.find((e) => e.page)?.page ?? null;
|
||||
if (scrollPage && scrollPage >= 1) {
|
||||
scrollToHighlightOnPage(scrollPage);
|
||||
}
|
||||
},
|
||||
[applyHighlights],
|
||||
);
|
||||
|
||||
// Trackpad pinch-to-zoom (wheel + ctrlKey)
|
||||
useEffect(() => {
|
||||
const el = scrollContainerRef.current;
|
||||
if (!el) return;
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
if (!e.ctrlKey) return;
|
||||
e.preventDefault();
|
||||
const delta = e.deltaMode === 0 ? e.deltaY / 300 : e.deltaY * 0.1;
|
||||
const next = Math.min(
|
||||
ZOOM_MAX,
|
||||
Math.max(
|
||||
ZOOM_MIN,
|
||||
Math.round(zoomRef.current * Math.exp(-delta) * 100) / 100,
|
||||
),
|
||||
);
|
||||
if (next === zoomRef.current) return;
|
||||
zoomRef.current = next;
|
||||
setZoom(next);
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(() => {
|
||||
if (pdfDocRef.current) {
|
||||
renderPDF(
|
||||
pdfDocRef.current,
|
||||
quoteListRef.current,
|
||||
currentPageRef.current,
|
||||
);
|
||||
}
|
||||
}, 150);
|
||||
};
|
||||
|
||||
el.addEventListener("wheel", handleWheel, { passive: false });
|
||||
return () => {
|
||||
el.removeEventListener("wheel", handleWheel);
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
};
|
||||
}, [renderPDF]);
|
||||
|
||||
// Touch pinch-to-zoom
|
||||
useEffect(() => {
|
||||
const el = scrollContainerRef.current;
|
||||
if (!el) return;
|
||||
let initialDist = 0;
|
||||
let initialZoom = 1.0;
|
||||
|
||||
function getTouchDist(touches: TouchList) {
|
||||
const dx = touches[0].clientX - touches[1].clientX;
|
||||
const dy = touches[0].clientY - touches[1].clientY;
|
||||
return Math.hypot(dx, dy);
|
||||
}
|
||||
|
||||
const handleTouchStart = (e: TouchEvent) => {
|
||||
if (e.touches.length === 2) {
|
||||
initialDist = getTouchDist(e.touches);
|
||||
initialZoom = zoomRef.current;
|
||||
}
|
||||
};
|
||||
|
||||
const handleTouchMove = (e: TouchEvent) => {
|
||||
if (e.touches.length !== 2 || initialDist === 0) return;
|
||||
e.preventDefault();
|
||||
const next = Math.min(
|
||||
ZOOM_MAX,
|
||||
Math.max(
|
||||
ZOOM_MIN,
|
||||
Math.round(
|
||||
initialZoom *
|
||||
(getTouchDist(e.touches) / initialDist) *
|
||||
100,
|
||||
) / 100,
|
||||
),
|
||||
);
|
||||
zoomRef.current = next;
|
||||
setZoom(next);
|
||||
};
|
||||
|
||||
const handleTouchEnd = (e: TouchEvent) => {
|
||||
if (e.touches.length < 2 && initialDist > 0) {
|
||||
initialDist = 0;
|
||||
if (pdfDocRef.current) {
|
||||
renderPDF(
|
||||
pdfDocRef.current,
|
||||
quoteListRef.current,
|
||||
currentPageRef.current,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
el.addEventListener("touchstart", handleTouchStart, { passive: true });
|
||||
el.addEventListener("touchmove", handleTouchMove, { passive: false });
|
||||
el.addEventListener("touchend", handleTouchEnd, { passive: true });
|
||||
return () => {
|
||||
el.removeEventListener("touchstart", handleTouchStart);
|
||||
el.removeEventListener("touchmove", handleTouchMove);
|
||||
el.removeEventListener("touchend", handleTouchEnd);
|
||||
};
|
||||
}, [renderPDF]);
|
||||
|
||||
// Clean up PDF.js static font-measurement canvases on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
getPdfJs().then((lib) => lib.TextLayer.cleanup());
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Render PDF when fetch result arrives
|
||||
useEffect(() => {
|
||||
if (!result || result.type !== "pdf") return;
|
||||
pdfDocRef.current = null;
|
||||
renderedPagesRef.current = [];
|
||||
quoteListRef.current = quoteList;
|
||||
zoomRef.current = 1.0;
|
||||
setZoom(1.0);
|
||||
setNumPages(0);
|
||||
const list = quoteList;
|
||||
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const lib = await getPdfJs();
|
||||
if (cancelled) return;
|
||||
const pdfDoc = await lib.getDocument({
|
||||
data: new Uint8Array(result.buffer),
|
||||
standardFontDataUrl: STANDARD_FONT_DATA_URL,
|
||||
}).promise;
|
||||
if (cancelled) return;
|
||||
pdfDocRef.current = pdfDoc;
|
||||
await renderPDF(pdfDoc, list);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [result, renderPDF]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Re-render at new scale when container is resized (debounced 150ms)
|
||||
useEffect(() => {
|
||||
if (!pdfDocRef.current) return;
|
||||
const timer = setTimeout(() => {
|
||||
if (pdfDocRef.current) {
|
||||
renderPDF(pdfDocRef.current, quoteListRef.current);
|
||||
}
|
||||
}, 150);
|
||||
return () => clearTimeout(timer);
|
||||
}, [containerWidth, renderPDF]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Re-highlight when quotes change without full re-render
|
||||
useEffect(() => {
|
||||
if (!pdfDocRef.current) return;
|
||||
quoteListRef.current = quoteList;
|
||||
rehighlightQuotes(quoteList);
|
||||
}, [quoteKey, quoteFocusKey, rehighlightQuotes]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
function handleZoomIn() {
|
||||
const next = Math.min(
|
||||
ZOOM_MAX,
|
||||
Math.round((zoomRef.current + ZOOM_STEP) * 100) / 100,
|
||||
);
|
||||
zoomRef.current = next;
|
||||
setZoom(next);
|
||||
if (pdfDocRef.current) {
|
||||
renderPDF(
|
||||
pdfDocRef.current,
|
||||
quoteListRef.current,
|
||||
currentPageRef.current,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function handleZoomOut() {
|
||||
const next = Math.max(
|
||||
ZOOM_MIN,
|
||||
Math.round((zoomRef.current - ZOOM_STEP) * 100) / 100,
|
||||
);
|
||||
zoomRef.current = next;
|
||||
setZoom(next);
|
||||
if (pdfDocRef.current) {
|
||||
renderPDF(
|
||||
pdfDocRef.current,
|
||||
quoteListRef.current,
|
||||
currentPageRef.current,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (fallbackToDocx && doc?.document_id) {
|
||||
return (
|
||||
<DocxView
|
||||
documentId={doc.document_id}
|
||||
versionId={doc.version_id ?? null}
|
||||
quotes={quotes}
|
||||
quoteFocusKey={quoteFocusKey}
|
||||
rounded={rounded}
|
||||
bordered={bordered}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative flex flex-col flex-1 overflow-hidden ${bordered ? "border border-gray-200" : ""} ${rounded ? "rounded-lg" : ""}`}
|
||||
>
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="flex-1 overflow-auto bg-gray-100 px-3 pt-5 pb-3"
|
||||
>
|
||||
{loading && (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Loader2 className="h-7 w-7 animate-spin text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-sm text-red-500">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
<div ref={containerRef} />
|
||||
</div>
|
||||
{numPages > 0 && (
|
||||
<>
|
||||
{/* Page counter — bottom left */}
|
||||
<div className="absolute bottom-4 left-4 pointer-events-none">
|
||||
<span className="flex items-center px-3 py-1.5 rounded-full text-xs font-medium tabular-nums text-gray-700 bg-white/25 backdrop-blur-md border border-white/30 shadow-md">
|
||||
{currentPage}/{numPages}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Zoom controls — bottom right */}
|
||||
<div className="absolute bottom-4 right-4 flex items-center gap-px rounded-full bg-white/25 backdrop-blur-md border border-white/30 shadow-md px-1 py-1">
|
||||
<button
|
||||
onClick={handleZoomOut}
|
||||
disabled={zoom <= ZOOM_MIN}
|
||||
className="flex items-center justify-center w-7 h-7 rounded-full text-gray-600 hover:bg-white/80 disabled:opacity-30 transition-colors"
|
||||
>
|
||||
<ZoomOut className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<span className="text-xs font-medium text-gray-600 tabular-nums w-9 text-center select-none">
|
||||
{Math.round(zoom * 100)}%
|
||||
</span>
|
||||
<button
|
||||
onClick={handleZoomIn}
|
||||
disabled={zoom >= ZOOM_MAX}
|
||||
className="flex items-center justify-center w-7 h-7 rounded-full text-gray-600 hover:bg-white/80 disabled:opacity-30 transition-colors"
|
||||
>
|
||||
<ZoomIn className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,101 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Download, Trash2, X } from "lucide-react";
|
||||
import { DocView } from "./DocView";
|
||||
import { getDocumentUrl } from "@/app/lib/mikeApi";
|
||||
import type { Document } from "./types";
|
||||
|
||||
interface Props {
|
||||
doc: Document | null;
|
||||
/** Optional specific version to display. Only honoured for DOCX. */
|
||||
versionId?: string | null;
|
||||
/** Optional label suffix for the header (e.g. "V3"). */
|
||||
versionLabel?: string | null;
|
||||
onClose: () => void;
|
||||
onDelete?: (doc: Document) => void;
|
||||
}
|
||||
|
||||
export function DocViewModal({
|
||||
doc,
|
||||
versionId,
|
||||
versionLabel,
|
||||
onClose,
|
||||
onDelete,
|
||||
}: Props) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
useEffect(() => setMounted(true), []);
|
||||
|
||||
if (!doc || !mounted) return null;
|
||||
|
||||
async function handleDownload() {
|
||||
if (!doc) return;
|
||||
const { url, filename } = await getDocumentUrl(doc.id, versionId ?? null);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-100 flex items-center justify-center bg-black/40"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="relative flex flex-col bg-white rounded-xl shadow-2xl w-[800px] max-w-[90vw] h-[90vh]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-3 shrink-0">
|
||||
<span className="text-base font-medium font-serif text-gray-800 truncate pr-4">
|
||||
{doc.filename}
|
||||
{versionLabel && (
|
||||
<span className="ml-2 text-xs font-normal text-gray-500">
|
||||
{versionLabel}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="flex items-center justify-center w-6 h-6 rounded hover:bg-gray-100 text-gray-400 hover:text-gray-700 transition-colors"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</button>
|
||||
{onDelete && (
|
||||
<button
|
||||
onClick={() => { onDelete(doc); onClose(); }}
|
||||
className="flex items-center justify-center w-6 h-6 rounded hover:bg-red-50 text-gray-400 hover:text-red-500 transition-colors"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex items-center justify-center w-6 h-6 rounded hover:bg-gray-100 text-gray-400 hover:text-gray-700 transition-colors"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* DocView serves PDF when available and falls back to
|
||||
docx-preview internally if the active version has no
|
||||
PDF rendition. Passing no versionId tells the backend
|
||||
to resolve the latest tracked-changes version. */}
|
||||
<div className="flex flex-col flex-1 overflow-hidden px-3 pb-3">
|
||||
<DocView
|
||||
key={versionId ?? "current"}
|
||||
doc={{
|
||||
document_id: doc.id,
|
||||
version_id: versionId ?? null,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { FileText, File, X, AlertCircle, Loader2 } from "lucide-react";
|
||||
import type { Document } from "./types";
|
||||
|
||||
interface Props {
|
||||
document: Document;
|
||||
onRemove?: (id: string) => void;
|
||||
onClick?: (doc: Document) => void;
|
||||
selected?: boolean;
|
||||
}
|
||||
|
||||
function FileIcon({ fileType }: { fileType: string | null }) {
|
||||
if (fileType === "pdf") {
|
||||
return <FileText className="h-4 w-4 text-red-600 shrink-0" />;
|
||||
}
|
||||
if (fileType === "docx" || fileType === "doc") {
|
||||
return <File className="h-4 w-4 text-blue-600 shrink-0" />;
|
||||
}
|
||||
return <File className="h-4 w-4 text-gray-500 shrink-0" />;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
export function DocumentCard({ document, onRemove, onClick, selected }: Props) {
|
||||
const isError = document.status === "error";
|
||||
const isProcessing = document.status === "pending" || document.status === "processing";
|
||||
const filename = document.filename;
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => onClick?.(document)}
|
||||
className={[
|
||||
"flex items-center gap-2.5 rounded-lg border px-3 py-2.5 text-sm transition-colors",
|
||||
onClick ? "cursor-pointer" : "",
|
||||
selected
|
||||
? "border-blue-500 bg-blue-50"
|
||||
: isError
|
||||
? "border-red-200 bg-red-50"
|
||||
: "border-gray-200 bg-white hover:border-gray-300",
|
||||
].join(" ")}
|
||||
>
|
||||
{isProcessing ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-gray-400 shrink-0" />
|
||||
) : isError ? (
|
||||
<AlertCircle className="h-4 w-4 text-red-500 shrink-0" />
|
||||
) : (
|
||||
<FileIcon fileType={document.file_type} />
|
||||
)}
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-medium text-gray-800" title={filename}>
|
||||
{filename}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
{isProcessing
|
||||
? "Processing…"
|
||||
: isError
|
||||
? "Upload failed"
|
||||
: [
|
||||
document.size_bytes != null ? formatBytes(document.size_bytes) : null,
|
||||
document.page_count ? `${document.page_count}p` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{onRemove && !isProcessing && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove(document.id);
|
||||
}}
|
||||
className="shrink-0 rounded p-0.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||
aria-label="Remove document"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,505 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useFetchDocxBytes } from "@/app/hooks/useFetchDocxBytes";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import {
|
||||
clearDocxQuoteHighlights,
|
||||
highlightDocxQuote,
|
||||
} from "./highlightDocxQuote";
|
||||
import type { CitationQuote } from "./types";
|
||||
|
||||
interface Props {
|
||||
documentId: string;
|
||||
versionId?: string | null;
|
||||
/**
|
||||
* Called once the document has been rendered to the DOM. Handy for
|
||||
* scrolling to a particular tracked change after a re-render.
|
||||
*/
|
||||
onReady?: () => void;
|
||||
/**
|
||||
* Tracked-change to scroll to + briefly flash after each render. The
|
||||
* `key` is used to re-trigger scrolling when the same edit is clicked
|
||||
* twice in a row.
|
||||
*/
|
||||
highlightEdit?: {
|
||||
key: string;
|
||||
inserted_text?: string;
|
||||
deleted_text?: string;
|
||||
/**
|
||||
* Numeric w:id values of the <w:ins>/<w:del> wrappers in
|
||||
* document.xml. Preferred over text matching — uniquely identifies
|
||||
* the right DOM element even when multiple edits share identical
|
||||
* inserted/deleted text. `docx-preview` drops these during parsing,
|
||||
* so we re-tag each rendered <ins>/<del> with data-w-id after load.
|
||||
*/
|
||||
ins_w_id?: string | null;
|
||||
del_w_id?: string | null;
|
||||
} | null;
|
||||
/**
|
||||
* Forces a byte re-fetch when it changes, even if documentId/versionId
|
||||
* are stable. Used after accept/reject: the backend overwrites bytes at
|
||||
* the same storage path (no new version row), so the hook has no other
|
||||
* signal that the file changed.
|
||||
*/
|
||||
refetchKey?: number;
|
||||
/**
|
||||
* Citation quotes to highlight in the rendered output. The first match
|
||||
* is scrolled into view. Page numbers are ignored — DOCX has no explicit
|
||||
* pagination the renderer can match against.
|
||||
*/
|
||||
quotes?: CitationQuote[];
|
||||
/** Changes when the parent wants the current quote re-focused. */
|
||||
quoteFocusKey?: string | number;
|
||||
/**
|
||||
* Warning banner copy rendered in the top-left of the viewer. Used
|
||||
* for non-blocking errors (e.g. "Accept failed — reverted").
|
||||
*/
|
||||
warning?: string | null;
|
||||
/**
|
||||
* Called when the user dismisses the warning banner.
|
||||
*/
|
||||
onWarningDismiss?: () => void;
|
||||
/**
|
||||
* Scroll position to restore after the first render — used by parents
|
||||
* that track per-tab scroll and want to re-enter at the same spot.
|
||||
* Null/undefined means "no override" (preserve the pre-render scroll).
|
||||
*/
|
||||
initialScrollTop?: number | null;
|
||||
/**
|
||||
* Fires on scroll (throttled by rAF) so the parent can persist the
|
||||
* current scrollTop against its tab state.
|
||||
*/
|
||||
onScrollChange?: (scrollTop: number) => void;
|
||||
rounded?: boolean;
|
||||
bordered?: boolean;
|
||||
}
|
||||
|
||||
function findEditElement(
|
||||
root: HTMLElement,
|
||||
tag: "ins" | "del",
|
||||
opts: { w_id?: string | null; text?: string },
|
||||
): HTMLElement | null {
|
||||
if (opts.w_id) {
|
||||
const byId = root.querySelector(
|
||||
`${tag}[data-w-id="${CSS.escape(opts.w_id)}"]`,
|
||||
) as HTMLElement | null;
|
||||
if (byId) return byId;
|
||||
}
|
||||
const text = opts.text ?? "";
|
||||
const normalize = (s: string) => s.replace(/\s+/g, " ").trim();
|
||||
const target = normalize(text);
|
||||
if (!target) return null;
|
||||
const candidates = Array.from(root.querySelectorAll(tag)) as HTMLElement[];
|
||||
return (
|
||||
candidates.find((el) => normalize(el.textContent ?? "") === target) ??
|
||||
candidates.find((el) =>
|
||||
normalize(el.textContent ?? "").includes(target),
|
||||
) ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
function scrollToHighlight(
|
||||
container: HTMLElement,
|
||||
scrollEl: HTMLElement,
|
||||
edit: {
|
||||
inserted_text?: string;
|
||||
deleted_text?: string;
|
||||
ins_w_id?: string | null;
|
||||
del_w_id?: string | null;
|
||||
},
|
||||
) {
|
||||
const insEl = findEditElement(container, "ins", {
|
||||
w_id: edit.ins_w_id,
|
||||
text: edit.inserted_text,
|
||||
});
|
||||
const delEl = findEditElement(container, "del", {
|
||||
w_id: edit.del_w_id,
|
||||
text: edit.deleted_text,
|
||||
});
|
||||
const anchor = insEl ?? delEl;
|
||||
if (!anchor) return;
|
||||
|
||||
const scrollRect = scrollEl.getBoundingClientRect();
|
||||
const targetRect = anchor.getBoundingClientRect();
|
||||
const offset = targetRect.top - scrollRect.top + scrollEl.scrollTop - 80;
|
||||
scrollEl.scrollTo({ top: Math.max(0, offset), behavior: "smooth" });
|
||||
|
||||
const flashed = [insEl, delEl].filter((el): el is HTMLElement => !!el);
|
||||
flashed.forEach((el) => el.classList.add("docx-edit-flash"));
|
||||
window.setTimeout(() => {
|
||||
flashed.forEach((el) => el.classList.remove("docx-edit-flash"));
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the ordered list of w:ids for every w:ins/w:del in the current
|
||||
* version and tag each rendered <ins>/<del> with data-w-id. The backend
|
||||
* returns ids in document order, and docx-preview emits <ins>/<del>
|
||||
* in the same order, so we can align by index.
|
||||
*/
|
||||
async function tagWIdsOnRenderedDom(
|
||||
container: HTMLElement,
|
||||
documentId: string,
|
||||
versionId: string | null | undefined,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
const token = session?.access_token;
|
||||
const apiBase =
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:3001";
|
||||
const qs = versionId
|
||||
? `?version_id=${encodeURIComponent(versionId)}`
|
||||
: "";
|
||||
const resp = await fetch(
|
||||
`${apiBase}/single-documents/${documentId}/tracked-change-ids${qs}`,
|
||||
{ headers: token ? { Authorization: `Bearer ${token}` } : {} },
|
||||
);
|
||||
if (!resp.ok) {
|
||||
console.warn(
|
||||
"[DocxView] tracked-change-ids fetch failed",
|
||||
resp.status,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const data = (await resp.json()) as {
|
||||
ids: { kind: "ins" | "del"; w_id: string }[];
|
||||
};
|
||||
const domEls = Array.from(
|
||||
container.querySelectorAll("ins, del"),
|
||||
) as HTMLElement[];
|
||||
const ids = data.ids ?? [];
|
||||
let tagged = 0;
|
||||
let mismatched = 0;
|
||||
for (let i = 0; i < Math.min(domEls.length, ids.length); i++) {
|
||||
const el = domEls[i];
|
||||
const info = ids[i];
|
||||
if (el.tagName.toLowerCase() !== info.kind) {
|
||||
mismatched++;
|
||||
continue;
|
||||
}
|
||||
el.setAttribute("data-w-id", info.w_id);
|
||||
tagged++;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[DocxView] tagWIdsOnRenderedDom failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a .docx in the browser using `docx-preview`. Tracked changes
|
||||
* (`w:ins` / `w:del`) show up automatically with coloured strike/underline
|
||||
* styling. Scroll position is preserved across re-renders so Accept/Reject
|
||||
* doesn't jump the user back to the top.
|
||||
*/
|
||||
export function DocxView({
|
||||
documentId,
|
||||
versionId,
|
||||
onReady,
|
||||
highlightEdit,
|
||||
refetchKey,
|
||||
quotes,
|
||||
quoteFocusKey,
|
||||
warning,
|
||||
onWarningDismiss,
|
||||
initialScrollTop,
|
||||
onScrollChange,
|
||||
rounded = true,
|
||||
bordered = true,
|
||||
}: Props) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const lastScrollTopRef = useRef(0);
|
||||
const renderKeyRef = useRef(0);
|
||||
// Ref-stabilize onReady and highlightEdit so the render effect only
|
||||
// re-fires when `bytes` actually change. Without this, any parent
|
||||
// re-render (e.g. clicking a new highlight) creates a new onReady
|
||||
// identity, triggers a full re-render, and snaps scroll back to top.
|
||||
const onReadyRef = useRef(onReady);
|
||||
onReadyRef.current = onReady;
|
||||
const highlightEditRef = useRef(highlightEdit);
|
||||
highlightEditRef.current = highlightEdit;
|
||||
const quotesRef = useRef(quotes);
|
||||
quotesRef.current = quotes;
|
||||
const initialScrollTopRef = useRef(initialScrollTop ?? null);
|
||||
initialScrollTopRef.current = initialScrollTop ?? null;
|
||||
const onScrollChangeRef = useRef(onScrollChange);
|
||||
onScrollChangeRef.current = onScrollChange;
|
||||
|
||||
// Stable key for the quote list so the re-highlight effect re-fires
|
||||
// only when the actual text/order of quotes changes.
|
||||
const quoteKey = useMemo(
|
||||
() => (quotes ?? []).map((q) => q.quote).join("||"),
|
||||
[quotes],
|
||||
);
|
||||
|
||||
const { bytes, loading, error } = useFetchDocxBytes(
|
||||
documentId,
|
||||
versionId,
|
||||
refetchKey,
|
||||
);
|
||||
|
||||
/**
|
||||
* Highlight every quote in `list` inside the rendered DOM and scroll
|
||||
* the first match into view. Returns true if any match was found.
|
||||
*/
|
||||
const applyQuoteHighlights = (
|
||||
containerEl: HTMLElement,
|
||||
scrollEl: HTMLElement,
|
||||
list: CitationQuote[] | undefined,
|
||||
): boolean => {
|
||||
clearDocxQuoteHighlights(containerEl);
|
||||
if (!list || list.length === 0) return false;
|
||||
|
||||
let firstMatch: HTMLElement | null = null;
|
||||
for (const q of list) {
|
||||
const match = highlightDocxQuote(containerEl, q.quote);
|
||||
if (match && !firstMatch) firstMatch = match;
|
||||
}
|
||||
if (!firstMatch) return false;
|
||||
|
||||
const scrollRect = scrollEl.getBoundingClientRect();
|
||||
const targetRect = firstMatch.getBoundingClientRect();
|
||||
const offset =
|
||||
targetRect.top -
|
||||
scrollRect.top +
|
||||
scrollEl.scrollTop -
|
||||
scrollEl.clientHeight / 2 +
|
||||
targetRect.height / 2;
|
||||
scrollEl.scrollTo({
|
||||
top: Math.max(0, offset),
|
||||
behavior: "instant" as ScrollBehavior,
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* docx-preview renders pages at their natural Word page width (e.g.
|
||||
* ~816px for US Letter). When the side-panel is narrower than that,
|
||||
* the page overflows horizontally. Apply CSS `zoom` on each
|
||||
* section.docx so the document shrinks to fit — `zoom` (unlike
|
||||
* `transform: scale`) also shrinks the layout box, so the scroll
|
||||
* container's scrollHeight adapts. We zoom each page rather than the
|
||||
* wrapper because docx-preview injects flex styles on `.docx-wrapper`
|
||||
* that can interfere with wrapper-level zoom.
|
||||
*/
|
||||
const applyDocxScale = () => {
|
||||
const containerEl = containerRef.current;
|
||||
const scrollEl = scrollRef.current;
|
||||
if (!containerEl || !scrollEl) return;
|
||||
const wrapper = containerEl.querySelector<HTMLElement>(".docx-wrapper");
|
||||
if (!wrapper) return;
|
||||
const sections = Array.from(
|
||||
wrapper.querySelectorAll<HTMLElement>("section.docx"),
|
||||
);
|
||||
if (sections.length === 0) return;
|
||||
// Reset zoom on every page before measuring so offsetWidth reports
|
||||
// each page's natural width (pages can have different widths — e.g.
|
||||
// mixed portrait/landscape sections).
|
||||
sections.forEach((s) => {
|
||||
s.style.zoom = "1";
|
||||
});
|
||||
// Use the scroll container's content box (clientWidth - padding)
|
||||
// as the available width.
|
||||
const styles = window.getComputedStyle(scrollEl);
|
||||
const padX =
|
||||
(parseFloat(styles.paddingLeft) || 0) +
|
||||
(parseFloat(styles.paddingRight) || 0);
|
||||
const available = scrollEl.clientWidth - padX;
|
||||
if (available <= 0) return;
|
||||
// Scale each page independently against its own natural width so
|
||||
// landscape/custom-size pages still fit without distorting the
|
||||
// page dividers.
|
||||
sections.forEach((s) => {
|
||||
const w = s.offsetWidth;
|
||||
if (!w) return;
|
||||
const scale = Math.min(1, available / w);
|
||||
s.style.zoom = String(scale);
|
||||
});
|
||||
};
|
||||
|
||||
// Observe the scroll container (which tracks the side panel's width)
|
||||
// and re-scale whenever it resizes. Also observe the docx container so
|
||||
// we re-scale once docx-preview finishes inserting pages.
|
||||
useEffect(() => {
|
||||
const scrollEl = scrollRef.current;
|
||||
const containerEl = containerRef.current;
|
||||
if (!scrollEl || !containerEl) return;
|
||||
let raf = 0;
|
||||
const schedule = () => {
|
||||
if (raf) cancelAnimationFrame(raf);
|
||||
raf = requestAnimationFrame(() => applyDocxScale());
|
||||
};
|
||||
const ro = new ResizeObserver(schedule);
|
||||
ro.observe(scrollEl);
|
||||
ro.observe(containerEl);
|
||||
return () => {
|
||||
if (raf) cancelAnimationFrame(raf);
|
||||
ro.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (!bytes || !containerRef.current || !scrollRef.current) return;
|
||||
|
||||
const scrollEl = scrollRef.current;
|
||||
const containerEl = containerRef.current;
|
||||
|
||||
// Remember scroll position across re-renders so Accept/Reject stays put.
|
||||
lastScrollTopRef.current = scrollEl.scrollTop;
|
||||
const thisRender = ++renderKeyRef.current;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const { renderAsync } = await import("docx-preview");
|
||||
if (cancelled) return;
|
||||
containerEl.innerHTML = "";
|
||||
await renderAsync(bytes, containerEl, undefined, {
|
||||
inWrapper: true,
|
||||
ignoreWidth: false,
|
||||
ignoreHeight: false,
|
||||
renderChanges: true,
|
||||
experimental: true,
|
||||
});
|
||||
if (cancelled) return;
|
||||
await tagWIdsOnRenderedDom(
|
||||
containerEl,
|
||||
documentId,
|
||||
versionId ?? null,
|
||||
);
|
||||
if (cancelled) return;
|
||||
// Scale to fit before scrolling so offsets are computed
|
||||
// against the post-zoom layout.
|
||||
applyDocxScale();
|
||||
requestAnimationFrame(() => {
|
||||
if (
|
||||
!scrollRef.current ||
|
||||
thisRender !== renderKeyRef.current
|
||||
)
|
||||
return;
|
||||
const pendingHighlight = highlightEditRef.current;
|
||||
const pendingQuotes = quotesRef.current;
|
||||
const pendingInitialScroll = initialScrollTopRef.current;
|
||||
if (pendingHighlight) {
|
||||
scrollToHighlight(
|
||||
containerEl,
|
||||
scrollRef.current,
|
||||
pendingHighlight,
|
||||
);
|
||||
// Highlight quotes too, but don't override the edit scroll
|
||||
if (pendingQuotes?.length) {
|
||||
for (const q of pendingQuotes)
|
||||
highlightDocxQuote(containerEl, q.quote);
|
||||
}
|
||||
} else if (
|
||||
pendingQuotes &&
|
||||
applyQuoteHighlights(
|
||||
containerEl,
|
||||
scrollRef.current,
|
||||
pendingQuotes,
|
||||
)
|
||||
) {
|
||||
// scrolled inside applyQuoteHighlights
|
||||
} else if (typeof pendingInitialScroll === "number") {
|
||||
scrollRef.current.scrollTop = pendingInitialScroll;
|
||||
} else {
|
||||
scrollRef.current.scrollTop = lastScrollTopRef.current;
|
||||
}
|
||||
onReadyRef.current?.();
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("docx-preview render failed", e);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [bytes]);
|
||||
|
||||
// Re-scroll/highlight if the target edit changes without a re-render
|
||||
// (e.g. same doc, different edit clicked).
|
||||
useEffect(() => {
|
||||
if (!highlightEdit || !containerRef.current || !scrollRef.current)
|
||||
return;
|
||||
scrollToHighlight(
|
||||
containerRef.current,
|
||||
scrollRef.current,
|
||||
highlightEdit,
|
||||
);
|
||||
}, [highlightEdit?.key]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Re-apply quote highlights when the quote list changes without a full
|
||||
// re-render (e.g. clicking a different citation on the same doc).
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || !scrollRef.current) return;
|
||||
applyQuoteHighlights(
|
||||
containerRef.current,
|
||||
scrollRef.current,
|
||||
quotesRef.current,
|
||||
);
|
||||
}, [quoteKey, quoteFocusKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Fire onScrollChange (rAF-throttled) so parents can persist scroll
|
||||
// per-tab. We still maintain lastScrollTopRef locally for same-mount
|
||||
// re-renders (Accept/Reject preserving scroll within one view).
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
let scheduled = false;
|
||||
const onScroll = () => {
|
||||
lastScrollTopRef.current = el.scrollTop;
|
||||
if (scheduled) return;
|
||||
scheduled = true;
|
||||
requestAnimationFrame(() => {
|
||||
scheduled = false;
|
||||
onScrollChangeRef.current?.(el.scrollTop);
|
||||
});
|
||||
};
|
||||
el.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => el.removeEventListener("scroll", onScroll);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative flex flex-col flex-1 overflow-hidden ${bordered ? "border border-gray-200" : ""} ${rounded ? "rounded-lg" : ""}`}
|
||||
>
|
||||
{warning && (
|
||||
<div className="absolute top-2 left-2 z-10 flex items-center gap-2 rounded-md border border-amber-200 bg-amber-50 px-2 py-1 text-xs text-amber-800 shadow-sm">
|
||||
<span>{warning}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onWarningDismiss?.()}
|
||||
className="text-amber-600 hover:text-amber-900"
|
||||
aria-label="Dismiss warning"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex-1 overflow-auto bg-gray-100 px-5 pt-5 pb-3 docx-view-scroll"
|
||||
data-document-id={documentId}
|
||||
data-version-id={versionId ?? ""}
|
||||
>
|
||||
{loading && !bytes && (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Loader2 className="h-7 w-7 animate-spin text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-sm text-red-500">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
<div ref={containerRef} className="docx-view-container" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,117 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
interface Props {
|
||||
emails: string[];
|
||||
onChange: (emails: string[]) => void;
|
||||
validate?: (email: string) => Promise<string | null>;
|
||||
onValidatingChange?: (validating: boolean) => void;
|
||||
placeholder?: string;
|
||||
autoFocus?: boolean;
|
||||
}
|
||||
|
||||
export function EmailPillInput({
|
||||
emails,
|
||||
onChange,
|
||||
validate,
|
||||
onValidatingChange,
|
||||
placeholder = "Add by email…",
|
||||
autoFocus = false,
|
||||
}: Props) {
|
||||
const [input, setInput] = useState("");
|
||||
const [validating, setValidating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
function setValidatingState(v: boolean) {
|
||||
setValidating(v);
|
||||
onValidatingChange?.(v);
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
||||
if (e.key === "Enter" || e.key === ",") {
|
||||
e.preventDefault();
|
||||
addEmail();
|
||||
} else if (e.key === "Backspace" && !input && emails.length > 0) {
|
||||
onChange(emails.slice(0, -1));
|
||||
}
|
||||
}
|
||||
|
||||
async function addEmail() {
|
||||
const email = input.trim().toLowerCase();
|
||||
if (!email) return;
|
||||
if (emails.includes(email)) {
|
||||
setInput("");
|
||||
return;
|
||||
}
|
||||
if (!EMAIL_RE.test(email)) {
|
||||
setError("Enter a valid email address.");
|
||||
return;
|
||||
}
|
||||
if (validate) {
|
||||
setValidatingState(true);
|
||||
setError(null);
|
||||
try {
|
||||
const err = await validate(email);
|
||||
if (err) {
|
||||
setError(err);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
setError("Could not verify email. Try again.");
|
||||
return;
|
||||
} finally {
|
||||
setValidatingState(false);
|
||||
}
|
||||
}
|
||||
onChange([...emails, email]);
|
||||
setInput("");
|
||||
setError(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className={`flex flex-wrap gap-1.5 rounded-lg border bg-gray-50 px-3 py-2 min-h-[40px] transition-colors ${
|
||||
error
|
||||
? "border-red-300 focus-within:border-red-400"
|
||||
: "border-gray-200 focus-within:border-gray-400"
|
||||
}`}
|
||||
>
|
||||
{emails.map((email) => (
|
||||
<span
|
||||
key={email}
|
||||
className="inline-flex items-center gap-1 rounded-full bg-gray-200 px-2.5 py-0.5 text-xs text-gray-700"
|
||||
>
|
||||
{email}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(emails.filter((e) => e !== email))}
|
||||
className="text-gray-400 hover:text-gray-700 transition-colors"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
type="email"
|
||||
value={input}
|
||||
onChange={(e) => {
|
||||
setInput(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={addEmail}
|
||||
placeholder={emails.length === 0 ? placeholder : ""}
|
||||
className="flex-1 min-w-[160px] bg-transparent text-sm text-gray-700 placeholder:text-gray-400 outline-none"
|
||||
autoFocus={autoFocus}
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="mt-1.5 text-xs text-red-500">{error}</p>}
|
||||
{validating && <p className="mt-1.5 text-xs text-gray-400">Checking…</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -5,14 +5,15 @@ import {
|
|||
Check,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
File,
|
||||
FileText,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
Trash2,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import type { Document, Project } from "./types";
|
||||
import { VersionChip } from "./VersionChip";
|
||||
import { FileTypeIcon } from "./FileTypeIcon";
|
||||
import { SearchBar } from "@/components/ui/search-bar";
|
||||
|
||||
function formatDate(iso: string | null) {
|
||||
if (!iso) return null;
|
||||
|
|
@ -24,9 +25,7 @@ function formatDate(iso: string | null) {
|
|||
}
|
||||
|
||||
export function DocFileIcon({ fileType }: { fileType: string | null }) {
|
||||
if (fileType === "pdf")
|
||||
return <FileText className="h-3.5 w-3.5 text-red-500 shrink-0" />;
|
||||
return <File className="h-3.5 w-3.5 text-blue-500 shrink-0" />;
|
||||
return <FileTypeIcon fileType={fileType} className="h-3.5 w-3.5" />;
|
||||
}
|
||||
|
||||
interface FileDirectoryProps {
|
||||
|
|
@ -41,6 +40,10 @@ interface FileDirectoryProps {
|
|||
heading?: string;
|
||||
onDelete?: (ids: string[]) => void | Promise<void>;
|
||||
uploadingFilenames?: string[];
|
||||
searchable?: boolean;
|
||||
searchPlaceholder?: string;
|
||||
searchAutoFocus?: boolean;
|
||||
searchNoResultsMessage?: string;
|
||||
}
|
||||
|
||||
export function FileDirectory({
|
||||
|
|
@ -55,13 +58,52 @@ export function FileDirectory({
|
|||
heading = "Documents",
|
||||
onDelete,
|
||||
uploadingFilenames = [],
|
||||
searchable = false,
|
||||
searchPlaceholder = "Search...",
|
||||
searchAutoFocus = false,
|
||||
searchNoResultsMessage = "No matches found",
|
||||
}: FileDirectoryProps) {
|
||||
const [expandedProjects, setExpandedProjects] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const selectedCount = selectedIds.size;
|
||||
const q = search.trim().toLowerCase();
|
||||
const visibleStandaloneDocs = q
|
||||
? standaloneDocs.filter((doc) => doc.filename.toLowerCase().includes(q))
|
||||
: standaloneDocs;
|
||||
const visibleUploadingFilenames = q
|
||||
? uploadingFilenames.filter((filename) =>
|
||||
filename.toLowerCase().includes(q),
|
||||
)
|
||||
: uploadingFilenames;
|
||||
const visibleDirectoryProjects = q
|
||||
? directoryProjects
|
||||
.map((project) => {
|
||||
const docs = project.documents ?? [];
|
||||
const projectMatches =
|
||||
project.name.toLowerCase().includes(q) ||
|
||||
(project.cm_number ?? "").toLowerCase().includes(q);
|
||||
return {
|
||||
...project,
|
||||
documents: projectMatches
|
||||
? docs
|
||||
: docs.filter((doc) =>
|
||||
doc.filename.toLowerCase().includes(q),
|
||||
),
|
||||
};
|
||||
})
|
||||
.filter((project) => {
|
||||
const docs = project.documents ?? [];
|
||||
return (
|
||||
docs.length > 0 ||
|
||||
project.name.toLowerCase().includes(q) ||
|
||||
(project.cm_number ?? "").toLowerCase().includes(q)
|
||||
);
|
||||
})
|
||||
: directoryProjects;
|
||||
|
||||
async function handleDelete() {
|
||||
if (!onDelete || selectedCount === 0 || deleting) return;
|
||||
|
|
@ -83,8 +125,8 @@ export function FileDirectory({
|
|||
];
|
||||
|
||||
const allStandaloneSelected =
|
||||
standaloneDocs.length > 0 &&
|
||||
standaloneDocs.every((d) => selectedIds.has(d.id));
|
||||
visibleStandaloneDocs.length > 0 &&
|
||||
visibleStandaloneDocs.every((d) => selectedIds.has(d.id));
|
||||
|
||||
function toggle(docId: string) {
|
||||
if (!allowMultiple) {
|
||||
|
|
@ -92,18 +134,22 @@ export function FileDirectory({
|
|||
return;
|
||||
}
|
||||
const next = new Set(selectedIds);
|
||||
next.has(docId) ? next.delete(docId) : next.add(docId);
|
||||
if (next.has(docId)) {
|
||||
next.delete(docId);
|
||||
} else {
|
||||
next.add(docId);
|
||||
}
|
||||
onChange(next);
|
||||
}
|
||||
|
||||
function toggleAll() {
|
||||
if (allStandaloneSelected) {
|
||||
const next = new Set(selectedIds);
|
||||
standaloneDocs.forEach((d) => next.delete(d.id));
|
||||
visibleStandaloneDocs.forEach((d) => next.delete(d.id));
|
||||
onChange(next);
|
||||
} else {
|
||||
const next = new Set(selectedIds);
|
||||
standaloneDocs.forEach((d) => next.add(d.id));
|
||||
visibleStandaloneDocs.forEach((d) => next.add(d.id));
|
||||
onChange(next);
|
||||
}
|
||||
}
|
||||
|
|
@ -112,30 +158,43 @@ export function FileDirectory({
|
|||
if (forceExpanded) return;
|
||||
setExpandedProjects((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.has(projectId) ? next.delete(projectId) : next.add(projectId);
|
||||
if (next.has(projectId)) {
|
||||
next.delete(projectId);
|
||||
} else {
|
||||
next.add(projectId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="rounded-sm border border-gray-100 overflow-hidden">
|
||||
<div className="space-y-px">
|
||||
{searchable && (
|
||||
<SearchBar
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
placeholder={searchPlaceholder}
|
||||
autoFocus={searchAutoFocus}
|
||||
wrapperClassName="mb-2"
|
||||
/>
|
||||
)}
|
||||
{/* Documents header skeleton */}
|
||||
<div className="flex items-center justify-between px-2 py-2">
|
||||
<div className="h-3 w-20 rounded bg-gray-200 animate-pulse" />
|
||||
<div className="h-3 w-12 rounded bg-gray-200 animate-pulse" />
|
||||
<div className="flex items-center justify-between rounded-md px-2 py-2">
|
||||
<div className="h-3 w-20 rounded bg-gray-100 animate-pulse" />
|
||||
<div className="h-3 w-12 rounded bg-gray-100 animate-pulse" />
|
||||
</div>
|
||||
{/* File rows skeleton */}
|
||||
<div>
|
||||
{[60, 45, 75, 55, 40].map((w, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-2 px-2 py-2"
|
||||
className="flex items-center gap-2 rounded-md px-2 py-2"
|
||||
>
|
||||
<div className="h-3.5 w-3.5 rounded border border-gray-200 shrink-0" />
|
||||
<div className="h-3.5 w-3.5 rounded bg-gray-200 animate-pulse shrink-0" />
|
||||
<div className="h-3.5 w-3.5 rounded bg-gray-100 animate-pulse shrink-0" />
|
||||
<div
|
||||
className="h-3 rounded bg-gray-200 animate-pulse"
|
||||
className="h-3 rounded bg-gray-100 animate-pulse"
|
||||
style={{ width: `${w}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -151,215 +210,255 @@ export function FileDirectory({
|
|||
uploadingFilenames.length === 0
|
||||
) {
|
||||
return (
|
||||
<p className="text-center text-sm text-gray-400 py-8">
|
||||
{emptyMessage}
|
||||
</p>
|
||||
<div>
|
||||
{searchable && (
|
||||
<SearchBar
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
placeholder={searchPlaceholder}
|
||||
autoFocus={searchAutoFocus}
|
||||
wrapperClassName="mb-2"
|
||||
/>
|
||||
)}
|
||||
<p className="text-center text-sm text-gray-400 py-8">
|
||||
{emptyMessage}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-sm border border-gray-100 overflow-hidden">
|
||||
<div>
|
||||
{(standaloneDocs.length > 0 ||
|
||||
uploadingFilenames.length > 0 ||
|
||||
(onDelete && selectedCount > 0)) && (
|
||||
<div className="flex items-center justify-between px-2 py-2">
|
||||
<p className="text-xs font-medium text-gray-400">
|
||||
{heading}
|
||||
</p>
|
||||
<div className="flex items-center gap-3">
|
||||
{onDelete && selectedCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDelete}
|
||||
disabled={deleting}
|
||||
className="inline-flex items-center gap-1 text-xs text-red-500 hover:text-red-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
{standaloneDocs.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleAll}
|
||||
className="text-xs text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
{allStandaloneSelected
|
||||
? "Deselect all"
|
||||
: "Select all"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{uploadingFilenames.map((filename) => (
|
||||
<div
|
||||
key={`uploading-${filename}`}
|
||||
className="w-full flex items-center gap-2 px-2 py-2 text-xs text-left"
|
||||
>
|
||||
<span className="shrink-0 h-3.5 w-3.5 rounded border border-gray-300" />
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin text-gray-400 shrink-0" />
|
||||
<span className="flex-1 truncate text-gray-400">
|
||||
{filename}
|
||||
</span>
|
||||
<span className="shrink-0 text-gray-300">
|
||||
Uploading
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{standaloneDocs.map((doc) => {
|
||||
const selected = selectedIds.has(doc.id);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={doc.id}
|
||||
onClick={() => toggle(doc.id)}
|
||||
className={`w-full flex items-center gap-2 px-2 py-2 text-xs transition-colors text-left ${
|
||||
selected ? "bg-gray-100" : "hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`shrink-0 h-3.5 w-3.5 rounded border flex items-center justify-center ${
|
||||
selected
|
||||
? "bg-gray-900 border-gray-900"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
>
|
||||
{selected && (
|
||||
<Check className="h-2.5 w-2.5 text-white" />
|
||||
<div className="space-y-2 rounded-sm">
|
||||
{searchable && (
|
||||
<SearchBar
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
placeholder={searchPlaceholder}
|
||||
autoFocus={searchAutoFocus}
|
||||
/>
|
||||
)}
|
||||
{q &&
|
||||
visibleStandaloneDocs.length === 0 &&
|
||||
visibleDirectoryProjects.length === 0 &&
|
||||
visibleUploadingFilenames.length === 0 ? (
|
||||
<p className="text-center text-sm text-gray-400 py-8">
|
||||
{searchNoResultsMessage}
|
||||
</p>
|
||||
) : (
|
||||
<div>
|
||||
{(visibleStandaloneDocs.length > 0 ||
|
||||
visibleUploadingFilenames.length > 0 ||
|
||||
(onDelete && selectedCount > 0)) && (
|
||||
<div className="flex items-center justify-between px-2 py-2">
|
||||
<p className="text-xs font-medium text-gray-400">
|
||||
{heading}
|
||||
</p>
|
||||
<div className="flex items-center gap-3">
|
||||
{onDelete && selectedCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDelete}
|
||||
disabled={deleting}
|
||||
className="inline-flex items-center gap-1 text-xs text-red-500 hover:text-red-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
{visibleStandaloneDocs.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleAll}
|
||||
className="text-xs text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
{allStandaloneSelected
|
||||
? "Deselect all"
|
||||
: "Select all"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{visibleUploadingFilenames.map((filename) => (
|
||||
<div
|
||||
key={`uploading-${filename}`}
|
||||
className="w-full flex items-center gap-2 px-2 py-2 text-xs text-left"
|
||||
>
|
||||
<span className="shrink-0 h-3.5 w-3.5 rounded border border-gray-300" />
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin text-gray-400 shrink-0" />
|
||||
<span className="flex-1 truncate text-gray-400">
|
||||
{filename}
|
||||
</span>
|
||||
<DocFileIcon fileType={doc.file_type} />
|
||||
<span
|
||||
className={`flex-1 truncate ${
|
||||
selected ? "text-gray-900" : "text-gray-700"
|
||||
}`}
|
||||
>
|
||||
{doc.filename}
|
||||
<span className="shrink-0 text-gray-300">
|
||||
Uploading
|
||||
</span>
|
||||
<VersionChip
|
||||
n={
|
||||
doc.active_version_number ??
|
||||
doc.latest_version_number
|
||||
}
|
||||
/>
|
||||
{doc.created_at && (
|
||||
<span className="shrink-0 text-gray-300">
|
||||
{formatDate(doc.created_at)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{standaloneDocs.length > 0 && directoryProjects.length > 0 && (
|
||||
<div className="border-t border-gray-100 py-2 px-2">
|
||||
<p className="text-xs font-medium text-gray-400">
|
||||
Projects
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{directoryProjects.map((project) => {
|
||||
const isExpanded =
|
||||
forceExpanded || expandedProjects.has(project.id);
|
||||
const docs = project.documents ?? [];
|
||||
return (
|
||||
<div key={project.id}>
|
||||
</div>
|
||||
))}
|
||||
{visibleStandaloneDocs.map((doc) => {
|
||||
const selected = selectedIds.has(doc.id);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleFolder(project.id)}
|
||||
className="w-full flex items-center gap-2 px-2 py-2 text-xs hover:bg-gray-50 transition-colors text-left"
|
||||
key={doc.id}
|
||||
onClick={() => toggle(doc.id)}
|
||||
className={`w-full rounded-md flex items-center gap-2 px-2 py-2 text-xs transition-all text-left ${
|
||||
selected
|
||||
? "bg-gray-100"
|
||||
: "hover:bg-gray-100/70"
|
||||
}`}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-3 w-3 text-gray-400 shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="h-3 w-3 text-gray-400 shrink-0" />
|
||||
)}
|
||||
<Folder className="h-3.5 w-3.5 shrink-0 text-gray-400" />
|
||||
<span className="flex-1 truncate font-medium text-gray-700">
|
||||
{project.name}
|
||||
{project.cm_number && (
|
||||
<span className="ml-1 font-normal text-gray-400">
|
||||
(#{project.cm_number})
|
||||
</span>
|
||||
<span
|
||||
className={`shrink-0 h-3.5 w-3.5 rounded border flex items-center justify-center ${
|
||||
selected
|
||||
? "bg-gray-900 border-gray-900"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
>
|
||||
{selected && (
|
||||
<Check className="h-2.5 w-2.5 text-white" />
|
||||
)}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400 shrink-0">
|
||||
{docs.length}
|
||||
<DocFileIcon fileType={doc.file_type} />
|
||||
<span
|
||||
className={`flex-1 truncate ${
|
||||
selected
|
||||
? "text-gray-900"
|
||||
: "text-gray-700"
|
||||
}`}
|
||||
>
|
||||
{doc.filename}
|
||||
</span>
|
||||
<VersionChip
|
||||
n={
|
||||
doc.active_version_number ??
|
||||
doc.latest_version_number
|
||||
}
|
||||
/>
|
||||
{doc.created_at && (
|
||||
<span className="shrink-0 text-gray-300">
|
||||
{formatDate(doc.created_at)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<div>
|
||||
{docs.length === 0 ? (
|
||||
<p className="pl-7 py-1 text-xs text-gray-400">
|
||||
Empty
|
||||
</p>
|
||||
);
|
||||
})}
|
||||
|
||||
{visibleStandaloneDocs.length > 0 &&
|
||||
visibleDirectoryProjects.length > 0 && (
|
||||
<div className="py-2 px-2 mt-2">
|
||||
<p className="text-xs font-medium text-gray-400">
|
||||
Projects
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{visibleDirectoryProjects.map((project) => {
|
||||
const isExpanded =
|
||||
forceExpanded ||
|
||||
!!q ||
|
||||
expandedProjects.has(project.id);
|
||||
const docs = project.documents ?? [];
|
||||
return (
|
||||
<div key={project.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleFolder(project.id)}
|
||||
className="w-full rounded-md flex items-center gap-2 px-2 py-2 text-xs transition-all text-left hover:bg-gray-100/70"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-3 w-3 text-gray-400 shrink-0" />
|
||||
) : (
|
||||
docs.map((doc) => {
|
||||
const selected = selectedIds.has(
|
||||
doc.id,
|
||||
);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={doc.id}
|
||||
onClick={() =>
|
||||
toggle(doc.id)
|
||||
}
|
||||
className={`w-full flex items-center gap-2 pl-7 pr-2 py-2 text-xs transition-colors text-left ${
|
||||
selected
|
||||
? "bg-gray-100"
|
||||
: "hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`shrink-0 h-3.5 w-3.5 rounded border flex items-center justify-center ${
|
||||
selected
|
||||
? "bg-gray-900 border-gray-900"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
>
|
||||
{selected && (
|
||||
<Check className="h-2.5 w-2.5 text-white" />
|
||||
)}
|
||||
</span>
|
||||
<DocFileIcon
|
||||
fileType={doc.file_type}
|
||||
/>
|
||||
<span
|
||||
className={`flex-1 truncate min-w-0 ${
|
||||
selected
|
||||
? "text-gray-900 font-medium"
|
||||
: "text-gray-700"
|
||||
}`}
|
||||
>
|
||||
{doc.filename}
|
||||
</span>
|
||||
<VersionChip
|
||||
n={
|
||||
doc.active_version_number ??
|
||||
doc.latest_version_number
|
||||
<ChevronRight className="h-3 w-3 text-gray-400 shrink-0" />
|
||||
)}
|
||||
{isExpanded ? (
|
||||
<FolderOpen className="h-3.5 w-3.5 shrink-0 text-gray-400" />
|
||||
) : (
|
||||
<Folder className="h-3.5 w-3.5 shrink-0 text-gray-400" />
|
||||
)}
|
||||
<span className="flex-1 truncate font-medium text-gray-700">
|
||||
{project.name}
|
||||
{project.cm_number && (
|
||||
<span className="ml-1 font-normal text-gray-400">
|
||||
(#{project.cm_number})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400 shrink-0">
|
||||
{docs.length}
|
||||
</span>
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<div>
|
||||
{docs.length === 0 ? (
|
||||
<p className="pl-7 py-1 text-xs text-gray-400">
|
||||
Empty
|
||||
</p>
|
||||
) : (
|
||||
docs.map((doc) => {
|
||||
const selected =
|
||||
selectedIds.has(doc.id);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={doc.id}
|
||||
onClick={() =>
|
||||
toggle(doc.id)
|
||||
}
|
||||
/>
|
||||
{doc.created_at && (
|
||||
<span className="shrink-0 text-gray-300">
|
||||
{formatDate(
|
||||
doc.created_at,
|
||||
className={`w-full rounded-md flex items-center gap-2 pl-7 pr-2 py-2 text-xs transition-all text-left ${
|
||||
selected
|
||||
? "bg-gray-100"
|
||||
: "hover:bg-gray-100/70"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`shrink-0 h-3.5 w-3.5 rounded border flex items-center justify-center ${
|
||||
selected
|
||||
? "bg-gray-900 border-gray-900"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
>
|
||||
{selected && (
|
||||
<Check className="h-2.5 w-2.5 text-white" />
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<DocFileIcon
|
||||
fileType={
|
||||
doc.file_type
|
||||
}
|
||||
/>
|
||||
<span
|
||||
className={`flex-1 truncate min-w-0 ${
|
||||
selected
|
||||
? "text-gray-900"
|
||||
: "text-gray-700"
|
||||
}`}
|
||||
>
|
||||
{doc.filename}
|
||||
</span>
|
||||
<VersionChip
|
||||
n={
|
||||
doc.active_version_number ??
|
||||
doc.latest_version_number
|
||||
}
|
||||
/>
|
||||
{doc.created_at && (
|
||||
<span className="shrink-0 text-gray-300">
|
||||
{formatDate(
|
||||
doc.created_at,
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
49
frontend/src/app/components/shared/FileTypeIcon.tsx
Normal file
49
frontend/src/app/components/shared/FileTypeIcon.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { File, FileChartPie, FileSpreadsheet, FileText } from "lucide-react";
|
||||
|
||||
export type FileTypeKind = "pdf" | "word" | "excel" | "ppt" | "other";
|
||||
|
||||
/**
|
||||
* Normalize a file_type value (e.g. "pdf") or a filename (e.g. "deck.pptx")
|
||||
* into a coarse kind used to pick an icon. Accepts both because some call
|
||||
* sites only have the filename (user-message files, citations) while others
|
||||
* carry the document's `file_type` field.
|
||||
*/
|
||||
export function fileTypeKind(value: string | null | undefined): FileTypeKind {
|
||||
const raw = (value ?? "").toLowerCase().trim();
|
||||
const ext = raw.includes(".") ? (raw.split(".").pop() ?? "") : raw;
|
||||
if (ext === "pdf") return "pdf";
|
||||
if (ext === "docx" || ext === "doc") return "word";
|
||||
if (ext === "xlsx" || ext === "xlsm" || ext === "xls") return "excel";
|
||||
if (ext === "pptx" || ext === "ppt") return "ppt";
|
||||
return "other";
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical document file-type icon. Size and any extra classes come from
|
||||
* `className`; `shrink-0` is always applied. `muted` renders a neutral grey
|
||||
* placeholder (used for loading/disabled rows).
|
||||
*/
|
||||
export function FileTypeIcon({
|
||||
fileType,
|
||||
className = "h-3.5 w-3.5",
|
||||
muted = false,
|
||||
}: {
|
||||
fileType: string | null | undefined;
|
||||
className?: string;
|
||||
muted?: boolean;
|
||||
}) {
|
||||
const cls = `${className} shrink-0`;
|
||||
if (muted) return <File className={`${cls} text-gray-300`} />;
|
||||
switch (fileTypeKind(fileType)) {
|
||||
case "pdf":
|
||||
return <FileText className={`${cls} text-red-500`} />;
|
||||
case "word":
|
||||
return <File className={`${cls} text-blue-500`} />;
|
||||
case "excel":
|
||||
return <FileSpreadsheet className={`${cls} text-emerald-500`} />;
|
||||
case "ppt":
|
||||
return <FileChartPie className={`${cls} text-red-500`} />;
|
||||
default:
|
||||
return <File className={`${cls} text-gray-500`} />;
|
||||
}
|
||||
}
|
||||
|
|
@ -4,7 +4,7 @@ import { useEffect, useState, type ReactNode } from "react";
|
|||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useUserProfile } from "@/contexts/UserProfileContext";
|
||||
import { needsMfaVerification } from "./MfaVerificationPopup";
|
||||
import { needsMfaVerification } from "../popups/MfaVerificationPopup";
|
||||
|
||||
type GateState = "idle" | "checking" | "required" | "verified";
|
||||
const MFA_VERIFIED_AT_KEY = "mike:mfa-verified-at";
|
||||
|
|
|
|||
|
|
@ -1,294 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ClipboardEvent,
|
||||
type KeyboardEvent,
|
||||
} from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { Modal } from "@/app/components/shared/Modal";
|
||||
|
||||
type MfaFactor = {
|
||||
id: string;
|
||||
friendly_name?: string | null;
|
||||
factor_type: string;
|
||||
};
|
||||
|
||||
const isDev = process.env.NODE_ENV !== "production";
|
||||
const devLog = (...args: Parameters<typeof console.log>) => {
|
||||
if (isDev) console.log(...args);
|
||||
};
|
||||
|
||||
export async function needsMfaVerification() {
|
||||
const { data, error } =
|
||||
await supabase.auth.mfa.getAuthenticatorAssuranceLevel();
|
||||
if (error) throw error;
|
||||
return data.nextLevel === "aal2" && data.currentLevel !== "aal2";
|
||||
}
|
||||
|
||||
interface MfaVerificationPopupProps {
|
||||
open: boolean;
|
||||
onCancel: () => void;
|
||||
onVerified: () => void;
|
||||
title?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export function MfaVerificationPopup({
|
||||
open,
|
||||
onCancel,
|
||||
onVerified,
|
||||
title = "Two-factor verification required",
|
||||
message = "Enter a code from your authenticator app to continue.",
|
||||
}: MfaVerificationPopupProps) {
|
||||
const [factors, setFactors] = useState<MfaFactor[]>([]);
|
||||
const [selectedFactorId, setSelectedFactorId] = useState("");
|
||||
const [code, setCode] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [verifying, setVerifying] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const canVerify =
|
||||
!verifying &&
|
||||
!loading &&
|
||||
!!selectedFactorId &&
|
||||
code.trim().length === 6;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let cancelled = false;
|
||||
devLog("[mfa-popup] opened");
|
||||
|
||||
async function loadFactors() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setCode("");
|
||||
const { data, error: listError } =
|
||||
await supabase.auth.mfa.listFactors();
|
||||
if (cancelled) return;
|
||||
if (listError) {
|
||||
devLog("[mfa-popup] list factors failed", {
|
||||
error: listError.message,
|
||||
});
|
||||
setError(listError.message);
|
||||
setFactors([]);
|
||||
setSelectedFactorId("");
|
||||
} else {
|
||||
const verified = (data.totp ?? []) as MfaFactor[];
|
||||
devLog("[mfa-popup] factors loaded", {
|
||||
totpCount: verified.length,
|
||||
selectedFactorId: verified[0]?.id ?? null,
|
||||
});
|
||||
setFactors(verified);
|
||||
setSelectedFactorId(verified[0]?.id ?? "");
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
void loadFactors();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
async function verify() {
|
||||
if (!canVerify) return;
|
||||
|
||||
setVerifying(true);
|
||||
setError(null);
|
||||
devLog("[mfa-popup] verifying code", { factorId: selectedFactorId });
|
||||
const { error: verifyError } =
|
||||
await supabase.auth.mfa.challengeAndVerify({
|
||||
factorId: selectedFactorId,
|
||||
code: code.trim(),
|
||||
});
|
||||
setVerifying(false);
|
||||
|
||||
if (verifyError) {
|
||||
devLog("[mfa-popup] verification failed", {
|
||||
error: verifyError.message,
|
||||
});
|
||||
setError(verifyError.message);
|
||||
return;
|
||||
}
|
||||
|
||||
devLog("[mfa-popup] verification succeeded");
|
||||
setCode("");
|
||||
onVerified();
|
||||
}
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onCancel}
|
||||
title={title}
|
||||
size="sm"
|
||||
className="h-auto min-h-[310px] max-h-[min(92vh,400px)]"
|
||||
cancelAction={{
|
||||
label: "Cancel",
|
||||
onClick: onCancel,
|
||||
disabled: verifying,
|
||||
}}
|
||||
primaryAction={{
|
||||
label: verifying ? (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
Verifying...
|
||||
</span>
|
||||
) : (
|
||||
"Verify"
|
||||
),
|
||||
onClick: () => void verify(),
|
||||
disabled: !canVerify,
|
||||
}}
|
||||
>
|
||||
<div className="space-y-5 pb-2 pt-0">
|
||||
<p className="text-sm text-gray-500 pb-6">{message}</p>
|
||||
{loading ? (
|
||||
<div className="flex h-13 items-center justify-center text-sm text-gray-500">
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Loading authenticator...
|
||||
</div>
|
||||
) : factors.length === 0 ? (
|
||||
<p className="rounded-lg bg-gray-100 px-3 py-2 text-sm text-gray-600">
|
||||
No verified authenticator factor is available for this
|
||||
session.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{factors.length > 1 && (
|
||||
<select
|
||||
value={selectedFactorId}
|
||||
onChange={(event) =>
|
||||
setSelectedFactorId(event.target.value)
|
||||
}
|
||||
className="h-9 w-full rounded-lg bg-gray-100 px-3 text-sm text-gray-900 outline-none focus-visible:ring-2 focus-visible:ring-gray-300/45"
|
||||
>
|
||||
{factors.map((factor) => (
|
||||
<option key={factor.id} value={factor.id}>
|
||||
{factor.friendly_name ||
|
||||
"Authenticator app"}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<VerificationCodeInput
|
||||
value={code}
|
||||
onChange={setCode}
|
||||
disabled={verifying}
|
||||
autoFocus={open && !loading}
|
||||
onSubmit={() => void verify()}
|
||||
canSubmit={canVerify}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{error && <p className="text-xs text-red-600">{error}</p>}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export function VerificationCodeInput({
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
autoFocus,
|
||||
onSubmit,
|
||||
canSubmit,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
autoFocus?: boolean;
|
||||
onSubmit?: () => void;
|
||||
canSubmit?: boolean;
|
||||
}) {
|
||||
const inputsRef = useRef<Array<HTMLInputElement | null>>([]);
|
||||
const digits = Array.from({ length: 6 }, (_, index) => value[index] ?? "");
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoFocus || disabled) return;
|
||||
const focusTimer = window.setTimeout(() => {
|
||||
const firstEmptyIndex = digits.findIndex((digit) => !digit);
|
||||
inputsRef.current[
|
||||
firstEmptyIndex === -1 ? 0 : firstEmptyIndex
|
||||
]?.focus();
|
||||
}, 0);
|
||||
return () => window.clearTimeout(focusTimer);
|
||||
}, [autoFocus, disabled]);
|
||||
|
||||
function updateDigit(index: number, nextValue: string) {
|
||||
const digit = nextValue.replace(/\D/g, "").slice(-1);
|
||||
const nextDigits = [...digits];
|
||||
nextDigits[index] = digit;
|
||||
onChange(nextDigits.join(""));
|
||||
if (digit && index < inputsRef.current.length - 1) {
|
||||
inputsRef.current[index + 1]?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function handlePaste(event: ClipboardEvent<HTMLInputElement>) {
|
||||
event.preventDefault();
|
||||
const pasted = event.clipboardData
|
||||
.getData("text")
|
||||
.replace(/\D/g, "")
|
||||
.slice(0, 6);
|
||||
if (!pasted) return;
|
||||
onChange(pasted);
|
||||
inputsRef.current[Math.min(pasted.length, 6) - 1]?.focus();
|
||||
}
|
||||
|
||||
function handleKeyDown(
|
||||
event: KeyboardEvent<HTMLInputElement>,
|
||||
index: number,
|
||||
) {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
if (canSubmit) onSubmit?.();
|
||||
return;
|
||||
}
|
||||
if (event.key === "Backspace" && !digits[index] && index > 0) {
|
||||
inputsRef.current[index - 1]?.focus();
|
||||
}
|
||||
if (event.key === "ArrowLeft" && index > 0) {
|
||||
event.preventDefault();
|
||||
inputsRef.current[index - 1]?.focus();
|
||||
}
|
||||
if (event.key === "ArrowRight" && index < digits.length - 1) {
|
||||
event.preventDefault();
|
||||
inputsRef.current[index + 1]?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex justify-center gap-2"
|
||||
role="group"
|
||||
aria-label="Six digit verification code"
|
||||
>
|
||||
{digits.map((digit, index) => (
|
||||
<input
|
||||
key={index}
|
||||
ref={(element) => {
|
||||
inputsRef.current[index] = element;
|
||||
}}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete={index === 0 ? "one-time-code" : "off"}
|
||||
value={digit}
|
||||
disabled={disabled}
|
||||
onChange={(event) => updateDigit(index, event.target.value)}
|
||||
onPaste={handlePaste}
|
||||
onKeyDown={(event) => handleKeyDown(event, index)}
|
||||
className="h-13 w-12 rounded-lg border border-gray-300 bg-gray-50 text-center text-2xl font-medium font-serif text-gray-950 shadow-none outline-none transition-colors focus:border-gray-400 focus:ring-2 focus:ring-gray-300/45 disabled:cursor-not-allowed disabled:opacity-45"
|
||||
aria-label={`Verification code digit ${index + 1}`}
|
||||
maxLength={1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,208 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { createPortal } from "react-dom";
|
||||
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ModalSize = "sm" | "md" | "lg" | "xl";
|
||||
type ModalAction = Omit<
|
||||
ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
"className"
|
||||
> & {
|
||||
label: ReactNode;
|
||||
icon?: ReactNode;
|
||||
variant?: "primary" | "secondary" | "danger";
|
||||
};
|
||||
|
||||
interface ModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
breadcrumbs?: ReactNode[];
|
||||
title?: ReactNode;
|
||||
icon?: ReactNode;
|
||||
headerAction?: ReactNode;
|
||||
size?: ModalSize;
|
||||
className?: string;
|
||||
footerInfo?: ReactNode;
|
||||
footerStatus?: ReactNode;
|
||||
primaryAction?: ModalAction;
|
||||
secondaryAction?: ModalAction;
|
||||
cancelAction?: ModalAction | false;
|
||||
}
|
||||
|
||||
const sizeClassName: Record<ModalSize, string> = {
|
||||
sm: "max-w-md",
|
||||
md: "max-w-xl",
|
||||
lg: "max-w-2xl",
|
||||
xl: "max-w-4xl",
|
||||
};
|
||||
|
||||
export function Modal({
|
||||
open,
|
||||
onClose,
|
||||
children,
|
||||
breadcrumbs,
|
||||
title,
|
||||
icon,
|
||||
headerAction,
|
||||
size = "lg",
|
||||
className,
|
||||
footerInfo,
|
||||
footerStatus,
|
||||
primaryAction,
|
||||
secondaryAction,
|
||||
cancelAction,
|
||||
}: ModalProps) {
|
||||
const hasHeader = breadcrumbs?.length || title || icon;
|
||||
const hasFooter =
|
||||
footerInfo ||
|
||||
footerStatus ||
|
||||
primaryAction ||
|
||||
secondaryAction ||
|
||||
cancelAction;
|
||||
const resolvedCancelAction =
|
||||
cancelAction === undefined && primaryAction
|
||||
? { label: "Cancel", onClick: onClose }
|
||||
: cancelAction;
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className={cn(
|
||||
"fixed inset-0 z-[200] flex items-center justify-center px-4",
|
||||
"bg-white/30 backdrop-blur-[2px]",
|
||||
)}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"w-full rounded-3xl flex h-[600px] flex-col",
|
||||
sizeClassName[size],
|
||||
"border border-white/70 bg-white/94 shadow-[0_12px_36px_rgba(15,23,42,0.1)] backdrop-blur-2xl",
|
||||
className,
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{hasHeader && (
|
||||
<div className="flex items-start justify-between gap-3 px-4 py-4">
|
||||
{breadcrumbs?.length ? (
|
||||
<div className="flex min-w-0 flex-1 items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-1.5 text-xs text-gray-400">
|
||||
{breadcrumbs.map((segment, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
{index > 0 && <span>›</span>}
|
||||
<span className="truncate">
|
||||
{segment}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{headerAction}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-w-0 flex-1 items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{icon}
|
||||
<h2 className="truncate text-base font-medium text-gray-900">
|
||||
{title}
|
||||
</h2>
|
||||
</div>
|
||||
{headerAction}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="shrink-0 text-gray-400 transition-colors hover:text-gray-600"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto px-4">
|
||||
{children}
|
||||
</div>
|
||||
{hasFooter && (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-3 p-3",
|
||||
secondaryAction || footerInfo
|
||||
? "justify-between"
|
||||
: "justify-end",
|
||||
"border-t border-white/60",
|
||||
)}
|
||||
>
|
||||
{(secondaryAction || footerInfo) && (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{secondaryAction && (
|
||||
<ModalActionButton
|
||||
action={secondaryAction}
|
||||
fallbackVariant="secondary"
|
||||
/>
|
||||
)}
|
||||
{footerInfo}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
{footerStatus}
|
||||
{resolvedCancelAction && (
|
||||
<ModalActionButton
|
||||
action={resolvedCancelAction}
|
||||
fallbackVariant="cancel"
|
||||
/>
|
||||
)}
|
||||
{primaryAction && (
|
||||
<ModalActionButton
|
||||
action={primaryAction}
|
||||
fallbackVariant="primary"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
function ModalActionButton({
|
||||
action,
|
||||
fallbackVariant,
|
||||
}: {
|
||||
action: ModalAction;
|
||||
fallbackVariant: "primary" | "secondary" | "danger" | "cancel";
|
||||
}) {
|
||||
const {
|
||||
label,
|
||||
icon,
|
||||
variant = fallbackVariant === "cancel" ? "secondary" : fallbackVariant,
|
||||
...props
|
||||
} = action;
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center gap-1.5 px-4 py-1.5 text-sm font-medium transition-all disabled:cursor-not-allowed disabled:opacity-40",
|
||||
variant === "primary" &&
|
||||
"rounded-full border border-gray-700/40 bg-gray-950/88 text-white shadow-[0_3px_9px_rgba(15,23,42,0.16),inset_0_1px_0_rgba(255,255,255,0.22),inset_0_-4px_9px_rgba(15,23,42,0.2)] backdrop-blur-xl hover:bg-gray-900/90 active:scale-[0.98] disabled:active:scale-100",
|
||||
variant === "secondary" && "text-gray-600 hover:text-gray-950",
|
||||
fallbackVariant === "secondary" &&
|
||||
variant === "secondary" &&
|
||||
"rounded-full border border-blue-500/35 bg-blue-600/90 text-white shadow-[0_3px_9px_rgba(37,99,235,0.16),inset_0_1px_0_rgba(255,255,255,0.28),inset_0_-4px_9px_rgba(29,78,216,0.2)] backdrop-blur-xl hover:bg-blue-600 hover:text-white active:scale-[0.98] disabled:active:scale-100",
|
||||
variant === "danger" &&
|
||||
"px-1 text-red-600 hover:text-red-700 active:scale-[0.98] disabled:active:scale-100",
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { Lock } from "lucide-react";
|
||||
import { WarningPopup } from "./WarningPopup";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
/** Short headline above the body, e.g. "Owner-only action". */
|
||||
title?: string;
|
||||
/** Sentence describing what the user tried to do. */
|
||||
action?: string;
|
||||
/** Email of the project/resource owner, shown so the user knows who to ask. */
|
||||
ownerEmail?: string | null;
|
||||
/** Override the default message entirely. */
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight "you don't have permission" modal shown when a non-owner
|
||||
* attempts an owner-only action (manage people, rename, delete, …) on a
|
||||
* shared project. Replaces the silent 404 the backend would otherwise
|
||||
* return so the user understands why the action didn't go through.
|
||||
*/
|
||||
export function OwnerOnlyModal({
|
||||
open,
|
||||
onClose,
|
||||
title = "Owner-only action",
|
||||
action,
|
||||
ownerEmail,
|
||||
message,
|
||||
}: Props) {
|
||||
if (!open) return null;
|
||||
|
||||
const body =
|
||||
message ??
|
||||
(action
|
||||
? `Only the project owner can ${action}.`
|
||||
: "Only the project owner can perform this action.");
|
||||
|
||||
return (
|
||||
<WarningPopup
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={title}
|
||||
message={body}
|
||||
icon={<Lock className="h-3.5 w-3.5 shrink-0 text-red-600" />}
|
||||
>
|
||||
{ownerEmail && (
|
||||
<p className="mt-1 text-xs text-gray-600">
|
||||
Ask <span className="text-gray-600">{ownerEmail}</span> if
|
||||
you need access.
|
||||
</p>
|
||||
)}
|
||||
</WarningPopup>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,362 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { User, UserPlus, Loader2, Plus } from "lucide-react";
|
||||
import type { ProjectPeople } from "@/app/lib/mikeApi";
|
||||
import { Modal } from "./Modal";
|
||||
|
||||
/**
|
||||
* Any resource the modal can manage members for — projects today, tabular
|
||||
* reviews now, anything else with a `shared_with` email list later.
|
||||
*/
|
||||
export interface SharedResource {
|
||||
id: string;
|
||||
shared_with?: string[] | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
/** The thing being shared (project, review, …). */
|
||||
resource: SharedResource | null;
|
||||
/**
|
||||
* Resolve the owner + members roster for the given resource. Different
|
||||
* resource types hit different endpoints (`/projects/:id/people`,
|
||||
* `/tabular-review/:id/people`, …) so the caller passes the appropriate
|
||||
* fetcher.
|
||||
*/
|
||||
fetchPeople: (id: string) => Promise<ProjectPeople>;
|
||||
/** Currently signed-in user's email — gets the "You" tag if it matches. */
|
||||
currentUserEmail?: string | null;
|
||||
breadcrumb: string[];
|
||||
/**
|
||||
* Persist a new shared_with list. Parent should PATCH the resource and
|
||||
* sync its local state on success. Throw to surface an error inline.
|
||||
*/
|
||||
onSharedWithChange?: (sharedWith: string[]) => Promise<void> | void;
|
||||
}
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
type RosterRow = {
|
||||
email: string;
|
||||
display_name: string | null;
|
||||
role: "owner" | "member";
|
||||
};
|
||||
|
||||
/**
|
||||
* Roster of every Mike member with access to the project, with controls to
|
||||
* add/remove members. Mirrors AddDocumentsModal's frame.
|
||||
*/
|
||||
export function PeopleModal({
|
||||
open,
|
||||
onClose,
|
||||
resource,
|
||||
fetchPeople,
|
||||
currentUserEmail,
|
||||
breadcrumb,
|
||||
onSharedWithChange,
|
||||
}: Props) {
|
||||
const [newEmail, setNewEmail] = useState("");
|
||||
const [busy, setBusy] = useState<"add" | "remove" | null>(null);
|
||||
const [removingEmail, setRemovingEmail] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Server-resolved roster: owner email/display_name + members'
|
||||
// display_names. We keep `resource.shared_with` as the source of truth
|
||||
// for membership and just merge display_names from this fetch.
|
||||
const [people, setPeople] = useState<ProjectPeople | null>(null);
|
||||
const [peopleLoading, setPeopleLoading] = useState(false);
|
||||
|
||||
const resourceId = resource?.id ?? null;
|
||||
const sharedWith: string[] = Array.isArray(resource?.shared_with)
|
||||
? (resource!.shared_with as string[])
|
||||
: [];
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setNewEmail("");
|
||||
setError(null);
|
||||
setBusy(null);
|
||||
setRemovingEmail(null);
|
||||
}, [open]);
|
||||
|
||||
// Re-fetch roster whenever the modal opens or membership changes —
|
||||
// keyed by the joined shared_with list so add/remove triggers a refresh.
|
||||
const sharedKey = sharedWith
|
||||
.map((e) => e.toLowerCase())
|
||||
.sort()
|
||||
.join(",");
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !resourceId) return;
|
||||
let cancelled = false;
|
||||
setPeopleLoading(true);
|
||||
fetchPeople(resourceId)
|
||||
.then((data) => {
|
||||
if (cancelled) return;
|
||||
setPeople(data);
|
||||
})
|
||||
.catch(() => {
|
||||
/* keep stale data; modal still works on emails alone */
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setPeopleLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, resourceId, sharedKey, fetchPeople]);
|
||||
|
||||
if (!open || !resource) return null;
|
||||
|
||||
const memberDisplayByEmail = new Map<string, string | null>();
|
||||
for (const m of people?.members ?? []) {
|
||||
memberDisplayByEmail.set(m.email.toLowerCase(), m.display_name);
|
||||
}
|
||||
const ownerEmail = people?.owner.email ?? null;
|
||||
const ownerDisplayName = people?.owner.display_name ?? null;
|
||||
|
||||
const roster: RosterRow[] = [];
|
||||
if (ownerEmail) {
|
||||
roster.push({
|
||||
email: ownerEmail,
|
||||
display_name: ownerDisplayName,
|
||||
role: "owner",
|
||||
});
|
||||
}
|
||||
for (const email of sharedWith) {
|
||||
const lower = email.toLowerCase();
|
||||
if (ownerEmail && lower === ownerEmail.toLowerCase()) continue;
|
||||
roster.push({
|
||||
email,
|
||||
display_name: memberDisplayByEmail.get(lower) ?? null,
|
||||
role: "member",
|
||||
});
|
||||
}
|
||||
|
||||
const trimmedNewEmail = newEmail.trim().toLowerCase();
|
||||
const normalizedCurrentUserEmail =
|
||||
currentUserEmail?.trim().toLowerCase() ?? null;
|
||||
const isValidEmail = EMAIL_RE.test(trimmedNewEmail);
|
||||
const sharedLower = sharedWith.map((e) => e.toLowerCase());
|
||||
const alreadyShared = sharedLower.includes(trimmedNewEmail);
|
||||
const isOwnerEmail =
|
||||
!!ownerEmail && trimmedNewEmail === ownerEmail.toLowerCase();
|
||||
const isSelfEmail =
|
||||
!!normalizedCurrentUserEmail &&
|
||||
trimmedNewEmail === normalizedCurrentUserEmail;
|
||||
const canAdd =
|
||||
isValidEmail &&
|
||||
!alreadyShared &&
|
||||
!isOwnerEmail &&
|
||||
!isSelfEmail &&
|
||||
busy === null;
|
||||
|
||||
async function handleAdd() {
|
||||
if (!canAdd || !onSharedWithChange) return;
|
||||
setBusy("add");
|
||||
setError(null);
|
||||
try {
|
||||
const next = [...sharedWith, trimmedNewEmail];
|
||||
await onSharedWithChange(next);
|
||||
setNewEmail("");
|
||||
} catch (e) {
|
||||
setError(
|
||||
e instanceof Error
|
||||
? e.message
|
||||
: "Couldn't add the member. Try again.",
|
||||
);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemove(email: string) {
|
||||
if (!onSharedWithChange || busy !== null) return;
|
||||
setBusy("remove");
|
||||
setRemovingEmail(email);
|
||||
setError(null);
|
||||
try {
|
||||
const next = sharedWith.filter(
|
||||
(e) => e.toLowerCase() !== email.toLowerCase(),
|
||||
);
|
||||
await onSharedWithChange(next);
|
||||
} catch (e) {
|
||||
setError(
|
||||
e instanceof Error
|
||||
? e.message
|
||||
: "Couldn't remove the member. Try again.",
|
||||
);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
setRemovingEmail(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
breadcrumbs={breadcrumb}
|
||||
footerInfo={
|
||||
roster.length === 0
|
||||
? "No one has access yet."
|
||||
: `${roster.length} ${
|
||||
roster.length === 1 ? "person" : "people"
|
||||
} with access.`
|
||||
}
|
||||
>
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-6">
|
||||
{/* Add-member row */}
|
||||
{onSharedWithChange && (
|
||||
<section className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-1 items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-3 py-2">
|
||||
<UserPlus className="h-3.5 w-3.5 text-gray-400 shrink-0" />
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Add by email…"
|
||||
value={newEmail}
|
||||
onChange={(e) =>
|
||||
setNewEmail(e.target.value)
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void handleAdd();
|
||||
}}
|
||||
className="flex-1 bg-transparent text-sm text-gray-700 placeholder:text-gray-400 outline-none"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => void handleAdd()}
|
||||
disabled={!canAdd}
|
||||
title="Add member"
|
||||
className="inline-flex items-center justify-center rounded-lg border border-gray-900 bg-gray-900 p-2 text-white hover:bg-gray-800 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{busy === "add" ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{alreadyShared && trimmedNewEmail && (
|
||||
<p className="mt-1.5 text-xs text-gray-400">
|
||||
{trimmedNewEmail} already has access.
|
||||
</p>
|
||||
)}
|
||||
{isOwnerEmail && trimmedNewEmail && (
|
||||
<p className="mt-1.5 text-xs text-gray-400">
|
||||
{trimmedNewEmail} is the owner.
|
||||
</p>
|
||||
)}
|
||||
{isSelfEmail && !isOwnerEmail && trimmedNewEmail && (
|
||||
<p className="mt-1.5 text-xs text-gray-400">
|
||||
You cannot share this with yourself.
|
||||
</p>
|
||||
)}
|
||||
{trimmedNewEmail &&
|
||||
!isValidEmail &&
|
||||
!alreadyShared &&
|
||||
!isOwnerEmail &&
|
||||
!isSelfEmail && (
|
||||
<p className="mt-1.5 text-xs text-gray-400">
|
||||
Enter a valid email.
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<p className="mt-1.5 text-xs text-red-500">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="min-h-0 flex-1">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<h3 className="text-xs font-medium text-gray-500">
|
||||
People with Access
|
||||
</h3>
|
||||
{peopleLoading && (
|
||||
<Loader2 className="h-3 w-3 animate-spin text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Member list */}
|
||||
{roster.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center text-sm text-gray-400">
|
||||
No one has access yet.
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-gray-100 [&>li:nth-child(2)]:border-t-0">
|
||||
{roster.map((entry) => {
|
||||
const isYou =
|
||||
!!currentUserEmail &&
|
||||
entry.email.toLowerCase() ===
|
||||
currentUserEmail.toLowerCase();
|
||||
const isRemoving =
|
||||
busy === "remove" &&
|
||||
removingEmail === entry.email;
|
||||
const primary =
|
||||
entry.display_name?.trim() || entry.email;
|
||||
const showSecondary =
|
||||
!!entry.display_name?.trim() &&
|
||||
primary !== entry.email;
|
||||
return (
|
||||
<li
|
||||
key={`${entry.role}-${entry.email}`}
|
||||
className="flex items-center gap-3 py-3"
|
||||
>
|
||||
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-gray-900 text-white">
|
||||
<User className="h-3 w-3" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm text-gray-800">
|
||||
{primary}
|
||||
{isYou && (
|
||||
<span className="ml-1.5 text-xs text-gray-400">
|
||||
(You)
|
||||
</span>
|
||||
)}
|
||||
{entry.role === "owner" && (
|
||||
<span className="ml-1.5 text-[10px] text-gray-400">
|
||||
Owner
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
{showSecondary && (
|
||||
<p className="truncate text-xs text-gray-400">
|
||||
{entry.email}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{entry.role === "member" &&
|
||||
onSharedWithChange && (
|
||||
<button
|
||||
onClick={() =>
|
||||
void handleRemove(
|
||||
entry.email,
|
||||
)
|
||||
}
|
||||
disabled={busy !== null}
|
||||
title="Remove access"
|
||||
className="self-center inline-flex items-center gap-1 rounded px-2 py-1 text-xs text-gray-500 hover:bg-red-50 hover:text-red-600 disabled:opacity-50"
|
||||
>
|
||||
{isRemoving && (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
)}
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,74 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
|
||||
export function PreResponseWrapper({
|
||||
children,
|
||||
stepCount,
|
||||
shouldMinimize,
|
||||
isStreaming,
|
||||
compact = false,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
stepCount: number;
|
||||
shouldMinimize: boolean;
|
||||
isStreaming: boolean;
|
||||
/** Tighter typography + child gap for narrow side panels (e.g. TR chat). */
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const [userToggled, setUserToggled] = useState(false);
|
||||
const [isOpen, setIsOpen] = useState(!shouldMinimize);
|
||||
// Once content has streamed in (shouldMinimize=true even once), stay
|
||||
// minimized even if a later render briefly evaluates shouldMinimize=false.
|
||||
// Without this latch, the wrapper visibly pops open when isStreaming
|
||||
// flips off at the end of the response.
|
||||
const hasMinimizedRef = useRef(shouldMinimize);
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldMinimize) hasMinimizedRef.current = true;
|
||||
if (userToggled) return;
|
||||
setIsOpen(!shouldMinimize && !hasMinimizedRef.current);
|
||||
}, [shouldMinimize, userToggled]);
|
||||
|
||||
const stepWord = `step${stepCount === 1 ? "" : "s"}`;
|
||||
const label = isStreaming
|
||||
? "Working"
|
||||
: `Completed in ${stepCount} ${stepWord}`;
|
||||
|
||||
const buttonTextClass = compact ? "text-xs" : "text-sm";
|
||||
const childrenGapClass = compact ? "gap-2.5" : "gap-4";
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-white/70 bg-white/55 px-3 py-2 shadow-[0_3px_9px_rgba(15,23,42,0.03),inset_0_1px_0_rgba(255,255,255,0.9),inset_0_-4px_9px_rgba(255,255,255,0.05)] backdrop-blur-2xl">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setUserToggled(true);
|
||||
setIsOpen((v) => !v);
|
||||
}}
|
||||
className={`w-full flex items-center justify-between font-serif text-gray-500 hover:text-gray-700 transition-colors ${buttonTextClass}`}
|
||||
>
|
||||
<span className="flex items-baseline min-w-0">
|
||||
<span className="truncate">{label}</span>
|
||||
{isStreaming && (
|
||||
<span className="inline-flex ml-1 shrink-0 items-baseline">
|
||||
<span className="w-0.5 h-0.5 rounded-full bg-gray-400 mr-0.5 animate-[bounce_1.4s_infinite_0s]" />
|
||||
<span className="w-0.5 h-0.5 rounded-full bg-gray-400 mr-0.5 animate-[bounce_1.4s_infinite_0.2s]" />
|
||||
<span className="w-0.5 h-0.5 rounded-full bg-gray-400 animate-[bounce_1.4s_infinite_0.4s]" />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<ChevronDown
|
||||
size={12}
|
||||
className={`relative top-px shrink-0 ml-2 transition-transform duration-200 ${isOpen ? "" : "-rotate-90"}`}
|
||||
/>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className={`mt-3 flex flex-col ${childrenGapClass}`}>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,91 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Folder, Search, X } from "lucide-react";
|
||||
import type { Project } from "./types";
|
||||
|
||||
interface Props {
|
||||
projects: Project[];
|
||||
loading: boolean;
|
||||
selectedId: string | null;
|
||||
onSelect: (id: string | null) => void;
|
||||
}
|
||||
|
||||
export function ProjectPicker({ projects, loading, selectedId, onSelect }: Props) {
|
||||
const [search, setSearch] = useState("");
|
||||
const q = search.toLowerCase().trim();
|
||||
const filtered = q ? projects.filter((p) => p.name.toLowerCase().includes(q)) : projects;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="pt-1 pb-2">
|
||||
<div className="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-3 py-2">
|
||||
<Search className="h-3.5 w-3.5 text-gray-400 shrink-0" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search projects…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="flex-1 bg-transparent text-sm text-gray-700 placeholder:text-gray-400 outline-none"
|
||||
autoFocus
|
||||
/>
|
||||
{search && (
|
||||
<button onClick={() => setSearch("")} className="text-gray-400 hover:text-gray-600">
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto pb-2">
|
||||
{loading ? (
|
||||
<div className="rounded-sm border border-gray-100 overflow-hidden">
|
||||
<div className="flex items-center px-2 py-2">
|
||||
<div className="h-3 w-14 rounded bg-gray-200 animate-pulse" />
|
||||
</div>
|
||||
{[65, 45, 80, 55, 70].map((w, i) => (
|
||||
<div key={i} className="flex items-center gap-2 px-2 py-2">
|
||||
<div className="h-3.5 w-3.5 rounded-full border border-gray-200 shrink-0" />
|
||||
<div className="h-3.5 w-3.5 rounded bg-gray-200 animate-pulse shrink-0" />
|
||||
<div className="h-3 rounded bg-gray-200 animate-pulse" style={{ width: `${w}%` }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<p className="text-center text-sm text-gray-400 py-8">
|
||||
{q ? "No matches found" : "No projects yet"}
|
||||
</p>
|
||||
) : (
|
||||
<div className="rounded-sm border border-gray-100 overflow-hidden">
|
||||
<div className="flex items-center justify-between px-2 py-2">
|
||||
<p className="text-xs font-medium text-gray-400">Projects</p>
|
||||
</div>
|
||||
<div className="space-y-px">
|
||||
{filtered.map((project) => {
|
||||
const isSelected = selectedId === project.id;
|
||||
return (
|
||||
<button
|
||||
key={project.id}
|
||||
onClick={() => onSelect(isSelected ? null : project.id)}
|
||||
className={`w-full flex items-center gap-2 px-2 py-2 text-xs transition-colors text-left ${isSelected ? "bg-gray-100" : "hover:bg-gray-50"}`}
|
||||
>
|
||||
<span className={`shrink-0 h-3.5 w-3.5 rounded-full border flex items-center justify-center ${isSelected ? "bg-gray-900 border-gray-900" : "border-gray-300"}`}>
|
||||
{isSelected && <span className="h-1.5 w-1.5 rounded-full bg-white" />}
|
||||
</span>
|
||||
<Folder className="h-3.5 w-3.5 shrink-0 text-gray-400" />
|
||||
<span className={`flex-1 truncate ${isSelected ? "text-gray-900 font-medium" : "text-gray-700"}`}>
|
||||
{project.name}
|
||||
{project.cm_number && (
|
||||
<span className="ml-1 font-normal text-gray-400">(#{project.cm_number})</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="shrink-0 text-gray-400">{project.document_count ?? 0}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,297 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { Minus, RectangleHorizontal, Rows3 } from "lucide-react";
|
||||
import { CiteButton } from "@/components/ui/cite-button";
|
||||
|
||||
export type RelevantQuoteItem = {
|
||||
id: string;
|
||||
quote: string;
|
||||
eyebrow?: string | null;
|
||||
inlineDetail?: string | null;
|
||||
detail?: string | null;
|
||||
citationText?: string | null;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
quotes: RelevantQuoteItem[];
|
||||
error?: string | null;
|
||||
isLoading?: boolean;
|
||||
activeQuoteId?: string | null;
|
||||
currentIndex?: number;
|
||||
citationRef?: number;
|
||||
citationText?: string;
|
||||
onSelect?: (quote: RelevantQuoteItem, index: number) => void;
|
||||
onIndexChange?: (index: number) => void;
|
||||
}
|
||||
|
||||
export function RelevantQuotes({
|
||||
quotes,
|
||||
error = null,
|
||||
isLoading = false,
|
||||
activeQuoteId = null,
|
||||
currentIndex = 0,
|
||||
citationRef,
|
||||
citationText,
|
||||
onSelect,
|
||||
onIndexChange,
|
||||
}: Props) {
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
const [viewMode, setViewMode] = useState<"single" | "list">("single");
|
||||
const hasMultipleQuotes = quotes.length > 1;
|
||||
const currentQuote = quotes[currentIndex];
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasMultipleQuotes && viewMode === "list") {
|
||||
setViewMode("single");
|
||||
}
|
||||
}, [hasMultipleQuotes, viewMode]);
|
||||
|
||||
return (
|
||||
<div className="px-3">
|
||||
<div className="rounded-lg border border-gray-200">
|
||||
<div className="flex h-10 items-center justify-between px-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-xs font-medium text-gray-700">
|
||||
{typeof citationRef === "number"
|
||||
? `Citation ${citationRef}`
|
||||
: "Citation"}
|
||||
</p>
|
||||
{hasMultipleQuotes && (
|
||||
<div className="flex items-center gap-1">
|
||||
{quotes.map((quote, index) => (
|
||||
<button
|
||||
key={quote.id}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onIndexChange?.(index)
|
||||
}
|
||||
className={`flex h-4 w-4 items-center justify-center rounded-full text-[9px] transition-colors ${
|
||||
currentIndex === index
|
||||
? "bg-white font-medium text-gray-800 shadow-[0_1px_3px_rgba(0,0,0,0.22)]"
|
||||
: "bg-gray-200 text-gray-500 hover:bg-gray-300 hover:text-gray-700"
|
||||
}`}
|
||||
>
|
||||
{index + 1}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{currentQuote && (
|
||||
<CiteButton
|
||||
quoteText={currentQuote.quote}
|
||||
citationText={
|
||||
currentQuote.citationText ??
|
||||
citationText ??
|
||||
""
|
||||
}
|
||||
className="rounded-sm bg-white px-2 h-6 text-gray-600 shadow-[0_1px_3px_rgba(0,0,0,0.22)] hover:bg-gray-50"
|
||||
showText
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={`relative flex h-6 items-center justify-start gap-1 rounded-sm bg-gray-200 p-1 ${
|
||||
hasMultipleQuotes ? "w-16" : "w-11"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`absolute top-1 h-4 w-4 rounded bg-white shadow-sm transition-all ${
|
||||
!isExpanded
|
||||
? "left-1"
|
||||
: hasMultipleQuotes &&
|
||||
viewMode === "list"
|
||||
? "left-11"
|
||||
: "left-6"
|
||||
}`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsExpanded(false)}
|
||||
className={`relative z-10 flex h-4 w-4 items-center justify-center rounded ${
|
||||
!isExpanded
|
||||
? "text-gray-800"
|
||||
: "text-gray-500 hover:text-gray-700"
|
||||
}`}
|
||||
title="Minimize"
|
||||
>
|
||||
<Minus className="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsExpanded(true);
|
||||
setViewMode("single");
|
||||
}}
|
||||
className={`relative z-10 flex h-4 w-4 items-center justify-center rounded ${
|
||||
isExpanded && viewMode === "single"
|
||||
? "text-gray-800"
|
||||
: "text-gray-500 hover:text-gray-700"
|
||||
}`}
|
||||
title="Single quote"
|
||||
>
|
||||
<RectangleHorizontal className="h-3 w-3" />
|
||||
</button>
|
||||
{hasMultipleQuotes && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsExpanded(true);
|
||||
setViewMode("list");
|
||||
}}
|
||||
className={`relative z-10 flex h-4 w-4 items-center justify-center rounded ${
|
||||
isExpanded && viewMode === "list"
|
||||
? "text-gray-800"
|
||||
: "text-gray-500 hover:text-gray-700"
|
||||
}`}
|
||||
title="Quote list"
|
||||
>
|
||||
<Rows3 className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div className="px-2 pb-2">
|
||||
{isLoading ? (
|
||||
<RelevantQuoteSkeleton />
|
||||
) : error ? (
|
||||
<RelevantQuoteMessage tone="error">
|
||||
{error}
|
||||
</RelevantQuoteMessage>
|
||||
) : quotes.length > 0 ? (
|
||||
viewMode === "list" ? (
|
||||
<div className="space-y-2">
|
||||
{quotes.map((quote, index) => (
|
||||
<QuoteItem
|
||||
key={quote.id}
|
||||
quote={quote}
|
||||
isActive={
|
||||
activeQuoteId === quote.id
|
||||
}
|
||||
onClick={() =>
|
||||
onSelect?.(quote, index)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : currentQuote ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<QuoteItem
|
||||
quote={currentQuote}
|
||||
isActive={
|
||||
activeQuoteId === currentQuote.id
|
||||
}
|
||||
onClick={() =>
|
||||
onSelect?.(
|
||||
currentQuote,
|
||||
currentIndex,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : null
|
||||
) : (
|
||||
<RelevantQuoteMessage>
|
||||
No relevant quotes.
|
||||
</RelevantQuoteMessage>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RelevantQuoteSkeleton() {
|
||||
return (
|
||||
<div className="animate-pulse rounded-md border border-gray-200 bg-gray-50 px-3 py-2.5">
|
||||
<div className="h-3 w-28 rounded bg-gray-200" />
|
||||
<div className="mt-2.5 h-3 w-full rounded bg-gray-200" />
|
||||
<div className="mt-2 h-3 w-11/12 rounded bg-gray-200" />
|
||||
<div className="mt-2 h-3 w-2/3 rounded bg-gray-200" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RelevantQuoteMessage({
|
||||
children,
|
||||
tone = "neutral",
|
||||
}: {
|
||||
children: ReactNode;
|
||||
tone?: "neutral" | "error";
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-md border border-gray-200 bg-gray-50 px-3 py-2.5">
|
||||
<p
|
||||
className={`font-serif text-sm leading-6 ${
|
||||
tone === "error" ? "text-red-700" : "text-gray-600"
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QuoteItem({
|
||||
quote,
|
||||
isActive,
|
||||
onClick,
|
||||
}: {
|
||||
quote: RelevantQuoteItem;
|
||||
isActive: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`w-full rounded-md border px-3 py-2.5 text-left transition-colors ${
|
||||
isActive
|
||||
? "border-blue-300 bg-blue-50"
|
||||
: "border-gray-200 bg-gray-50 hover:border-blue-300 hover:bg-blue-50/50"
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{quote.eyebrow && (
|
||||
<p
|
||||
className={`font-serif text-xs ${
|
||||
isActive ? "text-blue-900" : "text-gray-500"
|
||||
}`}
|
||||
>
|
||||
{quote.eyebrow}
|
||||
</p>
|
||||
)}
|
||||
<p
|
||||
className={`font-serif text-sm leading-6 ${
|
||||
isActive ? "text-blue-950" : "text-gray-700"
|
||||
}`}
|
||||
>
|
||||
“{quote.quote.replace(/"/g, "'")}”
|
||||
{quote.inlineDetail && (
|
||||
<span
|
||||
className={`text-sm ${
|
||||
isActive ? "text-blue-900" : "text-gray-500"
|
||||
}`}
|
||||
>
|
||||
{" "}
|
||||
({quote.inlineDetail})
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
{quote.detail && (
|
||||
<p
|
||||
className={`font-serif text-xs ${
|
||||
isActive ? "text-blue-900" : "text-gray-500"
|
||||
}`}
|
||||
>
|
||||
{quote.detail}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,91 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
onCommit: (newValue: string) => void;
|
||||
suffix?: React.ReactNode;
|
||||
}
|
||||
|
||||
type CaretDocument = Document & {
|
||||
caretPositionFromPoint?: (
|
||||
x: number,
|
||||
y: number,
|
||||
) => { offset: number } | null;
|
||||
caretRangeFromPoint?: (x: number, y: number) => Range | null;
|
||||
};
|
||||
|
||||
export function RenameableTitle({ value, onCommit, suffix }: Props) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState("");
|
||||
const caretPos = useRef<number | null>(null);
|
||||
const escaped = useRef(false);
|
||||
const committed = useRef(false);
|
||||
|
||||
function startEditing(e: React.MouseEvent) {
|
||||
const doc = document as CaretDocument;
|
||||
const caret = doc.caretPositionFromPoint?.(e.clientX, e.clientY);
|
||||
const range = !caret && doc.caretRangeFromPoint?.(e.clientX, e.clientY);
|
||||
caretPos.current = caret
|
||||
? caret.offset
|
||||
: range
|
||||
? range.startOffset
|
||||
: null;
|
||||
escaped.current = false;
|
||||
committed.current = false;
|
||||
setDraft(value);
|
||||
setEditing(true);
|
||||
}
|
||||
|
||||
function commit() {
|
||||
if (committed.current) return;
|
||||
if (escaped.current) {
|
||||
escaped.current = false;
|
||||
return;
|
||||
}
|
||||
committed.current = true;
|
||||
setEditing(false);
|
||||
onCommit(draft.trim());
|
||||
}
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<input
|
||||
ref={(el) => {
|
||||
if (!el) return;
|
||||
el.focus();
|
||||
if (caretPos.current !== null) {
|
||||
el.setSelectionRange(caretPos.current, caretPos.current);
|
||||
}
|
||||
}}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
commit();
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
escaped.current = true;
|
||||
committed.current = true;
|
||||
setEditing(false);
|
||||
}
|
||||
}}
|
||||
onBlur={commit}
|
||||
className="text-gray-900 bg-transparent outline-none min-w-0"
|
||||
style={{ width: `${draft.length + 1}ch` }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className="inline-block cursor-text text-gray-900 transition-colors hover:text-gray-600"
|
||||
onClick={startEditing}
|
||||
>
|
||||
{value}
|
||||
{suffix}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
@ -35,6 +35,7 @@ interface Props {
|
|||
onNewSubfolder?: () => void;
|
||||
deleting?: boolean;
|
||||
deleteDisabled?: boolean;
|
||||
onEditDetails?: () => void;
|
||||
onRename?: () => void;
|
||||
onUpdateCmNumber?: () => void;
|
||||
newSubfolderLabel?: string;
|
||||
|
|
@ -53,6 +54,7 @@ export function RowActionMenuItems({
|
|||
onNewSubfolder,
|
||||
deleting,
|
||||
deleteDisabled = false,
|
||||
onEditDetails,
|
||||
onRename,
|
||||
onUpdateCmNumber,
|
||||
newSubfolderLabel = "New subfolder",
|
||||
|
|
@ -80,6 +82,15 @@ export function RowActionMenuItems({
|
|||
{renameLabel}
|
||||
</button>
|
||||
)}
|
||||
{onEditDetails && (
|
||||
<button
|
||||
onClick={() => { onClose(); onEditDetails(); }}
|
||||
className={`flex items-center gap-2 w-full px-3 py-2 text-xs text-gray-600 ${GLASS_MENU_ITEM}`}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
Edit details
|
||||
</button>
|
||||
)}
|
||||
{onUpdateCmNumber && (
|
||||
<button
|
||||
onClick={() => { onClose(); onUpdateCmNumber(); }}
|
||||
|
|
@ -131,7 +142,7 @@ export function RowActionMenuItems({
|
|||
className={`flex items-center gap-2 w-full px-3 py-2 text-xs text-gray-600 ${GLASS_MENU_ITEM}`}
|
||||
>
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
Unhide
|
||||
Activate
|
||||
</button>
|
||||
)}
|
||||
{onHide && (
|
||||
|
|
@ -140,7 +151,7 @@ export function RowActionMenuItems({
|
|||
className={`flex items-center gap-2 w-full px-3 py-2 text-xs text-gray-600 ${GLASS_MENU_ITEM}`}
|
||||
>
|
||||
<EyeOff className="h-3.5 w-3.5" />
|
||||
Hide
|
||||
Deactivate
|
||||
</button>
|
||||
)}
|
||||
{onDelete && (
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import {
|
|||
} from "@/components/ui/dropdown-menu";
|
||||
import { useChatHistoryContext } from "@/app/contexts/ChatHistoryContext";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { OwnerOnlyModal } from "@/app/components/shared/OwnerOnlyModal";
|
||||
import { OwnerOnlyPopup } from "@/app/components/popups/OwnerOnlyPopup";
|
||||
import type { Chat } from "@/app/components/shared/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
|
|
@ -146,7 +146,7 @@ export function SidebarChatItem({ chat, isActive, onSelect, projectName }: Props
|
|||
</DropdownMenu>
|
||||
</>
|
||||
)}
|
||||
<OwnerOnlyModal
|
||||
<OwnerOnlyPopup
|
||||
open={!!ownerOnlyAction}
|
||||
action={ownerOnlyAction ?? undefined}
|
||||
onClose={() => setOwnerOnlyAction(null)}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type HTMLAttributes,
|
||||
type MouseEvent,
|
||||
|
|
@ -45,13 +46,32 @@ export function TableScrollArea({
|
|||
children,
|
||||
className,
|
||||
innerClassName,
|
||||
}: DivProps & { innerClassName?: string }) {
|
||||
header,
|
||||
}: DivProps & { innerClassName?: string; header?: ReactNode }) {
|
||||
const bodyRef = useRef<HTMLDivElement>(null);
|
||||
const headerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
function syncHeader() {
|
||||
if (headerRef.current && bodyRef.current) {
|
||||
headerRef.current.scrollLeft = bodyRef.current.scrollLeft;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("w-full min-h-0 flex-1 overflow-auto", className)}>
|
||||
<div className={cn("w-full min-h-0 flex-1 flex flex-col overflow-hidden", className)}>
|
||||
{header !== undefined && (
|
||||
<div ref={headerRef} className="shrink-0 overflow-hidden">
|
||||
{header}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={cn("flex min-h-full min-w-max flex-col", innerClassName)}
|
||||
ref={bodyRef}
|
||||
className="min-h-0 flex-1 overflow-auto"
|
||||
onScroll={header !== undefined ? syncHeader : undefined}
|
||||
>
|
||||
{children}
|
||||
<div className={cn("flex min-h-full min-w-max flex-col", innerClassName)}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -61,7 +81,7 @@ export function TableHeaderRow({ children, className, ...props }: DivProps) {
|
|||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-8 items-center border-b border-gray-200 pr-3 text-xs font-medium text-gray-500 select-none md:pr-10",
|
||||
"sticky top-0 z-[70] flex h-8 items-center border-b border-gray-200 bg-[#fafbfc] pr-3 text-xs font-medium text-gray-500 select-none md:pr-10",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -176,7 +196,7 @@ export function TableStickyCell({
|
|||
widthClassName,
|
||||
bgClassName,
|
||||
header
|
||||
? "items-center self-stretch"
|
||||
? "z-[80] items-center self-stretch"
|
||||
: "py-2 transition-colors",
|
||||
!header && hover && "group-hover:bg-gray-100",
|
||||
className,
|
||||
|
|
|
|||
|
|
@ -1,123 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Upload } from "lucide-react";
|
||||
import { listDocumentVersions } from "@/app/lib/mikeApi";
|
||||
import type { Document } from "./types";
|
||||
import { Modal } from "./Modal";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
doc: Document | null;
|
||||
onSubmit: (file: File, filename: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function UploadNewVersionModal({ open, onClose, doc, onSubmit }: Props) {
|
||||
const [name, setName] = useState("");
|
||||
const [stagedFile, setStagedFile] = useState<File | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [currentVersion, setCurrentVersion] = useState<number | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !doc) return;
|
||||
setName(doc.filename);
|
||||
setStagedFile(null);
|
||||
setSubmitting(false);
|
||||
setCurrentVersion(null);
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const { current_version_id, versions } =
|
||||
await listDocumentVersions(doc.id);
|
||||
const current = versions.find(
|
||||
(v) => v.id === current_version_id,
|
||||
);
|
||||
const initial =
|
||||
(current?.filename && current.filename.trim()) ||
|
||||
doc.filename;
|
||||
if (!cancelled) {
|
||||
setName(initial);
|
||||
setCurrentVersion(current?.version_number ?? null);
|
||||
}
|
||||
} catch {
|
||||
/* keep fallback */
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, doc]);
|
||||
|
||||
if (!open || !doc) return null;
|
||||
|
||||
const accept = ".pdf,.docx,.doc";
|
||||
|
||||
function handleFilePick(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0] ?? null;
|
||||
setStagedFile(file);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!stagedFile || submitting || !doc) return;
|
||||
const finalName = name.trim() || doc.filename;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await onSubmit(stagedFile, finalName);
|
||||
onClose();
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
breadcrumbs={["Upload new version", doc.filename]}
|
||||
secondaryAction={{
|
||||
label: stagedFile ? "Change file" : "Upload",
|
||||
icon: <Upload className="h-3.5 w-3.5" />,
|
||||
onClick: () => fileInputRef.current?.click(),
|
||||
disabled: submitting,
|
||||
}}
|
||||
primaryAction={{
|
||||
label: submitting ? "Saving…" : "Save",
|
||||
onClick: handleSubmit,
|
||||
disabled: !stagedFile || submitting,
|
||||
}}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={accept}
|
||||
className="hidden"
|
||||
onChange={handleFilePick}
|
||||
/>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">
|
||||
New version name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Version name"
|
||||
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-gray-400"
|
||||
/>
|
||||
<div className="mt-2 text-xs text-gray-500">
|
||||
Current Version:{" "}
|
||||
<span className="text-gray-700 font-medium">
|
||||
{currentVersion ?? "—"}
|
||||
</span>
|
||||
</div>
|
||||
{stagedFile && (
|
||||
<div className="mt-2 text-xs text-gray-500 truncate">
|
||||
New Version File:{" "}
|
||||
<span className="text-gray-700">{stagedFile.name}</span>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,117 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { createPortal } from "react-dom";
|
||||
import type { ReactNode } from "react";
|
||||
import { AlertCircle, X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface WarningPopupAction {
|
||||
label: ReactNode;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface WarningPopupProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title?: ReactNode;
|
||||
message?: ReactNode;
|
||||
children?: ReactNode;
|
||||
icon?: ReactNode;
|
||||
primaryAction?: WarningPopupAction;
|
||||
secondaryAction?: WarningPopupAction;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function WarningPopup({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
message,
|
||||
children,
|
||||
icon,
|
||||
primaryAction,
|
||||
secondaryAction,
|
||||
className,
|
||||
}: WarningPopupProps) {
|
||||
if (!open) return null;
|
||||
|
||||
const warningIcon = icon ?? (
|
||||
<AlertCircle className="h-3 w-3 shrink-0 text-red-600" />
|
||||
);
|
||||
|
||||
return createPortal(
|
||||
<div className="pointer-events-none fixed left-1/2 top-5 z-[220] w-[min(92vw,520px)] -translate-x-1/2 px-4">
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-auto flex items-start gap-2 rounded-2xl border border-white/70 bg-red-50/75 px-3 py-2 text-xs shadow-[0_4px_12px_rgba(15,23,42,0.11),inset_0_1px_0_rgba(255,255,255,0.9),inset_0_-6px_12px_rgba(255,255,255,0.2)] backdrop-blur-2xl",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0 flex-1 self-center text-red-600">
|
||||
{title && (
|
||||
<div className="flex items-center gap-1.5 font-medium mb-1">
|
||||
{warningIcon}
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
{message && (
|
||||
<div
|
||||
className={cn(!title && "flex items-start gap-1.5")}
|
||||
>
|
||||
{!title && warningIcon}
|
||||
<span className="min-w-0">{message}</span>
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
{(primaryAction || secondaryAction) && (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
{secondaryAction && (
|
||||
<WarningPopupButton action={secondaryAction} />
|
||||
)}
|
||||
{primaryAction && (
|
||||
<WarningPopupButton
|
||||
action={primaryAction}
|
||||
primary
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="shrink-0 text-red-700 transition-colors hover:text-red-500"
|
||||
aria-label="Dismiss warning"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
function WarningPopupButton({
|
||||
action,
|
||||
primary = false,
|
||||
}: {
|
||||
action: WarningPopupAction;
|
||||
primary?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={action.onClick}
|
||||
disabled={action.disabled}
|
||||
className={cn(
|
||||
"rounded-lg px-3 py-1 text-xs font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-40",
|
||||
primary
|
||||
? "bg-gray-900 text-white hover:bg-gray-700"
|
||||
: "text-gray-700 hover:bg-white/70",
|
||||
)}
|
||||
>
|
||||
{action.label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,130 +0,0 @@
|
|||
const HIGHLIGHT_CLASS = "docx-text-highlight";
|
||||
const IGNORED_TEXT_SELECTOR = ".star-pagination,.case-page-number";
|
||||
|
||||
function onlyLetters(s: string): string {
|
||||
return s.replace(/[^a-zA-Z0-9]/g, "").toLowerCase();
|
||||
}
|
||||
|
||||
function toOrigPos(text: string, strippedPos: number): number {
|
||||
let count = 0;
|
||||
for (let k = 0; k < text.length; k++) {
|
||||
if (/[a-zA-Z0-9]/.test(text[k])) {
|
||||
if (count === strippedPos) return k;
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return text.length;
|
||||
}
|
||||
|
||||
function collectTextNodes(root: HTMLElement): Text[] {
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
|
||||
acceptNode(node: Node) {
|
||||
const p = node.parentElement;
|
||||
if (!p) return NodeFilter.FILTER_REJECT;
|
||||
const tag = p.tagName;
|
||||
if (tag === "STYLE" || tag === "SCRIPT")
|
||||
return NodeFilter.FILTER_REJECT;
|
||||
if (p.closest(IGNORED_TEXT_SELECTOR))
|
||||
return NodeFilter.FILTER_REJECT;
|
||||
return NodeFilter.FILTER_ACCEPT;
|
||||
},
|
||||
});
|
||||
const out: Text[] = [];
|
||||
let cur = walker.nextNode() as Text | null;
|
||||
while (cur) {
|
||||
out.push(cur);
|
||||
cur = walker.nextNode() as Text | null;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function clearDocxQuoteHighlights(root: HTMLElement): void {
|
||||
root.querySelectorAll(`.${HIGHLIGHT_CLASS}`).forEach((span) => {
|
||||
const parent = span.parentNode;
|
||||
if (!parent) return;
|
||||
while (span.firstChild) parent.insertBefore(span.firstChild, span);
|
||||
parent.removeChild(span);
|
||||
});
|
||||
root.normalize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Highlight the given quote text inside `root` (a docx-preview output).
|
||||
* Quote is split on ellipsis variants; each segment is located via
|
||||
* letters-only substring matching, so whitespace/punctuation differences
|
||||
* between the LLM's quote and the rendered text don't break matching.
|
||||
*
|
||||
* Returns the first highlight span if any match was found, for
|
||||
* scroll-into-view by the caller.
|
||||
*/
|
||||
export function highlightDocxQuote(
|
||||
root: HTMLElement,
|
||||
quote: string,
|
||||
): HTMLElement | null {
|
||||
clearDocxQuoteHighlights(root);
|
||||
if (!quote) return null;
|
||||
const segments = quote
|
||||
.split(/\.{3}|…/)
|
||||
.map(onlyLetters)
|
||||
.filter((s) => s.length > 0);
|
||||
if (segments.length === 0) return null;
|
||||
|
||||
const textNodes = collectTextNodes(root);
|
||||
const nodeStartInFull: number[] = [];
|
||||
const nodeStrippedLen: number[] = [];
|
||||
let fullStripped = "";
|
||||
for (const node of textNodes) {
|
||||
const stripped = onlyLetters(node.data);
|
||||
nodeStartInFull.push(fullStripped.length);
|
||||
nodeStrippedLen.push(stripped.length);
|
||||
fullStripped += stripped;
|
||||
}
|
||||
|
||||
type Range = { nodeIdx: number; origStart: number; origEnd: number };
|
||||
const ranges: Range[] = [];
|
||||
|
||||
for (const segment of segments) {
|
||||
const searchKey = segment.slice(0, 30);
|
||||
const matchPos = fullStripped.indexOf(searchKey);
|
||||
if (matchPos < 0) continue;
|
||||
const matchEnd = matchPos + segment.length;
|
||||
|
||||
for (let i = 0; i < textNodes.length; i++) {
|
||||
const start = nodeStartInFull[i];
|
||||
const end = start + nodeStrippedLen[i];
|
||||
if (matchPos >= end || matchEnd <= start) continue;
|
||||
|
||||
const localStart = Math.max(0, matchPos - start);
|
||||
const localEnd = Math.min(nodeStrippedLen[i], matchEnd - start);
|
||||
const text = textNodes[i].data;
|
||||
const origStart = toOrigPos(text, localStart);
|
||||
const origEnd = toOrigPos(text, localEnd);
|
||||
if (origStart >= origEnd) continue;
|
||||
ranges.push({ nodeIdx: i, origStart, origEnd });
|
||||
}
|
||||
}
|
||||
|
||||
if (ranges.length === 0) return null;
|
||||
|
||||
// Apply in reverse document order so splits don't shift earlier ranges.
|
||||
ranges.sort((a, b) => {
|
||||
if (a.nodeIdx !== b.nodeIdx) return b.nodeIdx - a.nodeIdx;
|
||||
return b.origStart - a.origStart;
|
||||
});
|
||||
|
||||
const spans: HTMLElement[] = [];
|
||||
for (const r of ranges) {
|
||||
const node = textNodes[r.nodeIdx];
|
||||
const mid = node.splitText(r.origStart);
|
||||
mid.splitText(r.origEnd - r.origStart);
|
||||
const span = document.createElement("span");
|
||||
span.className = HIGHLIGHT_CLASS;
|
||||
mid.parentNode?.insertBefore(span, mid);
|
||||
span.appendChild(mid);
|
||||
spans.push(span);
|
||||
}
|
||||
|
||||
// Because we processed ranges in reverse order, the earliest-in-document
|
||||
// highlight is the last one we pushed.
|
||||
return spans[spans.length - 1] ?? null;
|
||||
}
|
||||
|
|
@ -1,124 +0,0 @@
|
|||
let pdfjsLib: typeof import("pdfjs-dist") | null = null;
|
||||
|
||||
export async function getPdfJs() {
|
||||
if (pdfjsLib) return pdfjsLib;
|
||||
pdfjsLib = await import("pdfjs-dist");
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
|
||||
"pdfjs-dist/build/pdf.worker.min.mjs",
|
||||
import.meta.url,
|
||||
).toString();
|
||||
return pdfjsLib;
|
||||
}
|
||||
|
||||
export const STANDARD_FONT_DATA_URL =
|
||||
"https://unpkg.com/pdfjs-dist@4.10.38/standard_fonts/";
|
||||
|
||||
const HIGHLIGHT_CLASS = "pdf-text-highlight";
|
||||
const ORIGINAL_TEXT_ATTR = "data-original-text";
|
||||
|
||||
// Strip everything except alphanumerics (a-z A-Z 0-9) for robust matching
|
||||
function onlyLetters(s: string): string {
|
||||
return s.replace(/[^a-zA-Z0-9]/g, "").toLowerCase();
|
||||
}
|
||||
|
||||
function escapeHtml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
// Given a position in the stripped (letters-only) version of `original`,
|
||||
// return the corresponding index in `original`.
|
||||
function strippedPosToOriginal(original: string, strippedPos: number): number {
|
||||
let count = 0;
|
||||
for (let i = 0; i < original.length; i++) {
|
||||
if (/[a-zA-Z0-9]/.test(original[i])) {
|
||||
if (count === strippedPos) return i;
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return original.length;
|
||||
}
|
||||
|
||||
export function clearHighlights(textDivs: HTMLElement[]) {
|
||||
for (const div of textDivs) {
|
||||
if (div.hasAttribute(ORIGINAL_TEXT_ATTR)) {
|
||||
div.textContent = div.getAttribute(ORIGINAL_TEXT_ATTR)!;
|
||||
div.removeAttribute(ORIGINAL_TEXT_ATTR);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function highlightQuote(
|
||||
textDivs: HTMLElement[],
|
||||
quote: string,
|
||||
): Promise<boolean> {
|
||||
clearHighlights(textDivs);
|
||||
|
||||
// Split on ellipsis variants to highlight each segment separately
|
||||
const segments = quote
|
||||
.split(/\.{3}|…/)
|
||||
.map((s) => onlyLetters(s))
|
||||
.filter((s) => s.length > 0);
|
||||
|
||||
// Build the stripped full text and track each div's start position within it.
|
||||
// Also keep original div texts for display.
|
||||
const divOrigTexts: string[] = []; // original text for innerHTML slicing
|
||||
const divStripped: string[] = []; // letters-only version for matching
|
||||
const divStartInFull: number[] = []; // start index in fullStripped
|
||||
let fullStripped = "";
|
||||
|
||||
for (let i = 0; i < textDivs.length; i++) {
|
||||
const orig = textDivs[i].textContent ?? "";
|
||||
divOrigTexts.push(orig);
|
||||
const stripped = onlyLetters(orig);
|
||||
divStripped.push(stripped);
|
||||
divStartInFull.push(fullStripped.length);
|
||||
fullStripped += stripped;
|
||||
}
|
||||
|
||||
// Map: divIndex -> [strippedLocalStart, strippedLocalEnd]
|
||||
const divHighlightRanges = new Map<number, [number, number]>();
|
||||
|
||||
for (const segment of segments) {
|
||||
const searchKey = segment.slice(0, 30);
|
||||
const matchPos = fullStripped.indexOf(searchKey);
|
||||
if (matchPos === -1) {
|
||||
continue;
|
||||
}
|
||||
const matchEnd = matchPos + segment.length;
|
||||
|
||||
for (let i = 0; i < textDivs.length; i++) {
|
||||
const divStart = divStartInFull[i];
|
||||
const divEnd = divStart + divStripped[i].length;
|
||||
if (matchPos >= divEnd || matchEnd <= divStart) continue;
|
||||
|
||||
const localStart = Math.max(0, matchPos - divStart);
|
||||
const localEnd = Math.min(
|
||||
divStripped[i].length,
|
||||
matchEnd - divStart,
|
||||
);
|
||||
divHighlightRanges.set(i, [localStart, localEnd]);
|
||||
}
|
||||
}
|
||||
|
||||
if (divHighlightRanges.size === 0) return false;
|
||||
|
||||
for (const [idx, [strStart, strEnd]] of divHighlightRanges) {
|
||||
const div = textDivs[idx];
|
||||
const orig = divOrigTexts[idx];
|
||||
|
||||
// Map stripped positions back to original character positions
|
||||
const origStart = strippedPosToOriginal(orig, strStart);
|
||||
const origEnd = strippedPosToOriginal(orig, strEnd);
|
||||
|
||||
div.setAttribute(ORIGINAL_TEXT_ATTR, orig);
|
||||
div.innerHTML =
|
||||
escapeHtml(orig.slice(0, origStart)) +
|
||||
`<span class="${HIGHLIGHT_CLASS}">${escapeHtml(orig.slice(origStart, origEnd))}</span>` +
|
||||
escapeHtml(orig.slice(origEnd));
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
|
@ -18,6 +18,7 @@ export interface Project {
|
|||
owner_email?: string | null;
|
||||
name: string;
|
||||
cm_number: string | null;
|
||||
practice: string | null;
|
||||
shared_with: string[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
|
|
@ -36,7 +37,7 @@ export interface Document {
|
|||
filename: string;
|
||||
owner_email?: string | null;
|
||||
owner_display_name?: string | null;
|
||||
file_type: string | null; // pdf | docx | doc
|
||||
file_type: string | null; // pdf | docx | doc | xlsx | xlsm | xls | pptx | ppt
|
||||
storage_path: string | null;
|
||||
pdf_storage_path: string | null;
|
||||
size_bytes: number | null;
|
||||
|
|
@ -105,6 +106,46 @@ export type AssistantEvent =
|
|||
error?: string;
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
| {
|
||||
type: "ask_inputs";
|
||||
items: (
|
||||
| {
|
||||
id: string;
|
||||
kind: "choice";
|
||||
question: string;
|
||||
options: {
|
||||
value: string;
|
||||
}[];
|
||||
allow_other: boolean;
|
||||
other_label: string;
|
||||
response_prefix?: string;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
kind: "documents";
|
||||
document_types: string[];
|
||||
response_prefix?: string;
|
||||
}
|
||||
)[];
|
||||
}
|
||||
| {
|
||||
type: "ask_inputs_response";
|
||||
responses: (
|
||||
| {
|
||||
id: string;
|
||||
kind: "choice";
|
||||
question: string;
|
||||
answer?: string;
|
||||
skipped?: boolean;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
kind: "documents";
|
||||
filenames: string[];
|
||||
skipped?: boolean;
|
||||
}
|
||||
)[];
|
||||
}
|
||||
| { type: "thinking"; isStreaming?: boolean }
|
||||
| {
|
||||
type: "doc_read";
|
||||
|
|
@ -255,12 +296,13 @@ export type CaseCitationQuote = {
|
|||
};
|
||||
|
||||
export interface Message {
|
||||
id?: string;
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
files?: { filename: string; document_id?: string }[];
|
||||
workflow?: { id: string; title: string };
|
||||
model?: string;
|
||||
annotations?: CitationAnnotation[];
|
||||
citations?: Citation[];
|
||||
citationStatus?: "started" | "partial" | "final";
|
||||
events?: AssistantEvent[];
|
||||
/** Set when streaming failed; rendered as a red error block. */
|
||||
|
|
@ -275,9 +317,15 @@ export interface CitationQuote {
|
|||
export type DocumentCitationQuote = {
|
||||
page: number | string;
|
||||
quote: string;
|
||||
/**
|
||||
* Spreadsheet citations are located by cell, not page: `sheet` is the
|
||||
* worksheet name and `cell` is an A1 address or range (e.g. "B7", "B7:C9").
|
||||
*/
|
||||
sheet?: string;
|
||||
cell?: string;
|
||||
};
|
||||
|
||||
export type DocumentCitationAnnotation = {
|
||||
export type DocumentCitation = {
|
||||
type: "citation_data";
|
||||
kind?: "document";
|
||||
ref: number;
|
||||
|
|
@ -286,13 +334,15 @@ export type DocumentCitationAnnotation = {
|
|||
version_id?: string | null;
|
||||
version_number?: number | null;
|
||||
filename: string;
|
||||
/** Legacy single-quote fields. Prefer `quotes` for new annotations. */
|
||||
/** Legacy single-quote fields. Prefer `quotes` for new citations. */
|
||||
page: number | string;
|
||||
quote: string;
|
||||
sheet?: string;
|
||||
cell?: string;
|
||||
quotes?: DocumentCitationQuote[];
|
||||
};
|
||||
|
||||
export type CaseCitationAnnotation = {
|
||||
export type CaseCitation = {
|
||||
type: "citation_data";
|
||||
kind: "case";
|
||||
ref: number;
|
||||
|
|
@ -310,12 +360,49 @@ export type CaseCitationAnnotation = {
|
|||
* anchors. Case citations anchor to a CourtListener cluster and include a
|
||||
* quoted opinion passage.
|
||||
*/
|
||||
export type CitationAnnotation =
|
||||
| DocumentCitationAnnotation
|
||||
| CaseCitationAnnotation;
|
||||
export type Citation =
|
||||
| DocumentCitation
|
||||
| CaseCitation;
|
||||
|
||||
const PAGE_BREAK_SENTINEL = "[[PAGE_BREAK]]";
|
||||
|
||||
export function isSpreadsheetFilename(filename: string): boolean {
|
||||
const ext = filename.split(".").pop()?.toLowerCase();
|
||||
return ext === "xlsx" || ext === "xlsm" || ext === "xls";
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable cell locator for a spreadsheet citation, e.g. "Sheet1!B7".
|
||||
* Falls back to whichever of `sheet`/`cell` is present.
|
||||
*/
|
||||
function formatCellLocator(sheet?: string, cell?: string): string {
|
||||
if (sheet && cell) return `${sheet}!${cell}`;
|
||||
return cell ?? sheet ?? "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Reader-friendly cell locator, e.g. "Sheet1, cell B7" (or "cells B7:C9" for a
|
||||
* range). Unlike `formatCellLocator`, this avoids the Excel `!` notation, which
|
||||
* reads poorly in prose. Used for the single-quote detail shown to the reader;
|
||||
* the machine-style `Sheet1!B7` form is kept where locators are joined together.
|
||||
*/
|
||||
function formatCellLocatorReadable(sheet?: string, cell?: string): string {
|
||||
if (!cell) return sheet ?? "";
|
||||
const cellWord = cell.includes(":") ? "cells" : "cell";
|
||||
const cellPart = `${cellWord} ${cell}`;
|
||||
return sheet ? `${sheet}, ${cellPart}` : cellPart;
|
||||
}
|
||||
|
||||
/** `{sheet, cell}` locators for a citation's quotes (spreadsheet sources). */
|
||||
export function getCitationCells(
|
||||
a: Citation,
|
||||
): { sheet?: string; cell?: string }[] {
|
||||
if (a.kind === "case") return [];
|
||||
return getDocumentCitationQuotes(a)
|
||||
.filter((q) => q.cell || q.sheet)
|
||||
.map((q) => ({ sheet: q.sheet, cell: q.cell }));
|
||||
}
|
||||
|
||||
function expandDocumentQuoteEntry(entry: DocumentCitationQuote): CitationQuote[] {
|
||||
const rangeMatch =
|
||||
typeof entry.page === "string"
|
||||
|
|
@ -339,13 +426,13 @@ function expandDocumentQuoteEntry(entry: DocumentCitationQuote): CitationQuote[]
|
|||
}
|
||||
|
||||
export function getDocumentCitationQuotes(
|
||||
a: CitationAnnotation,
|
||||
a: Citation,
|
||||
): DocumentCitationQuote[] {
|
||||
if (a.kind === "case") return [];
|
||||
if (Array.isArray(a.quotes) && a.quotes.length) {
|
||||
return a.quotes.filter((entry) => entry.quote.trim().length > 0);
|
||||
}
|
||||
return [{ page: a.page, quote: a.quote }];
|
||||
return [{ page: a.page, quote: a.quote, sheet: a.sheet, cell: a.cell }];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -354,36 +441,72 @@ export function getDocumentCitationQuotes(
|
|||
* cross-page citation with page "N-M" and a `[[PAGE_BREAK]]` split yields two.
|
||||
*/
|
||||
export function expandCitationToEntries(
|
||||
a: CitationAnnotation,
|
||||
a: Citation,
|
||||
): CitationQuote[] {
|
||||
if (a.kind === "case") return [];
|
||||
return getDocumentCitationQuotes(a).flatMap(expandDocumentQuoteEntry);
|
||||
}
|
||||
|
||||
/** Format the page(s) of a citation for display, e.g. "Page 3" or "Page 41-42". */
|
||||
export function formatCitationPage(a: CitationAnnotation): string {
|
||||
/**
|
||||
* Format the page(s) of a citation for display, e.g. "Page 3" or "Page 41-42".
|
||||
* Spreadsheets have no meaningful page locator, so this returns "" for them —
|
||||
* callers join with `.filter(Boolean)` so the locator is simply omitted.
|
||||
*/
|
||||
export function formatCitationPage(a: Citation): string {
|
||||
if (a.kind === "case") {
|
||||
return a.citation || a.case_name || `Case ${a.cluster_id}`;
|
||||
}
|
||||
const quotes = getDocumentCitationQuotes(a);
|
||||
// Spreadsheets are located by cell, e.g. "Sheet1!B7" (or several).
|
||||
if (isSpreadsheetFilename(a.filename)) {
|
||||
const cells = Array.from(
|
||||
new Set(
|
||||
quotes.map((q) => formatCellLocator(q.sheet, q.cell)).filter(Boolean),
|
||||
),
|
||||
);
|
||||
return cells.join(", ");
|
||||
}
|
||||
const pages = Array.from(
|
||||
new Set(quotes.map((q) => String(q.page)).filter(Boolean)),
|
||||
);
|
||||
if (pages.length > 1) return `Pages ${pages.join(", ")}`;
|
||||
if (pages.length === 1) return `Page ${pages[0]}`;
|
||||
if (typeof a.page === "string") return `Page ${a.page}`;
|
||||
return `Page ${a.page}`;
|
||||
}
|
||||
|
||||
/** Locator label for a single quote — "Page 3" for docs, "Sheet1, cell B7" for cells. */
|
||||
export function formatCitationQuotePage(
|
||||
a: Citation,
|
||||
page: number | string,
|
||||
quote?: DocumentCitationQuote,
|
||||
): string {
|
||||
if (a.kind !== "case" && isSpreadsheetFilename(a.filename)) {
|
||||
return formatCellLocatorReadable(quote?.sheet, quote?.cell);
|
||||
}
|
||||
return `Page ${page}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reader-friendly version of a single raw quote: replaces [[PAGE_BREAK]] with
|
||||
* "...". Spreadsheet quotes now carry plain cell values, so no stripping.
|
||||
*/
|
||||
export function cleanCitationQuoteText(
|
||||
_a: Citation,
|
||||
rawQuote: string,
|
||||
): string {
|
||||
return rawQuote.replaceAll(PAGE_BREAK_SENTINEL, "...");
|
||||
}
|
||||
|
||||
/** Produce a reader-friendly version of the quote (replaces [[PAGE_BREAK]] with "..."). */
|
||||
export function displayCitationQuote(a: CitationAnnotation): string {
|
||||
export function displayCitationQuote(a: Citation): string {
|
||||
if (a.kind === "case") {
|
||||
return a.quotes
|
||||
.map((q) => q.quote.replaceAll(PAGE_BREAK_SENTINEL, "..."))
|
||||
.join(" / ");
|
||||
}
|
||||
return getDocumentCitationQuotes(a)
|
||||
.map((q) => q.quote.replaceAll(PAGE_BREAK_SENTINEL, "..."))
|
||||
.map((q) => cleanCitationQuoteText(a, q.quote))
|
||||
.filter(Boolean)
|
||||
.join(" / ");
|
||||
}
|
||||
|
||||
|
|
@ -442,6 +565,28 @@ export interface TabularCell {
|
|||
|
||||
// Workflows
|
||||
|
||||
export interface WorkflowOpenSourceSubmission {
|
||||
id: string;
|
||||
status: "pending" | "approved" | "rejected";
|
||||
submitted_at: string;
|
||||
updated_at: string;
|
||||
reviewed_at?: string | null;
|
||||
}
|
||||
|
||||
export interface OpenSourceWorkflowResponse
|
||||
extends WorkflowOpenSourceSubmission {
|
||||
mode: "created" | "updated";
|
||||
}
|
||||
|
||||
export type OpenSourceWorkflowContributorMode = "named" | "anonymous";
|
||||
|
||||
export interface WorkflowContributor {
|
||||
name: string;
|
||||
organisation: string | null;
|
||||
role: string | null;
|
||||
linkedin: string | null;
|
||||
}
|
||||
|
||||
export interface Workflow {
|
||||
id: string;
|
||||
user_id: string | null;
|
||||
|
|
@ -451,10 +596,14 @@ export interface Workflow {
|
|||
columns_config: ColumnConfig[] | null;
|
||||
is_system: boolean;
|
||||
created_at: string;
|
||||
language?: string | null;
|
||||
version?: string | null;
|
||||
practice?: string | null;
|
||||
jurisdictions?: string[] | null;
|
||||
shared_by_name?: string | null;
|
||||
allow_edit?: boolean;
|
||||
is_owner?: boolean;
|
||||
open_source_submission?: WorkflowOpenSourceSubmission | null;
|
||||
}
|
||||
|
||||
// API helpers
|
||||
|
|
|
|||
|
|
@ -42,13 +42,23 @@ export function useDirectoryData(enabled: boolean) {
|
|||
);
|
||||
return Promise.all(ps.map((p) => getProject(p.id))).then(
|
||||
(fullProjects) => {
|
||||
const projectCounts = new Map(
|
||||
ps.map((p) => [p.id, p.document_count ?? 0]),
|
||||
);
|
||||
const projectsWithCounts = fullProjects.map((project) => ({
|
||||
...project,
|
||||
document_count:
|
||||
project.documents?.length ??
|
||||
projectCounts.get(project.id) ??
|
||||
0,
|
||||
}));
|
||||
cache = {
|
||||
standaloneDocuments: sorted,
|
||||
projects: fullProjects,
|
||||
projects: projectsWithCounts,
|
||||
fetchedAt: Date.now(),
|
||||
};
|
||||
setStandaloneDocuments(sorted);
|
||||
setProjects(fullProjects);
|
||||
setProjects(projectsWithCounts);
|
||||
},
|
||||
);
|
||||
})
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue